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