feat(library): enhance library UI with new tab titles, filtering options, and improved state management

This commit is contained in:
Kingkor Roy Tirtho 2026-10-14 21:52:24 +06:00
parent 5b442a93a9
commit cf9476ab08
17 changed files with 693 additions and 329 deletions

View File

@ -108,6 +108,7 @@ kotlin {
} }
jvm() jvm()
jvmToolchain(21)
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {

View File

@ -25,6 +25,22 @@
<string name="search_clear_history">Clear all history</string> <string name="search_clear_history">Clear all history</string>
<string name="search_filter_placeholder">Search...</string> <string name="search_filter_placeholder">Search...</string>
<!-- Library toolbar -->
<string name="library_screen_title">Your Library</string>
<string name="library_tab_playlists">Playlists</string>
<string name="library_tab_albums">Albums</string>
<string name="library_tab_artists">Artists</string>
<string name="library_tab_local_tracks">Local Tracks</string>
<string name="library_tab_downloads">Downloads</string>
<string name="library_filter_playlists">Search saved playlists…</string>
<string name="library_filter_albums">Search saved albums…</string>
<string name="library_filter_artists">Search saved artists…</string>
<string name="library_filter_local_tracks">Search folders or tracks…</string>
<string name="library_action_search">Filter library</string>
<string name="library_action_close_search">Close filter</string>
<string name="library_action_create_playlist">Create playlist</string>
<string name="library_action_refresh_local_tracks">Refresh local tracks</string>
<string name="accessibility_selected">Selected</string> <string name="accessibility_selected">Selected</string>
<string name="settings_screen_title">Settings</string> <string name="settings_screen_title">Settings</string>

View File

@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -44,16 +37,21 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.composeunstyled.Text
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
@Composable @Composable
fun CollectionView( fun CollectionView(

View File

@ -17,102 +17,192 @@
package dev.krtirtho.spotube.modules.library package dev.krtirtho.spotube.modules.library
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import com.composeunstyled.Text import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import com.composeunstyled.currentWindowWidthBreakpoint
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.isDesktop
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.spotube.millennium.adaptive.WindowBreakpoints
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField
import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(libraryState: LibraryState = koinInject()) {
libraryState: LibraryState = koinInject(), val state by libraryState.uiState.collectAsStateWithLifecycle()
appShellViewModel: AppShellViewModel = koinViewModel() val expandedDesktop = getPlatform().isDesktop() &&
) { (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
val currentTab by libraryState.currentTab.collectAsState() val focusManager = LocalFocusManager.current
val searchMode by libraryState.searchMode.collectAsState() val keyboard = LocalSoftwareKeyboardController.current
val isLargeScreen = appShellViewModel.useSidebar()
Column(modifier = Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Column {
ApplicationMainBar( ApplicationMainBar(
backButton = false, backButton = false,
title = { title = { LibraryToolbarTitle(state, expandedDesktop) },
if (!isLargeScreen) Text("Your Library")
},
actions = { actions = {
if (!isLargeScreen) { LibraryToolbarActions(
MillenniumIconButton( state = state,
icon = Iconsax.IconsaxSearchBroken, expandedDesktop = expandedDesktop,
contentDescription = "Search", onOpenSearch = libraryState::openSearch,
onClick = { onCloseSearch = {
libraryState.setSearchMode(!searchMode) focusManager.clearFocus()
keyboard?.hide()
libraryState.closeSearch()
}, },
style = MillenniumIconButtonStyle.Quiet) onCreatePlaylist = libraryState::showCreatePlaylist,
} onRefresh = libraryState::refreshLocalTracks
)
} }
) )
if (!isLargeScreen) if (!expandedDesktop) {
Column(
modifier = Modifier.padding(horizontal = 12.dp)
) {
Spacer(modifier = Modifier.height(4.dp))
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)) { contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
items(LibraryTab.entries.size) { index -> horizontalArrangement = Arrangement.spacedBy(8.dp)
val tab = LibraryTab.entries[index] ) {
items(LibraryTab.entries) { tab ->
MillenniumButton( MillenniumButton(
onClick = { libraryState.onTabSelected(tab) }, onClick = { libraryState.onTabSelected(tab) },
style = if (currentTab == tab) { style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
MillenniumButtonStyle.Primary ) { Text(stringResource(tab.titleResource)) }
} else {
MillenniumButtonStyle.Secondary
}) {
Text(tab.title)
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
} when (state.currentTab) {
} LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
)
Column( LibraryTab.Albums -> LibraryAlbumsScreen()
modifier = Modifier LibraryTab.Artists -> LibraryArtistsScreen()
.weight(1f) LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
.padding(horizontal = 12.dp)
) {
when (currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Downloads -> DownloadsScreen() LibraryTab.Downloads -> DownloadsScreen()
} }
} }
} }
} }
@Composable
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
if (!expandedDesktop && state.searchMode) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.fillMaxWidth(),
autoFocus = true
)
} else if (!expandedDesktop) {
Text(stringResource(Res.string.library_screen_title))
}
}
@Composable
internal fun RowScope.LibraryToolbarActions(
state: LibraryToolbarUiState,
expandedDesktop: Boolean,
onOpenSearch: () -> Unit,
onCloseSearch: () -> Unit,
onCreatePlaylist: () -> Unit,
onRefresh: () -> Unit
) {
if (!expandedDesktop && state.searchMode) {
MillenniumIconButton(
icon = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.library_action_close_search),
onClick = onCloseSearch,
style = MillenniumIconButtonStyle.Quiet
)
} else {
if (state.canFilter) {
if (expandedDesktop) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.width(300.dp)
)
} else {
MillenniumIconButton(
icon = Iconsax.IconsaxSearchBroken,
contentDescription = stringResource(Res.string.library_action_search),
onClick = onOpenSearch,
style = MillenniumIconButtonStyle.Quiet
)
}
}
if (state.canCreatePlaylist) {
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = onCreatePlaylist,
style = MillenniumIconButtonStyle.Quiet
)
}
if (state.canRefresh) {
MillenniumIconButton(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh,
style = MillenniumIconButtonStyle.Quiet
)
}
}
}
@Composable
private fun LibraryFilterField(
state: TextFieldState,
placeholder: StringResource,
modifier: Modifier,
autoFocus: Boolean = false
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) {
if (autoFocus) focusRequester.requestFocus()
}
MillenniumTextField(
state = state,
placeholder = stringResource(placeholder),
minimumHeight = 36.dp,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester)
)
}

