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

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

View File

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

View File

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

View File

@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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(

View File

@ -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()
}
}
}
}
}
@Composable
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
if (!expandedDesktop && state.searchMode) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.fillMaxWidth(),
autoFocus = true
)
} else if (!expandedDesktop) {
Text(stringResource(Res.string.library_screen_title))
}
}
@Composable
internal fun RowScope.LibraryToolbarActions(
state: LibraryToolbarUiState,
expandedDesktop: Boolean,
onOpenSearch: () -> Unit,
onCloseSearch: () -> Unit,
onCreatePlaylist: () -> Unit,
onRefresh: () -> Unit
) {
if (!expandedDesktop && state.searchMode) {
MillenniumIconButton(
icon = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.library_action_close_search),
onClick = onCloseSearch,
style = MillenniumIconButtonStyle.Quiet
)
} else {
if (state.canFilter) {
if (expandedDesktop) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.width(300.dp)
)
} else {
MillenniumIconButton(
icon = Iconsax.IconsaxSearchBroken,
contentDescription = stringResource(Res.string.library_action_search),
onClick = onOpenSearch,
style = MillenniumIconButtonStyle.Quiet
)
}
}
if (state.canCreatePlaylist) {
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = onCreatePlaylist,
style = MillenniumIconButtonStyle.Quiet
)
}
if (state.canRefresh) {
MillenniumIconButton(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh,
style = MillenniumIconButtonStyle.Quiet
)
}
}
}
@Composable
private fun LibraryFilterField(
state: TextFieldState,
placeholder: StringResource,
modifier: Modifier,
autoFocus: Boolean = false
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) {
if (autoFocus) focusRequester.requestFocus()
}
MillenniumTextField(
state = state,
placeholder = stringResource(placeholder),
minimumHeight = 36.dp,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester)
)
}

View File

@ -17,17 +17,90 @@
package dev.krtirtho.spotube.modules.library
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<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow()
val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode,
createPlaylistVisible
) { (tab, query), searching, creating ->
LibraryToolbarUiState(
currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
searchState = searchStates.getValue(tab),
canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks,
showCreatePlaylist = tab == LibraryTab.Playlists && creating
)
}.stateIn(
viewModelScope, SharingStarted.Eagerly,
LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists))
)
fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() }
fun setQuery(tab: LibraryTab, query: String) {
searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query)
}
fun onTabSelected(tab: LibraryTab) {
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)
}
}

View File

@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.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
}
}

View File

@ -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

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.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<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed>
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<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed>
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<LibraryAlbumsViewModel>()
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
val uiState: StateFlow<LibraryAlbumsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryAlbumsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Albums)
) { state, query ->
when (state) {
is LibraryAlbumsState.Data.Loaded -> state.copy(query = query)
is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading)
init {
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() {

View File

@ -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"
)
}
}
// }
}
}

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.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<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic>
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<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic>
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<LibraryArtistsViewModel>()
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
val uiState: StateFlow<LibraryArtistsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryArtistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Artists)
) { state, query ->
when (state) {
is LibraryArtistsState.Data.Loaded -> state.copy(query = query)
is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading)
init {
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() {

View File

@ -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<LibraryLocalTracksViewModel>()
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) })
}

View File

@ -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<LocalMediaFolder> = 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<LibraryLocalTracksState> = _state.asStateFlow()
val state: StateFlow<LibraryLocalTracksState> = combine(
_state, libraryState.queryFlow(LibraryTab.LocalTracks)
) { state, query -> state.copy(query = query) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState())
init {
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
)
}
}

View File

@ -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()
)
})
}
}

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.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<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist>
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<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist>
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<LibraryPlaylistsViewModel>()
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
val uiState: StateFlow<LibraryPlaylistsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryPlaylistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Playlists)
) { state, query ->
when (state) {
is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query)
is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading)
init {
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<String>) {
trackIds: List<String>
) {
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 {

View File

@ -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(

View File

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

View File

@ -19,9 +19,10 @@
kotlin.code.style=official
kotlin.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