From cf9476ab089f53673ee84e6e06c1b03b6ccee3f3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 14 Oct 2026 21:52:24 +0600 Subject: [PATCH] feat(library): enhance library UI with new tab titles, filtering options, and improved state management --- composeApp/build.gradle.kts | 1 + .../composeResources/values/strings.xml | 16 ++ .../core/ui/component/CollectionView.kt | 12 +- .../spotube/modules/library/LibraryScreen.kt | 232 ++++++++++++------ .../spotube/modules/library/LibraryState.kt | 81 +++++- .../spotube/modules/library/LibraryTab.kt | 22 +- .../library/album/LibraryAlbumsScreen.kt | 25 -- .../library/album/LibraryAlbumsViewModel.kt | 42 ++-- .../library/artist/LibraryArtistsScreen.kt | 57 ++--- .../library/artist/LibraryArtistsViewModel.kt | 45 ++-- .../local_tracks/LibraryLocalTracksScreen.kt | 61 ++--- .../LibraryLocalTracksViewModel.kt | 59 +++-- .../playlist/LibraryPlaylistsScreen.kt | 64 +---- .../playlist/LibraryPlaylistsViewModel.kt | 52 ++-- .../spotube/modules/search/SearchScreen.kt | 57 ++++- .../modules/library/LibraryToolbarTest.kt | 191 ++++++++++++++ gradle.properties | 5 +- 17 files changed, 693 insertions(+), 329 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2d8729d9..750ed2c6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -108,6 +108,7 @@ kotlin { } jvm() + jvmToolchain(21) sourceSets { val commonMain by getting { diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b8052b87..373fc2e4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,22 @@ Clear all history Search... + + Your Library + Playlists + Albums + Artists + Local Tracks + Downloads + Search saved playlists… + Search saved albums… + Search saved artists… + Search folders or tracks… + Filter library + Close filter + Create playlist + Refresh local tracks + Selected Settings diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 5434ce34..1b660b4c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf 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.unit.dp 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.artist.MetadataArtist 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.shimmerApply +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import org.jetbrains.compose.resources.DrawableResource 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 fun CollectionView( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index 1dbef564..de796a5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -17,102 +17,192 @@ 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.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.height +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow - -import com.composeunstyled.Text +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember 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.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.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen 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.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight 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.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 -fun LibraryScreen( - libraryState: LibraryState = koinInject(), - appShellViewModel: AppShellViewModel = koinViewModel() -) { - val currentTab by libraryState.currentTab.collectAsState() - val searchMode by libraryState.searchMode.collectAsState() - val isLargeScreen = appShellViewModel.useSidebar() +fun LibraryScreen(libraryState: LibraryState = koinInject()) { + val state by libraryState.uiState.collectAsStateWithLifecycle() + val expandedDesktop = getPlatform().isDesktop() && + (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current - Column(modifier = Modifier.fillMaxSize()) { - Column { - ApplicationMainBar( - backButton = false, - title = { - if (!isLargeScreen) Text("Your Library") - }, - actions = { - if (!isLargeScreen) { - MillenniumIconButton( - icon = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - onClick = { - libraryState.setSearchMode(!searchMode) - }, - style = MillenniumIconButtonStyle.Quiet) - } - } - ) - if (!isLargeScreen) - Column( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(LibraryTab.entries.size) { index -> - val tab = LibraryTab.entries[index] - MillenniumButton( - onClick = { libraryState.onTabSelected(tab) }, - style = if (currentTab == tab) { - MillenniumButtonStyle.Primary - } else { - MillenniumButtonStyle.Secondary - }) { - Text(tab.title) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) + Column(Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = false, + title = { LibraryToolbarTitle(state, expandedDesktop) }, + actions = { + LibraryToolbarActions( + state = state, + expandedDesktop = expandedDesktop, + onOpenSearch = libraryState::openSearch, + onCloseSearch = { + focusManager.clearFocus() + keyboard?.hide() + libraryState.closeSearch() + }, + onCreatePlaylist = libraryState::showCreatePlaylist, + onRefresh = libraryState::refreshLocalTracks + ) + } + ) + if (!expandedDesktop) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { libraryState.onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } } + } } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + when (state.currentTab) { + LibraryTab.Playlists -> LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist + ) - Column( - modifier = Modifier - .weight(1f) - .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.Albums -> LibraryAlbumsScreen() + LibraryTab.Artists -> LibraryArtistsScreen() + LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } } } -} \ No newline at end of file +} + +@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) + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt index 56de67de..9002784f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt @@ -17,17 +17,90 @@ 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.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 { - val searchMode = MutableStateFlow(false) +data class LibraryToolbarUiState( + 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) + private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } + private val createPlaylistVisible = MutableStateFlow(false) + private val refreshRequests = MutableSharedFlow(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) { currentTab.value = tab + createPlaylistVisible.value = false + if (tab == LibraryTab.Downloads) searchMode.value = false } - fun setSearchMode(enabled: Boolean) { - searchMode.value = enabled + fun openSearch() { + 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) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt index a64ebe96..3c9abd4d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryTab.kt @@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary 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) { Playlists("Playlists", Iconsax.IconsaxMusicDashboard), Albums("Albums", Iconsax.IconsaxCd), Artists("Artists", Iconsax.User), 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 + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index ed4b7a00..b422147f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -18,17 +18,12 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.LazyVerticalGrid 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.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryAlbumsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, viewModel: LibraryAlbumsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "") - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -93,15 +77,6 @@ fun LibraryAlbumsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode) { - MillenniumTextField( - state = searchState, - - modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved albums..." - ) - } - Spacer(modifier = Modifier.height(12.dp)) when (state) { is LibraryAlbumsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 4b48f0c8..60fd0b39 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -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.spotube.core.di.injectLogger 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryAlbumsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryAlbumsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = 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 { viewModelScope.launch { @@ -113,7 +129,8 @@ class LibraryAlbumsViewModel( val result = repository.savedAlbums() _state.value = LibraryAlbumsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) }.onFailure { e -> logger.e(e) { "Failed to load albums" } _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") @@ -127,7 +144,8 @@ class LibraryAlbumsViewModel( _state.value = LibraryAlbumsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } @@ -135,13 +153,7 @@ class LibraryAlbumsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - 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) - } - } + libraryState.setQuery(LibraryTab.Albums, query) } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index eadb5bce..2f64b93c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -18,23 +18,16 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryArtistsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryArtistsViewModel = koinViewModel()) { + viewModel: LibraryArtistsViewModel = koinViewModel() +) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() - val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "") - LaunchedEffect(searchState) { - snapshotFlow { searchState.text.toString() } - .drop(1) - .collect(viewModel::onQueryChange) - } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -92,14 +76,6 @@ fun LibraryArtistsScreen( } Column(modifier = modifier) { - AnimatedVisibility(searchMode){ - MillenniumTextField( - state = searchState, - - modifier = Modifier.fillMaxWidth(), - placeholder = "Search saved artists...") - } - Spacer(modifier = Modifier.height(12.dp)) when (state) { is LibraryArtistsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current @@ -111,13 +87,15 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { items(8) { SkeletonTree(true) { AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -152,22 +130,25 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp)) { + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { items(dataState.items, key = { it.id }) { artist -> ArtistCard( artist = artist, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) } // if (state is LibraryArtistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/400x400") - } + items(4) { + SkeletonTree(true) { + AvatarCard( + title = "Artist Name", + subtitle = "Artist", + imageURL = "https://placehold.co/400x400" + ) } + } // } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index 8d8b2cbb..fa5556ce 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -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.spotube.core.di.injectLogger 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,7 +49,8 @@ sealed interface LibraryArtistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -59,13 +63,15 @@ sealed interface LibraryArtistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -82,11 +88,21 @@ sealed interface LibraryArtistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryArtistsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryArtistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = 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 { viewModelScope.launch { @@ -111,7 +127,8 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri) + externalUri = it.externalUri + ) } ?: emptyList(), nextPagination = result?.nextPagination) }.onFailure { e -> @@ -129,12 +146,14 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri) + externalUri = it.externalUri + ) } ?: emptyList() _state.value = LibraryArtistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + newItems, - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } @@ -142,13 +161,7 @@ class LibraryArtistsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - 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) - } - } + libraryState.setQuery(LibraryTab.Artists, query) } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 31ce23ce..71e3c4f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIndeterminateProgress 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.layout.Arrangement 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.input.rememberTextFieldState import com.composeunstyled.Text @@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.drop import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 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.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.Play import org.koin.compose.viewmodel.koinViewModel @Composable -fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) { +fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) { val viewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() val permissionState = rememberLocalMediaPermissionState() @@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean 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()) { Column( modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - 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) - } - } - + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { if (state.currentFolderPath != null) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { Row(verticalAlignment = Alignment.CenterVertically) { MillenniumIconButton( icon = Iconsax.ArrowLeft3, contentDescription = "Back", onClick = viewModel::navigateUp, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) Text(viewModel.currentFolder()?.name ?: "Folder") } MillenniumIconButton( @@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean onClick = { 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) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp)) { + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { Text("Allow audio permission to show local tracks") MillenniumButton( onClick = permissionState.requestPermission, - style = MillenniumButtonStyle.Quiet) { + style = MillenniumButtonStyle.Quiet + ) { Text("Grant access") } } @@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean icon = Iconsax.Play, contentDescription = "Play", onClick = { viewModel.playTrack(track.path) }, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) }, onClick = { viewModel.playTrack(track.path) }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index d1897a74..63a0d18d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue 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.LocalMediaLibraryCoordinator import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class LibraryLocalTracksState( @@ -35,24 +39,34 @@ data class LibraryLocalTracksState( val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null) + val error: String? = null +) class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue, + private val libraryState: LibraryState +) : ViewModel() { private val _state = MutableStateFlow(LibraryLocalTracksState()) - val state: StateFlow = _state.asStateFlow() + val state: StateFlow = combine( + _state, libraryState.queryFlow(LibraryTab.LocalTracks) + ) { state, query -> state.copy(query = query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState()) init { + viewModelScope.launch { + libraryState.localRefreshRequests.collect { refresh() } + } viewModelScope.launch { coordinator.cacheState .collect { cache -> - _state.update { - it.copy( - folders = cache.folders, - error = null) + _state.update { + it.copy( + folders = cache.folders, + error = null + ) + } } - } } refreshIfEmpty() @@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel( } fun onQueryChange(query: String) { - _state.update { it.copy(query = query) } + libraryState.setQuery(LibraryTab.LocalTracks, query) } fun openFolder(folderPath: String) { @@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel( fun playFolder(folderPath: String) { 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 viewModelScope.launch { @@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = 0, - collectionEntry = null) + collectionEntry = null + ) } } fun playTrack(trackPath: String) { 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 } if (selectedIndex < 0) return @@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = selectedIndex, - collectionEntry = null) + collectionEntry = null + ) } } @@ -120,10 +136,10 @@ class LibraryLocalTracksViewModel( return state.value.folders.filter { folder -> folder.name.contains(query, ignoreCase = true) || - folder.tracks.any { track -> - track.name.contains(query, ignoreCase = true) || - track.artists.any { it.contains(query, ignoreCase = true) } - } + folder.tracks.any { track -> + track.name.contains(query, ignoreCase = true) || + track.artists.any { it.contains(query, ignoreCase = true) } + } } } @@ -156,8 +172,8 @@ class LibraryLocalTracksViewModel( return tracks.filter { track -> track.name.contains(normalizedQuery, ignoreCase = true) || - track.album?.contains(normalizedQuery, ignoreCase = true) == true || - track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } + track.album?.contains(normalizedQuery, ignoreCase = true) == true || + track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } } } @@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel( duration = durationMs, album = album, coverBytes = coverBytes, - url = path) + url = path + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 45247b53..e5d41549 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -18,68 +18,45 @@ 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.Box import androidx.compose.foundation.layout.Column 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.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.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState - -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @Composable fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, - searchMode: Boolean = false, - viewModel: LibraryPlaylistsViewModel = koinViewModel()) { + showCreatePlaylist: Boolean = false, + onDismissCreatePlaylist: () -> Unit = {}, + viewModel: LibraryPlaylistsViewModel = koinViewModel() +) { val state by viewModel.uiState.collectAsStateWithLifecycle() 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) { snapshotFlow { gridState.layoutInfo } @@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen( } 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) { is LibraryPlaylistsState.Loading -> { val shellBottomInset = LocalAppShellBottomInset.current @@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400") + imageURL = "https://placehold.co/600x400" + ) } } } @@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen( PlaylistFormSheet( visible = showCreatePlaylist, - onDismiss = { showCreatePlaylist = false }, + onDismiss = onDismissCreatePlaylist, onSubmit = { data -> viewModel.createPlaylist( name = data.name, @@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen( isPublic = data.isPublic, isCollaborating = data.isCollaborating, imageBase64 = data.imageBase64, - trackIds = emptyList()) + trackIds = emptyList() + ) }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index efceaded..0072a4ac 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -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.spotube.core.di.injectLogger 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.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -46,12 +49,13 @@ sealed interface LibraryPlaylistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) + (it.description?.contains(query, ignoreCase = true) == true) } } else { allItems @@ -60,18 +64,20 @@ sealed interface LibraryPlaylistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination) + nextPagination = nextPagination + ) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null) : Data { + override val nextPagination: PaginationStrategy? = null + ) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) + (it.description?.contains(query, ignoreCase = true) == true) } } else { allItems @@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryPlaylistsViewModel( - private val repository: LibraryRepository) : ViewModel(), KoinComponent { + private val repository: LibraryRepository, + private val libraryState: LibraryState +) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) - val uiState: StateFlow = _state.asStateFlow() + val uiState: StateFlow = 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 { viewModelScope.launch { @@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel( val result = repository.savedPlaylists() _state.value = LibraryPlaylistsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") @@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel( _state.value = LibraryPlaylistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination) + nextPagination = result?.nextPagination + ) } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } @@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel( } fun onQueryChange(query: String) { - val currentState = _state.value - 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) - } - } + libraryState.setQuery(LibraryTab.Playlists, query) } fun refresh() { @@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List) { + trackIds: List + ) { viewModelScope.launch { runCatching { repository.createPlaylist( @@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds) + trackIds = trackIds + ) }.onFailure { e -> logger.e(e) { "Failed to create playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index ff720d6c..8db81996 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -45,10 +45,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.grid.GridCells 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.unit.dp 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.artist.MetadataArtist 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.track.MetadataTrack 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.QueueEntry 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.dragScrollable 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.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository @@ -121,6 +128,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints private val GridMinCellSize = 180.dp @@ -153,6 +161,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { emptyList() ) + val isExpanded = + currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop() + fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = blacklistedTracks.map { it.id }.toSet() val artistIds = blacklistedArtists.map { it.id }.toSet() @@ -287,8 +298,25 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { modifier = Modifier .fillMaxSize() .background(MillenniumTheme.colors.background) + .windowInsetsPadding(WindowInsets.statusBars) ) { - ApplicationMainBar(backButton = false) + if (!isExpanded) { + SearchBar( + query = state.query, + onQueryChange = viewModel::onQueryChange, + recentSearchSuggestions = state.recentSearchSuggestions, + onRecentSearchClick = { search -> + viewModel.applyRecentSearch(search) + focusManager.clearFocus() + }, + onClearAllRecentSearches = { + viewModel.clearAllRecentSearches() + focusManager.clearFocus() + }, + ) + } else { + ApplicationMainBar(backButton = false) + } Column( modifier = Modifier .fillMaxWidth() @@ -297,18 +325,21 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { Column( modifier = Modifier.fillMaxWidth() ) { - SearchBar( - query = state.query, - onQueryChange = viewModel::onQueryChange, - recentSearchSuggestions = state.recentSearchSuggestions, - onRecentSearchClick = { search -> - viewModel.applyRecentSearch(search) - focusManager.clearFocus() - }, - onClearAllRecentSearches = { - viewModel.clearAllRecentSearches() - focusManager.clearFocus() - }) + 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()) { SearchTabs( diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt new file mode 100644 index 00000000..e5ce8585 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt @@ -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 = + 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) + } + } + } + } + } + } +} diff --git a/gradle.properties b/gradle.properties index e2904b99..a49b0977 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,9 +19,10 @@ kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M kotlin.mpp.stability.nowarn=true +kotlin.incremental=true #Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g org.gradle.configuration-cache=true org.gradle.caching=true @@ -32,3 +33,5 @@ android.useAndroidX=true android.builtInKotlin=false android.newDsl=false android.sourceset.disallowProvider=false + +org.gradle.vfs.watch=true \ No newline at end of file