View File

@ -17,17 +17,90 @@
package dev.krtirtho.spotube.modules.library package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class LibraryState { data class LibraryToolbarUiState(
val searchMode = MutableStateFlow(false) val currentTab: LibraryTab = LibraryTab.Playlists,
val searchMode: Boolean = false,
val searchState: TextFieldState = TextFieldState(),
val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false,
val showCreatePlaylist: Boolean = false
)
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryState : ViewModel() {
private val searchMode = MutableStateFlow(false)
val currentTab = MutableStateFlow(LibraryTab.Playlists) val currentTab = MutableStateFlow(LibraryTab.Playlists)
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
private val createPlaylistVisible = MutableStateFlow(false)
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow()
val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode,
createPlaylistVisible
) { (tab, query), searching, creating ->
LibraryToolbarUiState(
currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
searchState = searchStates.getValue(tab),
canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks,
showCreatePlaylist = tab == LibraryTab.Playlists && creating
)
}.stateIn(
viewModelScope, SharingStarted.Eagerly,
LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists))
)
fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() }
fun setQuery(tab: LibraryTab, query: String) {
searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query)
}
fun onTabSelected(tab: LibraryTab) { fun onTabSelected(tab: LibraryTab) {
currentTab.value = tab currentTab.value = tab
createPlaylistVisible.value = false
if (tab == LibraryTab.Downloads) searchMode.value = false
} }
fun setSearchMode(enabled: Boolean) { fun openSearch() {
searchMode.value = enabled if (currentTab.value != LibraryTab.Downloads) searchMode.value = true
}
fun closeSearch() {
setQuery(currentTab.value, "")
searchMode.value = false
}
fun showCreatePlaylist() {
if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true
}
fun dismissCreatePlaylist() {
createPlaylistVisible.value = false
}
fun refreshLocalTracks() {
refreshRequests.tryEmit(Unit)
} }
} }

View File

