mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(library): enhance library UI with new tab titles, filtering options, and improved state management
This commit is contained in:
parent
5b442a93a9
commit
cf9476ab08
@ -108,6 +108,7 @@ kotlin {
|
||||
}
|
||||
|
||||
jvm()
|
||||
jvmToolchain(21)
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user