@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.*
enum class LibraryTab(val title: String, val icon: ImageVector) { enum class LibraryTab(val title: String, val icon: ImageVector) {
Playlists("Playlists", Iconsax.IconsaxMusicDashboard), Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
Albums("Albums", Iconsax.IconsaxCd), Albums("Albums", Iconsax.IconsaxCd),
Artists("Artists", Iconsax.User), Artists("Artists", Iconsax.User),
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary), LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary),
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive), Downloads("Downloads", Iconsax.IconsaxDirectboxReceive);
val titleResource: StringResource
get() = when (this) {
Playlists -> Res.string.library_tab_playlists
Albums -> Res.string.library_tab_albums
Artists -> Res.string.library_tab_artists
LocalTracks -> Res.string.library_tab_local_tracks
Downloads -> Res.string.library_tab_downloads
}
val filterPlaceholder: StringResource
get() = when (this) {
Playlists -> Res.string.library_filter_playlists
Albums -> Res.string.library_filter_albums
Artists -> Res.string.library_filter_artists
LocalTracks -> Res.string.library_filter_local_tracks
Downloads -> Res.string.search_filter_placeholder
}
} }

View File

@ -18,17 +18,12 @@
package dev.krtirtho.spotube.modules.library.album package dev.krtirtho.spotube.modules.library.album
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
@ -43,35 +38,24 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryAlbumsScreen( fun LibraryAlbumsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false,
viewModel: LibraryAlbumsViewModel = koinViewModel() viewModel: LibraryAlbumsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -93,15 +77,6 @@ fun LibraryAlbumsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility(searchMode) {
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved albums..."
)
}
Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryAlbumsState.Loading -> { is LibraryAlbumsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed> override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed> override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryAlbumsViewModel( class LibraryAlbumsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryAlbumsViewModel>() private val logger by injectLogger<LibraryAlbumsViewModel>()
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading) private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
val uiState: StateFlow<LibraryAlbumsState> = _state.asStateFlow() val uiState: StateFlow<LibraryAlbumsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Albums)
) { state, query ->
when (state) {
is LibraryAlbumsState.Data.Loaded -> state.copy(query = query)
is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -113,7 +129,8 @@ class LibraryAlbumsViewModel(
val result = repository.savedAlbums() val result = repository.savedAlbums()
_state.value = LibraryAlbumsState.Data.Loaded( _state.value = LibraryAlbumsState.Data.Loaded(
allItems = result?.items ?: emptyList(), allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load albums" } logger.e(e) { "Failed to load albums" }
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
@ -127,7 +144,8 @@ class LibraryAlbumsViewModel(
_state.value = LibraryAlbumsState.Data.Loaded( _state.value = LibraryAlbumsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()), allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more albums" } logger.e(e) { "Failed to load more albums" }
@ -135,13 +153,7 @@ class LibraryAlbumsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Albums, query)
if (currentState is LibraryAlbumsState.Data) {
_state.value = when (currentState) {
is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query)
is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
} }
fun refresh() { fun refresh() {

View File

@ -18,23 +18,16 @@
package dev.krtirtho.spotube.modules.library.artist package dev.krtirtho.spotube.modules.library.artist
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryArtistsScreen( fun LibraryArtistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false, viewModel: LibraryArtistsViewModel = koinViewModel()
viewModel: LibraryArtistsViewModel = koinViewModel()) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -92,14 +76,6 @@ fun LibraryArtistsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility(searchMode){
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved artists...")
}
Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryArtistsState.Loading -> { is LibraryArtistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -111,13 +87,15 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) { verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(8) { items(8) {
SkeletonTree(true) { SkeletonTree(true) {
AvatarCard( AvatarCard(
title = "Artist Name", title = "Artist Name",
subtitle = "Artist", subtitle = "Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -152,11 +130,13 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) { verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(dataState.items, key = { it.id }) { artist -> items(dataState.items, key = { it.id }) { artist ->
ArtistCard( ArtistCard(
artist = artist, artist = artist,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
} }
// if (state is LibraryArtistsState.Data.LoadingMore) { // if (state is LibraryArtistsState.Data.LoadingMore) {
@ -165,7 +145,8 @@ fun LibraryArtistsScreen(
AvatarCard( AvatarCard(
title = "Artist Name", title = "Artist Name",
subtitle = "Artist", subtitle = "Artist",
imageURL = "https://placehold.co/400x400") imageURL = "https://placehold.co/400x400"
)
} }
} }
// } // }

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryArtistsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(), override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic> override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
@ -59,13 +63,15 @@ sealed interface LibraryArtistsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(), override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic> override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
@ -82,11 +88,21 @@ sealed interface LibraryArtistsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryArtistsViewModel( class LibraryArtistsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryArtistsViewModel>() private val logger by injectLogger<LibraryArtistsViewModel>()
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading) private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
val uiState: StateFlow<LibraryArtistsState> = _state.asStateFlow() val uiState: StateFlow<LibraryArtistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Artists)
) { state, query ->
when (state) {
is LibraryArtistsState.Data.Loaded -> state.copy(query = query)
is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -111,7 +127,8 @@ class LibraryArtistsViewModel(
id = it.id, id = it.id,
name = it.name, name = it.name,
thumbnails = it.thumbnails, thumbnails = it.thumbnails,
externalUri = it.externalUri) externalUri = it.externalUri
)
} ?: emptyList(), } ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination)
}.onFailure { e -> }.onFailure { e ->
@ -129,12 +146,14 @@ class LibraryArtistsViewModel(
id = it.id, id = it.id,
name = it.name, name = it.name,
thumbnails = it.thumbnails, thumbnails = it.thumbnails,
externalUri = it.externalUri) externalUri = it.externalUri
)
} ?: emptyList() } ?: emptyList()
_state.value = LibraryArtistsState.Data.Loaded( _state.value = LibraryArtistsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + newItems, allItems = currentState.allItems + newItems,
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more artists" } logger.e(e) { "Failed to load more artists" }
@ -142,13 +161,7 @@ class LibraryArtistsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Artists, query)
if (currentState is LibraryArtistsState.Data) {
_state.value = when (currentState) {
is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query)
is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
} }
fun refresh() { fun refresh() {

View File

@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -38,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.rememberTextFieldState
import com.composeunstyled.Text import com.composeunstyled.Text
@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.drop
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.Play import dev.krtirtho.spotube.resources.iconsax.Play
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) { fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
val viewModel = koinViewModel<LibraryLocalTracksViewModel>() val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permissionState = rememberLocalMediaPermissionState() val permissionState = rememberLocalMediaPermissionState()
@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
} }
val searchState = rememberTextFieldState(state.query)
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
Column( Column(
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)
AnimatedVisibility(searchMode) { ) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search")
MillenniumTextField(
state = searchState,
modifier = Modifier.weight(1f),
placeholder = if (state.currentFolderPath == null) {
"Search folders or tracks"
} else {
"Search tracks in folder"
})
MillenniumIconButton(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = "Refresh",
onClick = { viewModel.refresh() },
style = MillenniumIconButtonStyle.Quiet)
}
}
if (state.currentFolderPath != null) { if (state.currentFolderPath != null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) { horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.ArrowLeft3, icon = Iconsax.ArrowLeft3,
contentDescription = "Back", contentDescription = "Back",
onClick = viewModel::navigateUp, onClick = viewModel::navigateUp,
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
Text(viewModel.currentFolder()?.name ?: "Folder") Text(viewModel.currentFolder()?.name ?: "Folder")
} }
MillenniumIconButton( MillenniumIconButton(
@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
onClick = { onClick = {
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
}, },
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
} }
} }
@ -151,11 +117,13 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)) { verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Allow audio permission to show local tracks") Text("Allow audio permission to show local tracks")
MillenniumButton( MillenniumButton(
onClick = permissionState.requestPermission, onClick = permissionState.requestPermission,
style = MillenniumButtonStyle.Quiet) { style = MillenniumButtonStyle.Quiet
) {
Text("Grant access") Text("Grant access")
} }
} }
@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
icon = Iconsax.Play, icon = Iconsax.Play,
contentDescription = "Play", contentDescription = "Play",
onClick = { viewModel.playTrack(track.path) }, onClick = { viewModel.playTrack(track.path) },
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
}, },
onClick = { viewModel.playTrack(track.path) }) onClick = { viewModel.playTrack(track.path) })
} }

View File

@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LibraryLocalTracksState( data class LibraryLocalTracksState(
@ -35,22 +39,32 @@ data class LibraryLocalTracksState(
val folders: List<LocalMediaFolder> = emptyList(), val folders: List<LocalMediaFolder> = emptyList(),
val currentFolderPath: String? = null, val currentFolderPath: String? = null,
val query: String = "", val query: String = "",
val error: String? = null) val error: String? = null
)
class LibraryLocalTracksViewModel( class LibraryLocalTracksViewModel(
private val coordinator: LocalMediaLibraryCoordinator, private val coordinator: LocalMediaLibraryCoordinator,
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val audioPlayerQueue: AudioPlayerQueue,
private val libraryState: LibraryState
) : ViewModel() {
private val _state = MutableStateFlow(LibraryLocalTracksState()) private val _state = MutableStateFlow(LibraryLocalTracksState())
val state: StateFlow<LibraryLocalTracksState> = _state.asStateFlow() val state: StateFlow<LibraryLocalTracksState> = combine(
_state, libraryState.queryFlow(LibraryTab.LocalTracks)
) { state, query -> state.copy(query = query) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState())
init { init {
viewModelScope.launch {
libraryState.localRefreshRequests.collect { refresh() }
}
viewModelScope.launch { viewModelScope.launch {
coordinator.cacheState coordinator.cacheState
.collect { cache -> .collect { cache ->
_state.update { _state.update {
it.copy( it.copy(
folders = cache.folders, folders = cache.folders,
error = null) error = null
)
} }
} }
} }
@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
_state.update { it.copy(query = query) } libraryState.setQuery(LibraryTab.LocalTracks, query)
} }
fun openFolder(folderPath: String) { fun openFolder(folderPath: String) {
@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel(
fun playFolder(folderPath: String) { fun playFolder(folderPath: String) {
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
val tracks = filterTracks(folder.tracks, _state.value.query) val tracks = filterTracks(folder.tracks, state.value.query)
if (tracks.isEmpty()) return if (tracks.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel(
entries = tracks.map { it.toQueueEntry() }, entries = tracks.map { it.toQueueEntry() },
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null
)
} }
} }
fun playTrack(trackPath: String) { fun playTrack(trackPath: String) {
val folder = currentFolder() ?: return val folder = currentFolder() ?: return
val tracks = filterTracks(folder.tracks, _state.value.query) val tracks = filterTracks(folder.tracks, state.value.query)
val selectedIndex = tracks.indexOfFirst { it.path == trackPath } val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
if (selectedIndex < 0) return if (selectedIndex < 0) return
@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel(
entries = tracks.map { it.toQueueEntry() }, entries = tracks.map { it.toQueueEntry() },
autoPlay = true, autoPlay = true,
startPosition = selectedIndex, startPosition = selectedIndex,
collectionEntry = null) collectionEntry = null
)
} }
} }
@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel(
duration = durationMs, duration = durationMs,
album = album, album = album,
coverBytes = coverBytes, coverBytes = coverBytes,
url = path) url = path
)
} }
} }

View File

@ -18,68 +18,45 @@
package dev.krtirtho.spotube.modules.library.playlist package dev.krtirtho.spotube.modules.library.playlist
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryPlaylistsScreen( fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false, showCreatePlaylist: Boolean = false,
viewModel: LibraryPlaylistsViewModel = koinViewModel()) { onDismissCreatePlaylist: () -> Unit = {},
viewModel: LibraryPlaylistsViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var showCreatePlaylist by remember { mutableStateOf(false) }
val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = searchMode,
modifier = Modifier.weight(1f)) {
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved playlists...")
}
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = "Create Playlist",
onClick = { showCreatePlaylist = true },
modifier = Modifier.size(48.dp))
}
Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryPlaylistsState.Loading -> { is LibraryPlaylistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen(
PlayableCard( PlayableCard(
title = "Playlist Name", title = "Playlist Name",
subtitle = "By Artist", subtitle = "By Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen(
PlayableCard( PlayableCard(
title = "Playlist Name", title = "Playlist Name",
subtitle = "By Artist", subtitle = "By Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen(
PlaylistFormSheet( PlaylistFormSheet(
visible = showCreatePlaylist, visible = showCreatePlaylist,
onDismiss = { showCreatePlaylist = false }, onDismiss = onDismissCreatePlaylist,
onSubmit = { data -> onSubmit = { data ->
viewModel.createPlaylist( viewModel.createPlaylist(
name = data.name, name = data.name,
@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen(
isPublic = data.isPublic, isPublic = data.isPublic,
isCollaborating = data.isCollaborating, isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64, imageBase64 = data.imageBase64,
trackIds = emptyList()) trackIds = emptyList()
)
}) })
} }
} }

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryPlaylistsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
@ -60,13 +64,15 @@ sealed interface LibraryPlaylistsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryPlaylistsViewModel( class LibraryPlaylistsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryPlaylistsViewModel>() private val logger by injectLogger<LibraryPlaylistsViewModel>()
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading) private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
val uiState: StateFlow<LibraryPlaylistsState> = _state.asStateFlow() val uiState: StateFlow<LibraryPlaylistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Playlists)
) { state, query ->
when (state) {
is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query)
is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel(
val result = repository.savedPlaylists() val result = repository.savedPlaylists()
_state.value = LibraryPlaylistsState.Data.Loaded( _state.value = LibraryPlaylistsState.Data.Loaded(
allItems = result?.items ?: emptyList(), allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load playlists" } logger.e(e) { "Failed to load playlists" }
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel(
_state.value = LibraryPlaylistsState.Data.Loaded( _state.value = LibraryPlaylistsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()), allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more playlists" } logger.e(e) { "Failed to load more playlists" }
@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Playlists, query)
if (currentState is LibraryPlaylistsState.Data) {
_state.value = when (currentState) {
is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query)
is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
} }
fun refresh() { fun refresh() {
@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel(
isPublic: Boolean, isPublic: Boolean,
isCollaborating: Boolean, isCollaborating: Boolean,
imageBase64: String, imageBase64: String,
trackIds: List<String>) { trackIds: List<String>
) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
repository.createPlaylist( repository.createPlaylist(
@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel(
isPublic = isPublic, isPublic = isPublic,
isCollaborating = isCollaborating, isCollaborating = isCollaborating,
imageBase64 = imageBase64, imageBase64 = imageBase64,
trackIds = trackIds) trackIds = trackIds
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to create playlist" } logger.e(e) { "Failed to create playlist" }
}.onSuccess { }.onSuccess {

View File

@ -45,10 +45,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -77,12 +80,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.currentWindowWidthBreakpoint
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.Platform
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRole
@ -103,6 +108,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.isDesktop
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
@ -121,6 +128,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.spotube.millennium.adaptive.WindowBreakpoints
private val GridMinCellSize = 180.dp private val GridMinCellSize = 180.dp
@ -153,6 +161,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
emptyList() emptyList()
) )
val isExpanded =
currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop()
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = blacklistedTracks.map { it.id }.toSet() val trackIds = blacklistedTracks.map { it.id }.toSet()
val artistIds = blacklistedArtists.map { it.id }.toSet() val artistIds = blacklistedArtists.map { it.id }.toSet()
@ -287,16 +298,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background) .background(MillenniumTheme.colors.background)
.windowInsetsPadding(WindowInsets.statusBars)
) { ) {
ApplicationMainBar(backButton = false) if (!isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
SearchBar( SearchBar(
query = state.query, query = state.query,
onQueryChange = viewModel::onQueryChange, onQueryChange = viewModel::onQueryChange,
@ -308,7 +312,34 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
onClearAllRecentSearches = { onClearAllRecentSearches = {
viewModel.clearAllRecentSearches() viewModel.clearAllRecentSearches()
focusManager.clearFocus() focusManager.clearFocus()
}) },
)
} else {
ApplicationMainBar(backButton = false)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
if (isExpanded) {
SearchBar(
query = state.query,
onQueryChange = viewModel::onQueryChange,
recentSearchSuggestions = state.recentSearchSuggestions,
onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search)
focusManager.clearFocus()
},
onClearAllRecentSearches = {
viewModel.clearAllRecentSearches()
focusManager.clearFocus()
}
)
}
if (state.supportedSearchTypes.isNotEmpty()) { if (state.supportedSearchTypes.isNotEmpty()) {
SearchTabs( SearchTabs(

View File

@ -0,0 +1,191 @@
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.unit.dp
import androidx.compose.ui.use
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.spotube.millennium.composables.MillenniumTopAppBar
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryToolbarTest {
private lateinit var library: LibraryState
@BeforeTest
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
library = LibraryState()
}
@AfterTest
fun cleanup() {
library.viewModelScope.cancel()
Dispatchers.resetMain()
}
@Test
fun closingSearchClearsTheActiveFilter() = runTest {
library.openSearch()
library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse")
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(library.uiState.value.searchMode)
assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first())
library.closeSearch()
Snapshot.sendApplyNotifications()
runCurrent()
assertFalse(library.uiState.value.searchMode)
assertEquals("", library.uiState.value.searchState.text.toString())
}
@Test
fun eachTabRetainsItsOwnFilterAndActions() = runTest {
library.setQuery(LibraryTab.Playlists, "Rock")
library.onTabSelected(LibraryTab.Albums)
library.setQuery(LibraryTab.Albums, "Jazz")
Snapshot.sendApplyNotifications()
runCurrent()
assertEquals("Jazz", library.uiState.value.searchState.text.toString())
assertFalse(library.uiState.value.canCreatePlaylist)
library.onTabSelected(LibraryTab.Playlists)
runCurrent()
assertEquals("Rock", library.uiState.value.searchState.text.toString())
assertTrue(library.uiState.value.canCreatePlaylist)
library.showCreatePlaylist()
runCurrent()
assertTrue(library.uiState.value.showCreatePlaylist)
library.onTabSelected(LibraryTab.LocalTracks)
runCurrent()
assertTrue(library.uiState.value.canRefresh)
assertFalse(library.uiState.value.showCreatePlaylist)
library.onTabSelected(LibraryTab.Downloads)
runCurrent()
assertFalse(library.uiState.value.canFilter)
assertFalse(library.uiState.value.searchMode)
}
@Test
fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest {
library.setQuery(LibraryTab.Artists, "Radiohead")
library.onTabSelected(LibraryTab.Artists)
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(library.uiState.value.searchMode)
assertEquals("Radiohead", library.uiState.value.searchState.text.toString())
}
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun renderExpandedCompactAndSearchToolbars() {
val output = File("build/reports/library-toolbar").apply { mkdirs() }
for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) {
for ((mode, width) in listOf(
"desktop" to 575,
"mobile" to 360,
"search" to 360,
"compact-desktop" to 392,
"compact-desktop-search" to 392
)) {
val expandedDesktop = mode == "desktop"
val searching = mode.contains("search")
val state = LibraryToolbarUiState(
currentTab = tab,
searchMode = searching,
searchState = TextFieldState(if (searching) "Muse" else ""),
canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks
)
ImageComposeScene(width, 88) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
MillenniumTopAppBar(
title = { LibraryToolbarTitle(state, expandedDesktop) },
actions = {
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {})
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
Modifier.width(132.dp)
)
}
)
}
}.use { scene ->
repeat(8) { scene.render().close() }
fun flatten(node: SemanticsNode): List<SemanticsNode> =
listOf(node) + node.children.flatMap(::flatten)
val nodes =
scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) }
val descriptions = nodes.flatMap {
it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty()
}
val textFields =
nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null }
assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size)
if (searching) {
assertTrue("Close filter" in descriptions)
assertFalse("Create playlist" in descriptions)
assertFalse("Refresh local tracks" in descriptions)
assertEquals(
"Muse",
textFields.single().config[SemanticsProperties.EditableText].text
)
} else {
assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions)
assertEquals(
tab == LibraryTab.LocalTracks,
"Refresh local tracks" in descriptions
)
}
textFields.forEach { node ->
assertTrue(
node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width,
"Filter must fit beside desktop window controls"
)
}
scene.render().use { image ->
assertNotNull(image.encodeToData()).use {
File(
output,
"${tab.name}-$mode.png"
).writeBytes(it.bytes)
}
}
}
}
}
}
}

View File

@ -19,9 +19,10 @@
kotlin.code.style=official kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M kotlin.daemon.jvmargs=-Xmx3072M
kotlin.mpp.stability.nowarn=true kotlin.mpp.stability.nowarn=true
kotlin.incremental=true
#Gradle #Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
@ -32,3 +33,5 @@ android.useAndroidX=true
android.builtInKotlin=false android.builtInKotlin=false
android.newDsl=false android.newDsl=false
android.sourceset.disallowProvider=false android.sourceset.disallowProvider=false
org.gradle.vfs.watch=true