diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 373fc2e4..6de1648b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -27,6 +27,7 @@ Your Library + Library Playlists Albums Artists @@ -40,6 +41,59 @@ Close filter Create playlist Refresh local tracks + Switch to grid view + Switch to list view + No saved playlists + No playlists match “%1$s” + No saved albums + No albums match “%1$s” + No saved artists + No artists match “%1$s” + + + Folders + Folder + %1$s • %2$s + + %1$d track + %1$d tracks + + + %1$d folder + %1$d folders + + Back to folders + Play folder + Audio access required + Allow audio access to browse music on your device. + Grant access + No local folders found + No tracks in this folder + No matching folders or tracks + Failed to refresh local media + + + %1$d active • %2$d completed + Clear completed + No downloads yet + Downloading %1$d% + Queued + Completed + Cancelled + Failed + Failed: %1$s + Cancel download + Retry download + Remove download + + + Artist + Blacklisted + Liked Tracks + Your saved songs + Unknown artist + Track title + Artist name Selected @@ -122,11 +176,21 @@ Local media folders Custom folders to scan on desktop. Added: %1$d No custom folder added yet - Use the picker to add folders. Download folder override is included automatically. + Custom folders for your local music library. Manage Add folder Remove folder No custom folders added. + Remove %1$s from the scan list + Download and music cache folders are included automatically. + Your download folder is included automatically. + The music cache folder is included automatically. + Saving… + Couldn’t save folders. Your changes are still here; try again. + + %1$d folder + %1$d folders + Download music format Preferred codec and container for downloads. Current: %1$s Download music quality diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a8251a64..769c08ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -82,6 +82,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel import dev.krtirtho.spotube.modules.shell.AppShellViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -142,6 +143,13 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) + viewModel { + val repository = get() + LocalMediaFoldersViewModel(repository.userSettings) { folders -> + val current = repository.getCurrentSettings() + repository.updateSettings(current.copy(localMediaFolders = folders)) + } + } // Welcome / Onboarding singleOf(::WelcomeRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 23599f4e..df7145a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -40,12 +40,15 @@ fun AlbumCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject()) { + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( title = album.title, - subtitle = album.description ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", + subtitle = album.description + ?: "${album.albumType} • ${album.artists.joinToString { it.name }}", imageURL = album.thumbnails.firstOrNull()?.url, isPlaying = currentCollectionEntry?.id == album.id, onClick = { @@ -56,14 +59,18 @@ fun AlbumCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Album, album.id, - album.title) + album.title + ) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Album, album.id, - album.title) + album.title + ) }, - modifier = modifier.width(160.dp), - sharedElementKey = "album_art_${album.id}") + modifier = if (listMode) modifier else modifier.width(160.dp), + sharedElementKey = "album_art_${album.id}", + listMode = listMode + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt index 541facd1..0c9d065c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt @@ -17,52 +17,34 @@ package dev.krtirtho.spotube.core.ui.component -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import org.koin.compose.koinInject +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.* @Composable fun ArtistCard( artist: MetadataArtist.Basic, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false) { + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { val navigationCommands = koinInject() AvatarCard( title = artist.name, - subtitle = if (isBlacklisted) "Blacklisted" else "Artist", + subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist), imageURL = artist.thumbnails.firstOrNull()?.url, onClick = { navigationCommands.navigateTo(Routes.Artist(artist.id)) }, modifier = modifier, - isBlacklisted = isBlacklisted) + isBlacklisted = isBlacklisted, + listMode = listMode + ) } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt index ac3444e8..456de393 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt @@ -21,14 +21,7 @@ package dev.krtirtho.spotube.core.ui.component import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -44,7 +37,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,19 +50,21 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper -import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope -import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope -import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle import dev.krtirtho.spotube.resources.iconsax.Play import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.liked_tracks +import spotube.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.component.cards.MediaCardSurface +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -78,27 +72,49 @@ fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject()) { + navigationCommands: NavigationCommands = koinInject(), + listMode: Boolean = false +) { val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks + val title = stringResource(Res.string.media_liked_tracks) + val subtitle = stringResource(Res.string.media_saved_songs) + if (listMode) { + PlayableListTile( + title = title, + subtitle = subtitle, + isPlaying = isPlaying, + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, + onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, + onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, + modifier = modifier, + sharedElementKey = "saved_tracks_art", + artwork = { + Image( + painterResource(Res.drawable.liked_tracks), + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) - .width(160.dp) - .hoverable(interactionSource = interactionSource)) { + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = { navigationCommands.navigateTo(Routes.SavedTracks) } + ) { isHovered -> Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) { androidx.compose.foundation.Image( painter = painterResource(Res.drawable.liked_tracks), - contentDescription = "Liked Tracks", + contentDescription = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -106,14 +122,16 @@ fun LikedTracksCard( .sharedElementOrNone( key = "saved_tracks_art", sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope), - contentScale = ContentScale.Crop) + animatedVisibilityScope = animatedVisibilityScope + ), + contentScale = ContentScale.Crop + ) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - if (isHovered) { + if (isHovered || touchInput || isPlaying) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) @@ -125,7 +143,7 @@ fun LikedTracksCard( ) { MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", + contentDescription = stringResource(Res.string.player_add_to_queue_content_description), modifier = Modifier.size(16.dp) ) } @@ -139,7 +157,7 @@ fun LikedTracksCard( imageVector = if (isPlaying) { Iconsax.IconsaxPauseCircle } else Iconsax.Play, - contentDescription = "Play", + contentDescription = stringResource(Res.string.player_play_content_description), modifier = Modifier.size(16.dp) ) } @@ -151,17 +169,19 @@ fun LikedTracksCard( modifier = Modifier.fillMaxWidth().padding(12.dp) ) { Text( - text = "Liked Tracks", + text = title, style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Your saved songs", + text = subtitle, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index ffacbb4d..48e82d51 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -40,7 +40,9 @@ fun PlaylistCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject()) { + remotePlaybackController: RemotePlaybackController = koinInject(), + listMode: Boolean = false +) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -57,14 +59,18 @@ fun PlaylistCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Playlist, playlist.id, - playlist.title) + playlist.title + ) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Playlist, playlist.id, - playlist.title) + playlist.title + ) }, modifier = modifier, - sharedElementKey = "playlist_art_${playlist.id}") + sharedElementKey = "playlist_art_${playlist.id}", + listMode = listMode + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt index ff08d418..db311800 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt @@ -17,79 +17,67 @@ package dev.krtirtho.spotube.core.ui.component.cards -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply -import org.koin.compose.koinInject +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.User @Composable fun AvatarCard( title: String, subtitle: String? = null, imageURL: String? = null, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false) { - val backgroundColor = if (isBlacklisted) { - AppTheme.colors.error.copy(alpha = 0.1f) - } else { - Color.Transparent + isBlacklisted: Boolean = false, + listMode: Boolean = false +) { + if (listMode) { + AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted) + return } - - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) - .clickable(onClick = { onClick.invoke() }, enabled = true) - .width(160.dp)) { + + MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) { Column( modifier = Modifier .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally) { + horizontalAlignment = Alignment.CenterHorizontally + ) { Box( modifier = Modifier .fillMaxWidth() .clip(CircleShape) .padding(12.dp), - contentAlignment = Alignment.Center) { - AsyncImage( + contentAlignment = Alignment.Center + ) { + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop) + shape = CircleShape, + placeholderIcon = Iconsax.User + ) } Column( modifier = Modifier.fillMaxWidth().padding(12.dp) @@ -99,17 +87,20 @@ fun AvatarCard( style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) subtitle?.let { + Spacer(Modifier.height(4.dp)) TextWithShimmer( text = subtitle, - style = AppTheme.typography.bodyMedium, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth()) + modifier = Modifier.fillMaxWidth() + ) } } } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt new file mode 100644 index 00000000..b4095530 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt @@ -0,0 +1,31 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.User + +@Composable +fun AvatarListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isBlacklisted: Boolean = false +) { + PlayableListTile( + title = title, + subtitle = subtitle, + imageURL = imageURL, + onClick = onClick, + modifier = modifier, + imageShape = CircleShape, + placeholderIcon = Iconsax.User, + isBlacklisted = isBlacklisted + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt new file mode 100644 index 00000000..43809d5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt @@ -0,0 +1,84 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumActionPointer +import org.spotube.millennium.theme.millenniumSurface + +/** Interaction state is presentation-only; all navigation/playback stays in event callbacks. */ +@Composable +internal fun MediaCardSurface( + modifier: Modifier, + onClick: (() -> Unit)?, + interactive: Boolean = onClick != null, + isBlacklisted: Boolean = false, + content: @Composable (highlighted: Boolean) -> Unit +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val pressed by interaction.collectIsPressedAsState() + val focused by interaction.collectIsFocusedAsState() + var groupFocused by remember { mutableStateOf(false) } + val colors = MillenniumTheme.colors + val highlight by animateFloatAsState( + if (hovered || focused || groupFocused || pressed) 1f else 0f, + tween(140) + ) + val scale by animateFloatAsState(if (pressed) 0.985f else 1f, tween(100)) + val shape = RoundedCornerShape(10.dp) + Box( + modifier.graphicsLayer { scaleX = scale; scaleY = scale } + .millenniumSurface( + background = Brush.verticalGradient( + listOf( + colors.raised.copy(alpha = highlight), + if (isBlacklisted) colors.danger.copy(alpha = 0.12f + highlight * 0.12f) + else colors.surface.copy(alpha = highlight) + ) + ), + shape = shape, + borderColor = (if (focused) colors.accent else colors.border).copy(alpha = highlight), + borderWidth = if (focused) 2.dp else 1.dp, + innerGlowColor = colors.highlight.copy(alpha = highlight * 0.4f), + shadowElevation = (highlight * 4f).dp + ) + .clip(shape) + .onFocusChanged { groupFocused = it.hasFocus } + .focusGroup() + .hoverable(interaction, enabled = interactive) + .then( + if (onClick != null) Modifier.millenniumActionPointer().clickable( + interactionSource = interaction, + indication = LocalIndication.current, + role = Role.Button, + onClick = onClick + ) else Modifier + ) + ) { content(hovered || focused || groupFocused) } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt new file mode 100644 index 00000000..7cd95441 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface + +@Composable +internal fun MediaEmptyState( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + action: (@Composable () -> Unit)? = null +) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + Modifier.size(64.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + icon, + null, + modifier = Modifier.size(30.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } + Text(title, style = AppTheme.typography.titleMedium, textAlign = TextAlign.Center) + if (description != null) Text( + description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + action?.invoke() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index a4674b27..05edce14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -25,10 +25,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,21 +34,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone @@ -67,6 +60,8 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.player_add_to_queue_content_description import spotube.composeapp.generated.resources.player_pause_content_description import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.media_preview_title +import spotube.composeapp.generated.resources.media_preview_subtitle @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -79,22 +74,37 @@ fun PlayableCard( onPlay: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, - sharedElementKey: String? = null) { - val interactionSource = remember { MutableInteractionSource() } + sharedElementKey: String? = null, + listMode: Boolean = false +) { + if (listMode) { + PlayableListTile( + title, + subtitle, + imageURL, + isPlaying, + onClick, + onPlay, + onAddToQueue, + modifier, + sharedElementKey + ) + return + } + val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = { onClick?.invoke() }, enabled = true) - .width(160.dp) - .hoverable(interactionSource = interactionSource)) { + MediaCardSurface( + modifier = modifier.width(160.dp), + onClick = onClick ?: onPlay, + interactive = onClick != null || onPlay != null || onAddToQueue != null + ) { highlighted -> Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { - AsyncImage( + MediaArtwork( model = imageURL, - contentDescription = title, + title = title, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) @@ -102,19 +112,21 @@ fun PlayableCard( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope), - contentScale = ContentScale.Crop) + animatedVisibilityScope = animatedVisibilityScope + ), + shape = RoundedCornerShape(8.dp) + ) if (onPlay != null || onAddToQueue != null) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), contentAlignment = Alignment.BottomEnd ) { - val isHovered by interactionSource.collectIsHoveredAsState() this@Column.AnimatedVisibility( - visible = isHovered || (isPlaying && onPlay != null), + visible = highlighted || touchInput || (isPlaying && onPlay != null), enter = fadeIn(), - exit = fadeOut()) { + exit = fadeOut() + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) @@ -130,7 +142,9 @@ fun PlayableCard( MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = stringResource( - Res.string.player_add_to_queue_content_description)) + Res.string.player_add_to_queue_content_description + ) + ) } Spacer(modifier = Modifier.height(8.dp)) } @@ -150,7 +164,9 @@ fun PlayableCard( Res.string.player_pause_content_description } else { Res.string.player_play_content_description - })) + } + ) + ) } } } @@ -164,7 +180,8 @@ fun PlayableCard( text = title, style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(4.dp)) subtitle?.let { TextWithShimmer( @@ -172,7 +189,8 @@ fun PlayableCard( style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) } } } @@ -185,8 +203,8 @@ fun PlayableCard( private fun PlayableCardPreview() { Box { PlayableCard( - title = "Sample Title", - subtitle = "Sample Subtitle", + title = stringResource(Res.string.media_preview_title), + subtitle = stringResource(Res.string.media_preview_subtitle), imageURL = "https://placehold.co/600x400", isPlaying = true, onClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt new file mode 100644 index 00000000..253f2155 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt @@ -0,0 +1,175 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.core.ui.component.cards + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope +import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope +import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone +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.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PlayableListTile( + title: String, + subtitle: String? = null, + imageURL: String? = null, + isPlaying: Boolean = false, + onClick: (() -> Unit)? = null, + onPlay: (() -> Unit)? = null, + onAddToQueue: (() -> Unit)? = null, + modifier: Modifier = Modifier, + sharedElementKey: String? = null, + imageModel: Any? = imageURL, + imageShape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + isBlacklisted: Boolean = false +) { + val colors = MillenniumTheme.colors + MillenniumListTile( + modifier = modifier, + onClick = onClick ?: onPlay, + selected = isPlaying, + background = if (isPlaying) null else millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, + titleForeground = if (isBlacklisted) colors.danger else null, + borderWidth = 1.dp, + contentPadding = PaddingValues(12.dp), + minimumHeight = 76.dp, + leading = { + MediaArtwork( + imageModel, title, + Modifier.size(52.dp).sharedElementOrNone( + sharedElementKey, + LocalSharedTransitionScope.current, + LocalAnimatedVisibilityScope.current + ), + imageShape, placeholderIcon, artwork + ) + }, + title = { + TextWithShimmer( + title, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = subtitle?.let { text -> + { + TextWithShimmer( + text, + style = AppTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + }, + trailing = if (onPlay != null || onAddToQueue != null || trailingContent != null) { + { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + trailingContent?.invoke() + if (onAddToQueue != null && !isPlaying) { + MillenniumIconButton( + onClick = onAddToQueue, + modifier = Modifier.size(40.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + Iconsax.IconsaxAddSquare, + stringResource(Res.string.player_add_to_queue_content_description) + ) + } + } + if (onPlay != null) { + MillenniumIconButton( + onClick = onPlay, + modifier = Modifier.size(40.dp), + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description) + ) + } + } + } + } + } else null + ) +} + +@Composable +internal fun MediaArtwork( + model: Any?, + title: String, + modifier: Modifier, + shape: Shape = RoundedCornerShape(6.dp), + placeholderIcon: ImageVector = Iconsax.IconsaxMusic, + artwork: (@Composable () -> Unit)? = null +) { + Box( + modifier.millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = shape, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ).clip(shape).shimmerApply(), + contentAlignment = Alignment.Center + ) { + if (artwork != null) artwork() + else if (model != null) AsyncImage( + model, + title, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + else MillenniumIcon( + placeholderIcon, + null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt index b8cad5cf..df59bd4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt @@ -1,30 +1,8 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.downloads - -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumIconButton -import org.spotube.millennium.composables.MillenniumIconButtonStyle -import org.spotube.millennium.composables.MillenniumProgress -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,87 +16,153 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items - -import dev.krtirtho.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text - +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork +import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* @Composable -fun DownloadsScreen(modifier: Modifier = Modifier) { - val viewModel = koinViewModel() - val downloads by viewModel.downloads.collectAsStateWithLifecycle() - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset) +fun DownloadsScreen( + modifier: Modifier = Modifier, + viewModel: DownloadsViewModel = koinViewModel(), + headerInAppBar: Boolean = false, + topContent: @Composable () -> Unit = {} +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Column(modifier.fillMaxSize()) { + if (headerInAppBar) { + ApplicationMainBar( + backButton = false, + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) } + ) + topContent() + } + DownloadsContent( + state, + viewModel::cancel, + viewModel::retry, + viewModel::remove, + viewModel::clearCompleted, + Modifier.weight(1f) + .then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier), + showHeader = !headerInAppBar + ) } +} - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) { - val hasCompleted = downloads.any { it.status == DownloadStatus.Completed } - if (hasCompleted) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End) { - MillenniumButton( - onClick = viewModel::clearCompleted, - style = MillenniumButtonStyle.Quiet) { - Row(verticalAlignment = Alignment.CenterVertically) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - modifier = Modifier.size(16.dp)) - Text( - text = " Clear completed", - style = AppTheme.typography.labelLarge) - } - } +@Composable +internal fun DownloadsToolbarTitle() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(Res.string.library_tab_downloads), + style = AppTheme.typography.titleMedium + ) + } +} + +@Composable +internal fun DownloadsToolbarSummary(state: DownloadsUiState) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + modifier = Modifier.padding(start = 28.dp), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) +} + +@Composable +internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) { + if (state.canClearCompleted) MillenniumButton( + onClick = onClearCompleted, + style = MillenniumButtonStyle.Secondary + ) { + Text( + stringResource(Res.string.downloads_clear_completed), + style = AppTheme.typography.labelMedium + ) + } +} + +@Composable +internal fun DownloadsContent( + state: DownloadsUiState, + onCancel: (String) -> Unit, + onRetry: (String) -> Unit, + onRemove: (String) -> Unit, + onClearCompleted: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) { + if (showHeader) Row( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(Modifier.weight(1f)) { + DownloadsToolbarTitle() + DownloadsToolbarSummary(state) } + DownloadsToolbarActions(state, onClearCompleted) } - - if (downloads.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center) { - Text( - text = "No downloads yet", - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant) - } - } else { - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp)) { - items(downloads, key = { it.id }) { item -> - DownloadListRow( - item = item, - onCancel = { viewModel.cancel(item.id) }, - onRetry = { viewModel.retry(item.id) }, - onRemove = { viewModel.remove(item.id) }) - } + if (state.items.isEmpty()) MediaEmptyState( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.downloads_empty), + Modifier.padding(bottom = inset) + ) + else LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues( + top = if (showHeader) 0.dp else 16.dp, + bottom = inset + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.items, key = { it.id }) { row -> + DownloadListRow( + row, + { onCancel(row.id) }, + { onRetry(row.id) }, + { onRemove(row.id) }) } } } @@ -126,147 +170,82 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { } @Composable -private fun DownloadListRow( - item: DownloadItem, +internal fun DownloadListRow( + item: DownloadRowUiState, onCancel: () -> Unit, onRetry: () -> Unit, - onRemove: () -> Unit) { - val rowBackgroundColor = when (item.status) { - is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f) - is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f) - else -> AppTheme.colors.surface - } - + onRemove: () -> Unit +) { + val colors = MillenniumTheme.colors Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clip(AppTheme.shapes.small) - .background(rowBackgroundColor) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp)) { - val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) - ?.firstOrNull()?.url - - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) - .crossfade(false) - .build() - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(AppTheme.shapes.small), - contentAlignment = Alignment.Center) { - AsyncImage( - model = imageRequest, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize()) - - if (item.status == DownloadStatus.Downloading) { - MillenniumProgress( - progress = item.progress, - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .align(Alignment.BottomCenter)) - } - } - - Column(modifier = Modifier.weight(1f)) { + Modifier.fillMaxWidth().millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(8.dp), + borderColor = colors.border, + borderWidth = 1.dp, + innerGlowColor = colors.highlight.copy(alpha = 0.2f) + ).padding(12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = item.title, - style = AppTheme.typography.bodyLarge, + item.title, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis) - + overflow = TextOverflow.Ellipsis + ) Text( - text = item.artists, + item.artists, style = AppTheme.typography.bodySmall, - color = AppTheme.colors.primary, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis) + overflow = TextOverflow.Ellipsis + ) + Text( + when (item.status) { + DownloadStatus.Downloading -> stringResource( + Res.string.downloads_status_downloading, + item.percent + ) - when (item.status) { - is DownloadStatus.Downloading -> { - val percent = (item.progress * 100).toInt() - Text( - text = "Downloading... $percent%", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Queued -> { - Text( - text = "Queued", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Failed -> { - Text( - text = "Failed: ${item.errorMessage ?: "Unknown error"}", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.error, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - } - - is DownloadStatus.Cancelled -> { - Text( - text = "Cancelled", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) - } - - is DownloadStatus.Completed -> { - Text( - text = "Completed", - style = AppTheme.typography.labelSmall, - color = AppTheme.colors.primary) - } - } + DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued) + DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed) + DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled) + is DownloadStatus.Failed -> if (item.error != null) stringResource( + Res.string.downloads_status_failed_error, + item.error + ) else stringResource(Res.string.downloads_status_failed) + }, + style = AppTheme.typography.labelSmall, + color = when (item.status) { + is DownloadStatus.Failed -> AppTheme.colors.error + DownloadStatus.Completed -> AppTheme.colors.primary + else -> AppTheme.colors.onSurfaceVariant + }, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + if (item.status == DownloadStatus.Downloading) MillenniumProgress( + item.progress, + Modifier.fillMaxWidth().height(4.dp) + ) } - - when (item.status) { - is DownloadStatus.Downloading, is DownloadStatus.Queued -> { - MillenniumIconButton( - onClick = onCancel, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Cancel", - tint = AppTheme.colors.onSurfaceVariant) - } - } - - is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { - MillenniumIconButton( - onClick = onRetry, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Retry", - tint = AppTheme.colors.primary) - } - } - - is DownloadStatus.Completed -> { - MillenniumIconButton( - onClick = onRemove, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Remove", - tint = AppTheme.colors.onSurfaceVariant) - } - } + MillenniumIconButton( + onClick = when (item.action) { + DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove + }, + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon( + when (item.action) { + DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash + }, + stringResource( + when (item.action) { + DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove + } + ) + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt index b1aef155..a1b87a73 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt @@ -23,13 +23,64 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.map + +enum class DownloadRowAction { Cancel, Retry, Remove } + +data class DownloadRowUiState( + val id: String, + val title: String, + val artists: String, + val artworkUrl: String?, + val status: DownloadStatus, + val progress: Float, + val percent: Int, + val error: String?, + val action: DownloadRowAction +) + +data class DownloadsUiState( + val items: List = emptyList(), + val activeCount: Int = 0, + val completedCount: Int = 0, + val canClearCompleted: Boolean = false +) + +internal fun downloadsUiState(items: List): DownloadsUiState { + val completedCount = items.count { it.status == DownloadStatus.Completed } + return DownloadsUiState( + items = items.map { item -> + val progress = item.progress.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f + DownloadRowUiState( + item.id, item.title, item.artists, + (item.track?.album?.thumbnails + ?: item.track?.thumbnails)?.firstOrNull()?.url?.takeIf { it.isNotBlank() }, + item.status, progress, (progress * 100).toInt(), + item.errorMessage ?: (item.status as? DownloadStatus.Failed)?.error, + when (item.status) { + DownloadStatus.Completed -> DownloadRowAction.Remove + is DownloadStatus.Failed, DownloadStatus.Cancelled -> DownloadRowAction.Retry + else -> DownloadRowAction.Cancel + } + ) + }, + activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, + completedCount = completedCount, + canClearCompleted = completedCount > 0 + ) +} class DownloadsViewModel( - private val downloadManager: DownloadManager) : ViewModel() { + private val downloadManager: DownloadManager +) : ViewModel() { val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptyList()) + emptyList() + ) + + val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) fun downloadTrack(track: MetadataTrack) { downloadManager.enqueue(track) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt new file mode 100644 index 00000000..374ee5ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt @@ -0,0 +1,79 @@ +/* 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.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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 androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Only reports viewport geometry. The owning ViewModel decides when to load another page. */ +@Composable +internal fun LibraryMediaCollection( + items: List, + itemKey: (T) -> String, + viewMode: LibraryViewMode, + onViewportChanged: (Int?, Int) -> Unit, + itemContent: @Composable (T, Boolean) -> Unit, + skeleton: @Composable (Boolean) -> Unit, + loading: Boolean = false, + loadingMore: Boolean = false, + header: (@Composable (Boolean) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val gridState = rememberLazyGridState() + val listState = rememberLazyListState() + val reportViewport by rememberUpdatedState(onViewportChanged) + val padding = PaddingValues(top = 16.dp, bottom = LocalAppShellBottomInset.current + 16.dp) + LaunchedEffect(viewMode, gridState, listState) { + val positions = if (viewMode == LibraryViewMode.List) snapshotFlow { + listState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } else snapshotFlow { + gridState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount } + } + positions.distinctUntilChanged().collect { (last, total) -> reportViewport(last, total) } + } + if (viewMode == LibraryViewMode.List) { + LazyColumn( + modifier.fillMaxSize(), state = listState, contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } + else { + if (header != null) item(key = "library_header") { header(true) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } + } + } + } else { + LazyVerticalGrid( + GridCells.Adaptive(160.dp), modifier.fillMaxSize(), state = gridState, + contentPadding = padding, horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } + else { + if (header != null) item(key = "library_header") { header(false) } + items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } + if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index de796a5b..4b7b24af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.input.TextFieldState @@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint @@ -63,6 +65,9 @@ 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 compose.icons.FeatherIcons +import compose.icons.feathericons.Grid +import compose.icons.feathericons.List import spotube.composeapp.generated.resources.* @Composable @@ -70,13 +75,24 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { val state by libraryState.uiState.collectAsStateWithLifecycle() val expandedDesktop = getPlatform().isDesktop() && (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) + val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded + val compactDesktop = getPlatform().isDesktop() && isCompact + val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact) val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current + if (state.currentTab == LibraryTab.Downloads && !isCompact) { + DownloadsScreen( + headerInAppBar = true, + topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) } + ) + return + } + Column(Modifier.fillMaxSize()) { ApplicationMainBar( backButton = false, - title = { LibraryToolbarTitle(state, expandedDesktop) }, + title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) }, actions = { LibraryToolbarActions( state = state, @@ -88,32 +104,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { libraryState.closeSearch() }, onCreatePlaylist = libraryState::showCreatePlaylist, - onRefresh = libraryState::refreshLocalTracks + onRefresh = libraryState::refreshLocalTracks, + onToggleView = { libraryState.toggleViewMode(isCompact) }, + viewMode = viewMode, + compactDesktop = compactDesktop ) } ) 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)) } - } - } + LibraryTabs(state, libraryState::onTabSelected) } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { when (state.currentTab) { LibraryTab.Playlists -> LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, - onDismissCreatePlaylist = libraryState::dismissCreatePlaylist + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, + viewMode = viewMode ) - LibraryTab.Albums -> LibraryAlbumsScreen() - LibraryTab.Artists -> LibraryArtistsScreen() + LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode) + LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode) LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } @@ -122,7 +132,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { } @Composable -internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) { +private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LibraryTab.entries) { tab -> + MillenniumButton( + onClick = { onTabSelected(tab) }, + style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary + ) { Text(stringResource(tab.titleResource)) } + } + } +} + +@Composable +internal fun LibraryToolbarTitle( + state: LibraryToolbarUiState, + expandedDesktop: Boolean, + compactDesktop: Boolean = false +) { if (!expandedDesktop && state.searchMode) { LibraryFilterField( state.searchState, @@ -131,7 +160,11 @@ internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: autoFocus = true ) } else if (!expandedDesktop) { - Text(stringResource(Res.string.library_screen_title)) + Text( + stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } @@ -142,13 +175,18 @@ internal fun RowScope.LibraryToolbarActions( onOpenSearch: () -> Unit, onCloseSearch: () -> Unit, onCreatePlaylist: () -> Unit, - onRefresh: () -> Unit + onRefresh: () -> Unit, + onToggleView: () -> Unit = {}, + viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop), + compactDesktop: Boolean = false ) { + val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp) if (!expandedDesktop && state.searchMode) { MillenniumIconButton( icon = Iconsax.IconsaxCloseSquare, contentDescription = stringResource(Res.string.library_action_close_search), onClick = onCloseSearch, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } else { @@ -164,6 +202,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxSearchBroken, contentDescription = stringResource(Res.string.library_action_search), onClick = onOpenSearch, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } @@ -173,6 +212,7 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxAddSquare, contentDescription = stringResource(Res.string.library_action_create_playlist), onClick = onCreatePlaylist, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } @@ -181,6 +221,16 @@ internal fun RowScope.LibraryToolbarActions( icon = Iconsax.IconsaxRefreshRight, contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), onClick = onRefresh, + modifier = actionModifier, + style = MillenniumIconButtonStyle.Quiet + ) + } + if (state.canChangeView) { + MillenniumIconButton( + icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List, + contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view), + onClick = onToggleView, + modifier = actionModifier, style = MillenniumIconButtonStyle.Quiet ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt index 9002784f..4e7effbf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt @@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +enum class LibraryViewMode { Grid, List } data class LibraryToolbarUiState( val currentTab: LibraryTab = LibraryTab.Playlists, @@ -39,7 +42,9 @@ data class LibraryToolbarUiState( val canFilter: Boolean = true, val canCreatePlaylist: Boolean = true, val canRefresh: Boolean = false, - val showCreatePlaylist: Boolean = false + val showCreatePlaylist: Boolean = false, + val canChangeView: Boolean = true, + val viewModeOverride: LibraryViewMode? = null ) /** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */ @@ -49,14 +54,16 @@ class LibraryState : ViewModel() { val currentTab = MutableStateFlow(LibraryTab.Playlists) private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } private val createPlaylistVisible = MutableStateFlow(false) + private val viewModes = MutableStateFlow>(emptyMap()) private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) val localRefreshRequests = refreshRequests.asSharedFlow() val uiState = combine( currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, searchMode, - createPlaylistVisible - ) { (tab, query), searching, creating -> + createPlaylistVisible, + viewModes + ) { (tab, query), searching, creating, modes -> LibraryToolbarUiState( currentTab = tab, searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), @@ -64,7 +71,9 @@ class LibraryState : ViewModel() { canFilter = tab != LibraryTab.Downloads, canCreatePlaylist = tab == LibraryTab.Playlists, canRefresh = tab == LibraryTab.LocalTracks, - showCreatePlaylist = tab == LibraryTab.Playlists && creating + showCreatePlaylist = tab == LibraryTab.Playlists && creating, + canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, + viewModeOverride = modes[tab] ) }.stateIn( viewModelScope, SharingStarted.Eagerly, @@ -103,4 +112,16 @@ class LibraryState : ViewModel() { fun refreshLocalTracks() { refreshRequests.tryEmit(Unit) } + + fun toggleViewMode(isCompact: Boolean) { + val tab = currentTab.value + if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return + viewModes.update { modes -> + val current = modes[tab] ?: defaultLibraryViewMode(isCompact) + modes + (tab to if (current == LibraryViewMode.Grid) LibraryViewMode.List else LibraryViewMode.Grid) + } + } } + +internal fun defaultLibraryViewMode(isCompact: Boolean) = + if (isCompact) LibraryViewMode.List else LibraryViewMode.Grid diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index b422147f..7bc5bdaa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -1,158 +1,76 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.album - -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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -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.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 com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum 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 kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryAlbumsScreen( modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryAlbumsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() + when (val current = state) { + is LibraryAlbumsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() + else -> { + val data = current as? LibraryAlbumsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_albums_no_results, + data.query + ) + else stringResource(Res.string.library_albums_empty) + ) } - } - } - - Column(modifier = modifier) { - when (state) { - is LibraryAlbumsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - - is LibraryAlbumsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryAlbumsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryAlbumsState.Data -> { - val dataState = state as LibraryAlbumsState.Data - if (dataState.items.isEmpty() && state !is LibraryAlbumsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No albums found for '${dataState.query}'") - else -> Text("No saved albums") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(dataState.items, key = { it.id }) { album -> - AlbumCard( - album = album, - modifier = Modifier.fillMaxWidth() - ) - } - - if (state is LibraryAlbumsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - } - } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryAlbumsState.Loading, + loadingMore = current is LibraryAlbumsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + AlbumCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 60fd0b39..2f9de499 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -51,17 +51,16 @@ sealed interface LibraryAlbumsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -75,17 +74,16 @@ sealed interface LibraryAlbumsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { album -> - album.title.contains(query, ignoreCase = true) || - album.artists.any { artist -> - artist.name.contains(query, ignoreCase = true) - } - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { album -> + album.title.contains(query, ignoreCase = true) || + album.artists.any { artist -> + artist.name.contains(query, ignoreCase = true) + } } + } else { + allItems + } } } @@ -156,6 +154,12 @@ class LibraryAlbumsViewModel( libraryState.setQuery(LibraryTab.Albums, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index 2f64b93c..06b0ef94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -1,158 +1,76 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.artist - -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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -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 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 com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist 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 kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryArtistsScreen( modifier: Modifier = Modifier, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryArtistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() + when (val current = state) { + is LibraryArtistsState.Error -> Box( + modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) + } - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) - } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() + else -> { + val data = current as? LibraryArtistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_artists_no_results, + data.query + ) + else stringResource(Res.string.library_artists_empty) + ) } - } - } - - Column(modifier = modifier) { - when (state) { - is LibraryArtistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } - - is LibraryArtistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryArtistsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryArtistsState.Data -> { - val dataState = state as LibraryArtistsState.Data - if (dataState.items.isEmpty() && state !is LibraryArtistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotBlank() -> Text("No artists found for '${dataState.query}'") - else -> Text("No saved artists") - } - } - } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(dataState.items, key = { it.id }) { artist -> - ArtistCard( - artist = artist, - modifier = Modifier.fillMaxWidth() - ) - } - -// if (state is LibraryArtistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - AvatarCard( - title = "Artist Name", - subtitle = "Artist", - imageURL = "https://placehold.co/400x400" - ) - } - } -// } - } - } - } + } else LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryArtistsState.Loading, + loadingMore = current is LibraryArtistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + itemContent = { item, list -> + ArtistCard( + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + AvatarCard( + stringResource(Res.string.media_preview_subtitle), + stringResource(Res.string.media_artist), + modifier = Modifier.fillMaxWidth(), + listMode = list + ) + }, + modifier = modifier + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index fa5556ce..7e8cb1df 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -51,14 +51,13 @@ sealed interface LibraryArtistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -72,14 +71,13 @@ sealed interface LibraryArtistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { artist -> - artist.name.contains(query, ignoreCase = true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { artist -> + artist.name.contains(query, ignoreCase = true) } + } else { + allItems + } } } @@ -164,6 +162,12 @@ class LibraryArtistsViewModel( libraryState.setQuery(LibraryTab.Artists, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 71e3c4f6..1cd80768 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -1,31 +1,8 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.local_tracks - -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 org.spotube.millennium.composables.MillenniumIndeterminateProgress -import org.spotube.millennium.composables.MillenniumListTile -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,161 +10,288 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items - -import com.composeunstyled.Text - +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.getPlatform +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork +import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.theming.AppTheme 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.IconsaxFolderOpen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 -import dev.krtirtho.spotube.resources.iconsax.Play +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.pluralStringResource import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +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.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* @Composable -fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) { - val viewModel = koinViewModel() +fun LibraryLocalTracksScreen( + modifier: Modifier = Modifier, + viewModel: LibraryLocalTracksViewModel = koinViewModel() +) { val state by viewModel.state.collectAsStateWithLifecycle() - val permissionState = rememberLocalMediaPermissionState() - val platformType = remember { getPlatform().type } - val isAndroid = platformType == PlatformType.Android + val permission = rememberLocalMediaPermissionState() + // Platform permission events only; the ViewModel decides whether/when to index media. + LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } + LocalTracksContent( + state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, + viewModel::playCurrentFolder, permission.requestPermission, modifier + ) +} - LaunchedEffect(permissionState.isGranted, isAndroid) { - if (!isAndroid || permissionState.isGranted) { - viewModel.refresh() - } - } - - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - - Box(modifier = modifier.fillMaxWidth()) { +@Composable +internal fun LocalTracksContent( + state: LibraryLocalTracksState, + onOpenFolder: (String) -> Unit, + onPlayTrack: (String) -> Unit, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier +) { + val inset = LocalAppShellBottomInset.current + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp) + Modifier.widthIn(max = 960.dp).fillMaxSize() ) { - if (state.currentFolderPath != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - MillenniumIconButton( - icon = Iconsax.ArrowLeft3, - contentDescription = "Back", - onClick = viewModel::navigateUp, - style = MillenniumIconButtonStyle.Quiet - ) - Text(viewModel.currentFolder()?.name ?: "Folder") - } - MillenniumIconButton( - icon = Iconsax.IconsaxPlayCircle2, - contentDescription = "Play folder", - onClick = { - viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } - }, - style = MillenniumIconButtonStyle.Quiet - ) - } - } - - val folders = viewModel.visibleFolders() - val tracks = viewModel.visibleTracks() - - if (isAndroid && !permissionState.isGranted) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text("Allow audio permission to show local tracks") - MillenniumButton( - onClick = permissionState.requestPermission, - style = MillenniumButtonStyle.Quiet - ) { - Text("Grant access") + LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder) + when { + !state.canAccessMedia -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + stringResource(Res.string.library_local_permission_title), + Modifier.padding(bottom = inset), + stringResource(Res.string.library_local_permission_description), + action = { + MillenniumButton(onClick = onRequestPermission) { + Text( + stringResource( + Res.string.library_local_grant_access + ) + ) } - } - } - } else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + }) + + state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box( + Modifier.fillMaxSize().padding(bottom = inset), + contentAlignment = Alignment.Center + ) { MillenniumIndeterminateProgress() } - } else if (state.currentFolderPath == null) { - if (folders.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(state.error ?: "No local folders found") - } - } else { - LazyColumn(contentPadding = contentPadding) { - items(folders, key = { it.path }) { folder -> - MillenniumListTile( - title = { Text(folder.name) }, - subtitle = { Text("${folder.trackCount} track(s)") }, - leading = { - MillenniumIcon( - imageVector = Iconsax.IconsaxFolderOpen, - contentDescription = null - ) - }, - onClick = { viewModel.openFolder(folder.path) }) - } + + state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.visibleFolders, key = { it.path }) { folder -> + LocalFolderRow(folder) { onOpenFolder(folder.path) } } } - } else { - if (tracks.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No tracks in this folder") - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { - items(tracks, key = { it.path }) { track -> - MillenniumListTile( - title = { Text(track.name) }, - subtitle = { - val artists = - track.artists.joinToString().ifBlank { "Unknown artist" } - val album = track.album?.takeIf { it.isNotBlank() } - Text(if (album == null) artists else "$artists - $album") - }, - leading = { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicCircle, - contentDescription = null + + state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(bottom = inset + 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.visibleTracks, key = { it.path }) { track -> + PlayableListTile( + track.title, + track.subtitle ?: stringResource(Res.string.media_unknown_artist), + imageModel = track.artwork, + onClick = { onPlayTrack(track.path) }, + onPlay = { onPlayTrack(track.path) }, + trailingContent = { + track.durationLabel?.let { + Text( + it, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) - }, - trailing = { - MillenniumIconButton( - icon = Iconsax.Play, - contentDescription = "Play", - onClick = { viewModel.playTrack(track.path) }, - style = MillenniumIconButtonStyle.Quiet - ) - }, - onClick = { viewModel.playTrack(track.path) }) - } + } + }) } } + + else -> MediaEmptyState( + Iconsax.IconsaxFolderOpen, + state.error ?: stringResource( + when { + state.query.isNotBlank() -> Res.string.library_local_no_results + state.currentFolderPath == null -> Res.string.library_local_empty_folders + else -> Res.string.library_local_empty_tracks + } + ), Modifier.padding(bottom = inset) + ) } } } } + +@Composable +internal fun LocalTracksBreadcrumb( + state: LibraryLocalTracksState, + onNavigateUp: () -> Unit, + onPlayFolder: () -> Unit, + modifier: Modifier = Modifier +) { + val backDescription = stringResource(Res.string.library_local_back) + Row( + modifier.fillMaxWidth().height(32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + if (state.currentFolderPath != null) { + MillenniumButton( + onClick = onNavigateUp, + style = MillenniumButtonStyle.Quiet, + modifier = Modifier.height(28.dp) + .semantics { contentDescription = backDescription }, + padding = PaddingValues(0.dp), + minSize = DpSize(0.dp, 28.dp), + foreground = AppTheme.colors.primary + ) { + Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall + ) + } + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(12.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + state.currentFolder?.name ?: stringResource(Res.string.library_local_folder), + style = AppTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else Text( + stringResource(Res.string.library_local_folders), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + Text( + if (state.currentFolderPath != null) pluralStringResource( + Res.plurals.library_local_tracks_count, + state.visibleTracks.size, + state.visibleTracks.size + ) + else stringResource( + Res.string.library_local_summary, + pluralStringResource( + Res.plurals.library_local_folders_count, + state.totalFolderCount, + state.totalFolderCount + ), + pluralStringResource( + Res.plurals.library_local_tracks_count, + state.totalTrackCount, + state.totalTrackCount + ) + ), + style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant, + maxLines = 1 + ) + if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp)) + if (state.currentFolderPath != null) MillenniumIconButton( + onClick = onPlayFolder, enabled = state.canPlayFolder, + modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + Iconsax.IconsaxPlay, + stringResource(Res.string.library_local_play_folder), + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { + MillenniumListTile( + onClick = onClick, + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + contentPadding = PaddingValues(12.dp), + minimumHeight = 76.dp, + leading = { + MediaArtwork( + folder.artwork, + folder.name, + Modifier.size(52.dp), + placeholderIcon = Iconsax.IconsaxFolderOpen + ) + }, + title = { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = { + Text( + pluralStringResource( + Res.plurals.library_local_tracks_count, + folder.trackCount, + folder.trackCount + ), style = AppTheme.typography.bodySmall + ) + }, + trailing = { + MillenniumIcon( + FeatherIcons.ChevronRight, + null, + modifier = Modifier.size(18.dp) + ) + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 63a0d18d..84b14cff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -33,25 +33,103 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.getPlatform +import org.jetbrains.compose.resources.getString +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.library_local_refresh_failed + +data class LocalFolderUi( + val path: String, + val name: String, + val trackCount: Int, + val artwork: ByteArray? +) + +data class LocalTrackUi( + val path: String, + val title: String, + val subtitle: String?, + val durationLabel: String?, + val artwork: ByteArray? +) data class LibraryLocalTracksState( val isRefreshing: Boolean = false, val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null + val error: String? = null, + val canAccessMedia: Boolean = true, + val visibleFolders: List = emptyList(), + val visibleTracks: List = emptyList(), + val currentFolder: LocalMediaFolder? = null, + val totalFolderCount: Int = 0, + val totalTrackCount: Int = 0, + val canPlayFolder: Boolean = false ) +internal fun localTracksUiState( + source: LibraryLocalTracksState, + query: String +): LibraryLocalTracksState { + val normalized = query.trim() + val folder = source.folders.firstOrNull { it.path == source.currentFolderPath } + val folders = source.folders.filter { + normalized.isEmpty() || it.name.contains( + normalized, + true + ) || it.tracks.any { track -> localTrackMatches(track, normalized) } + } + val tracks = folder?.tracks.orEmpty().filter { localTrackMatches(it, normalized) } + return source.copy( + query = query, + currentFolder = folder, + visibleFolders = folders.map { + LocalFolderUi( + it.path, + it.name, + it.trackCount, + it.tracks.firstOrNull { track -> track.coverBytes?.isNotEmpty() == true }?.coverBytes + ) + }, + visibleTracks = tracks.map { track -> + val seconds = track.durationMs / 1000 + LocalTrackUi( + track.path, track.name, + listOfNotNull( + track.artists.joinToString(", ").takeIf { it.isNotBlank() }, + track.album?.takeIf { it.isNotBlank() }).joinToString(" • ") + .takeIf { it.isNotBlank() }, + if (seconds > 0) "${seconds / 60}:${ + (seconds % 60).toString().padStart(2, '0') + }" else null, + track.coverBytes?.takeIf { it.isNotEmpty() }) + }, + totalFolderCount = source.folders.size, + totalTrackCount = source.folders.sumOf { it.trackCount }, + canPlayFolder = source.canAccessMedia && tracks.isNotEmpty() + ) +} + +private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = query.isBlank() || + track.name.contains(query, true) || track.album?.contains( + query, + true +) == true || track.artists.any { it.contains(query, true) } + class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, private val audioPlayerQueue: AudioPlayerQueue, private val libraryState: LibraryState ) : ViewModel() { - private val _state = MutableStateFlow(LibraryLocalTracksState()) + private val requiresPermission = getPlatform().type == PlatformType.Android + private val _state = + MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission)) val state: StateFlow = combine( _state, libraryState.queryFlow(LibraryTab.LocalTracks) - ) { state, query -> state.copy(query = query) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState()) + ) { state, query -> localTracksUiState(state, query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value) init { viewModelScope.launch { @@ -85,14 +163,28 @@ class LibraryLocalTracksViewModel( _state.update { it.copy(currentFolderPath = null) } } + fun onPermissionChanged(granted: Boolean) { + val accessible = !requiresPermission || granted + val wasAccessible = _state.value.canAccessMedia + _state.update { it.copy(canAccessMedia = accessible) } + if (accessible && !wasAccessible) refresh() + } + + fun playCurrentFolder() { + _state.value.currentFolderPath?.let(::playFolder) + } + fun refresh() { + if (!_state.value.canAccessMedia) return viewModelScope.launch { _state.update { it.copy(isRefreshing = true, error = null) } runCatching { coordinator.refreshNow("manual") }.onFailure { throwable -> + val message = + throwable.message ?: getString(Res.string.library_local_refresh_failed) _state.update { - it.copy(error = throwable.message ?: "Failed to refresh local media") + it.copy(error = message) } } _state.update { it.copy(isRefreshing = false) } @@ -100,6 +192,7 @@ class LibraryLocalTracksViewModel( } fun playFolder(folderPath: String) { + if (!_state.value.canAccessMedia) return val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return val tracks = filterTracks(folder.tracks, state.value.query) if (tracks.isEmpty()) return @@ -115,6 +208,7 @@ class LibraryLocalTracksViewModel( } fun playTrack(trackPath: String) { + if (!_state.value.canAccessMedia) return val folder = currentFolder() ?: return val tracks = filterTracks(folder.tracks, state.value.query) val selectedIndex = tracks.indexOfFirst { it.path == trackPath } @@ -130,28 +224,7 @@ class LibraryLocalTracksViewModel( } } - fun visibleFolders(): List { - val query = state.value.query.trim() - if (query.isBlank()) return state.value.folders - - 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) } - } - } - } - - fun visibleTracks(): List { - val folder = currentFolder() ?: return emptyList() - return filterTracks(folder.tracks, state.value.query) - } - - fun currentFolder(): LocalMediaFolder? { - val path = state.value.currentFolderPath ?: return null - return state.value.folders.firstOrNull { it.path == path } - } + private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder private fun refreshIfEmpty() { if (_state.value.folders.isNotEmpty()) return @@ -159,6 +232,7 @@ class LibraryLocalTracksViewModel( } private fun refreshIfStale() { + if (!_state.value.canAccessMedia) return viewModelScope.launch { runCatching { coordinator.refreshIfStale() @@ -170,11 +244,7 @@ class LibraryLocalTracksViewModel( val normalizedQuery = query.trim() if (normalizedQuery.isBlank()) return tracks - 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) } - } + return tracks.filter { localTrackMatches(it, normalizedQuery) } } private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index e5d41549..56ad4381 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -1,180 +1,94 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - package dev.krtirtho.spotube.modules.library.playlist - -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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -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 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 com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist 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 kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import dev.krtirtho.spotube.modules.library.LibraryMediaCollection +import dev.krtirtho.spotube.modules.library.LibraryViewMode +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.* @Composable fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, showCreatePlaylist: Boolean = false, onDismissCreatePlaylist: () -> Unit = {}, + viewMode: LibraryViewMode = LibraryViewMode.Grid, viewModel: LibraryPlaylistsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - val gridState = rememberLazyGridState() - - LaunchedEffect(gridState) { - snapshotFlow { gridState.layoutInfo } - .map { layoutInfo -> - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index - val totalItems = layoutInfo.totalItemsCount - Pair(lastVisibleIndex, totalItems) + Column(modifier) { + when (val current = state) { + is LibraryPlaylistsState.Error -> Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh) } - .distinctUntilChanged() - .collect { (lastVisibleIndex, totalItems) -> - if ( - lastVisibleIndex != null && - totalItems > 0 && - lastVisibleIndex >= totalItems - 4 - ) { - viewModel.loadMoreData() - } - } - } - Column(modifier = modifier) { - when (state) { - is LibraryPlaylistsState.Loading -> { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(8) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400" + else -> { + val data = current as? LibraryPlaylistsState.Data + if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (data.query.isNotBlank()) stringResource( + Res.string.library_playlists_no_results, + data.query ) - } - } - } - } - - is LibraryPlaylistsState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ErrorDisplay( - errorMessage = (state as LibraryPlaylistsState.Error).message, - onRetry = { viewModel.refresh() }) - } - } - - is LibraryPlaylistsState.Data -> { - val dataState = state as LibraryPlaylistsState.Data - if (dataState.items.isEmpty() && state !is LibraryPlaylistsState.Data.LoadingMore) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - dataState.query.isNotEmpty() -> Text("No playlists found for '${dataState.query}'") - else -> Text("No saved playlists") - } + else stringResource(Res.string.library_playlists_empty) + ) } } else { - val shellBottomInset = LocalAppShellBottomInset.current - val contentPadding = remember(shellBottomInset) { - PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(160.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { + LibraryMediaCollection( + items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode, + loading = current is LibraryPlaylistsState.Loading, + loadingMore = current is LibraryPlaylistsState.Data.LoadingMore, + onViewportChanged = viewModel::onItemsVisible, + header = { list -> LikedTracksCard( - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + listMode = list ) - } - - items(dataState.items, key = { it.id }) { playlist -> + }, + itemContent = { item, list -> PlaylistCard( - playlist = playlist, - modifier = Modifier.fillMaxWidth() + item, + Modifier.fillMaxWidth(), + listMode = list + ) + }, + skeleton = { list -> + PlayableCard( + stringResource(Res.string.media_preview_title), + stringResource(Res.string.media_preview_subtitle), + modifier = Modifier.fillMaxWidth(), + listMode = list ) } - - if (state is LibraryPlaylistsState.Data.LoadingMore) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Playlist Name", - subtitle = "By Artist", - imageURL = "https://placehold.co/600x400" - ) - } - } - } - } + ) } } } - PlaylistFormSheet( visible = showCreatePlaylist, onDismiss = onDismissCreatePlaylist, - onSubmit = { data -> - viewModel.createPlaylist( - name = data.name, - description = data.description.ifBlank { null }, - isPublic = data.isPublic, - isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64, - trackIds = emptyList() - ) - }) + onSubmit = viewModel::createPlaylist + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 0072a4ac..4c6662aa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -51,15 +51,14 @@ sealed interface LibraryPlaylistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) } + } else { + allItems + } fun toLoadingMore(): LoadingMore = LoadingMore( query = query, @@ -73,15 +72,14 @@ sealed interface LibraryPlaylistsState { override val allItems: List = emptyList(), override val nextPagination: PaginationStrategy? = null ) : Data { - override val items: List - get() = if (query.isNotBlank()) { - allItems.filter { - it.title.contains(query, ignoreCase = true) || - (it.description?.contains(query, ignoreCase = true) == true) - } - } else { - allItems + override val items: List = if (query.isNotBlank()) { + allItems.filter { + it.title.contains(query, ignoreCase = true) || + (it.description?.contains(query, ignoreCase = true) == true) } + } else { + allItems + } } } @@ -152,6 +150,17 @@ class LibraryPlaylistsViewModel( libraryState.setQuery(LibraryTab.Playlists, query) } + fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) { + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) { + viewModelScope.launch { loadMoreData() } + } + } + + fun createPlaylist(data: PlaylistFormData) = createPlaylist( + data.name, data.description.ifBlank { null }, data.isPublic, + data.isCollaborating, data.imageBase64, emptyList() + ) + fun refresh() { viewModelScope.launch { repository.invalidateCaches() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index 7cc96696..ffcf1dfb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -74,7 +74,8 @@ data class PlaylistFormData( val isPublic: Boolean = true, val isCollaborating: Boolean = false, val imageBase64: String = "", - val imagePreviewUrl: String? = null) + val imagePreviewUrl: String? = null +) @Composable fun PlaylistFormSheet( @@ -83,7 +84,8 @@ fun PlaylistFormSheet( onSubmit: (PlaylistFormData) -> Unit, modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, - isEditing: Boolean = false) { + isEditing: Boolean = false +) { if (!visible) return val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium @@ -91,7 +93,11 @@ fun PlaylistFormSheet( var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } - var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) } + var isCollaborating by remember(initialData) { + mutableStateOf( + initialData?.isCollaborating ?: false + ) + } var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } @@ -131,7 +137,8 @@ fun PlaylistFormSheet( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl) + imagePreviewUrl = imagePreviewUrl + ) val handleImagePick = { imagePicker.launch() } @@ -140,7 +147,8 @@ fun PlaylistFormSheet( visible = true, onDismissRequest = onDismiss, title = if (isEditing) "Edit Playlist" else "Create Playlist", - modifier = modifier) { + modifier = modifier + ) { PlaylistFormFields( nameState = nameState, descriptionState = descriptionState, @@ -150,19 +158,22 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick) + onPickImage = handleImagePick + ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank()) { + enabled = name.isNotBlank() + ) { Text(if (isEditing) "Save" else "Create") } MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text("Cancel") } } @@ -173,11 +184,13 @@ fun PlaylistFormSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp)) { + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { Text( text = if (isEditing) "Edit Playlist" else "Create Playlist", style = AppTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp)) + modifier = Modifier.padding(bottom = 16.dp) + ) PlaylistFormFields( nameState = nameState, descriptionState = descriptionState, @@ -187,14 +200,17 @@ fun PlaylistFormSheet( onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick) + onPickImage = handleImagePick + ) Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { MillenniumButton( onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { + style = MillenniumButtonStyle.Secondary + ) { Text("Cancel") } MillenniumButton( @@ -202,7 +218,8 @@ fun PlaylistFormSheet( onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank()) { + enabled = name.isNotBlank() + ) { Text(if (isEditing) "Save" else "Create") } } @@ -222,23 +239,27 @@ private fun PlaylistFormFields( onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, - onPickImage: () -> Unit) { + onPickImage: () -> Unit +) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp)) { + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { MillenniumTextField( state = nameState, label = "Name", modifier = Modifier.fillMaxWidth(), - placeholder = "Playlist name") + placeholder = "Playlist name" + ) MillenniumTextField( state = descriptionState, label = "Description", modifier = Modifier.fillMaxWidth(), - placeholder = "Description (optional)") + placeholder = "Description (optional)" + ) Row( modifier = Modifier @@ -253,7 +274,8 @@ private fun PlaylistFormFields( Text( "Anyone can see this playlist", style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) } @@ -271,40 +293,49 @@ private fun PlaylistFormFields( Text( "Others can add tracks to this playlist", style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text("Cover Image", style = AppTheme.typography.bodyLarge) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center) { + contentAlignment = Alignment.Center + ) { when { imagePreviewUrl != null -> AsyncImage( model = imagePreviewUrl, contentDescription = "Playlist cover", modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop) + contentScale = ContentScale.Crop + ) + imageBase64.isNotBlank() -> { Text( "✓", style = AppTheme.typography.titleMedium, - color = AppTheme.colors.primary) + color = AppTheme.colors.primary + ) } + else -> { Text( "No image", style = AppTheme.typography.labelSmall, - color = AppTheme.colors.onSurfaceVariant) + color = AppTheme.colors.onSurfaceVariant + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt new file mode 100644 index 00000000..8dbc4973 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersViewModel.kt @@ -0,0 +1,128 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import spotube.composeapp.generated.resources.* + +data class LocalMediaFolderRowUi(val path: String, val name: String) + +data class LocalMediaFoldersUiState( + val isOpen: Boolean = false, + val configuredCount: Int = 0, + val singleConfiguredPath: String? = null, + val folders: List = emptyList(), + val isSaving: Boolean = false, + val canSave: Boolean = false, + val saveFailed: Boolean = false, + val automaticNote: StringResource? = null +) + +private data class FolderDraft( + val isOpen: Boolean = false, + val original: List = emptyList(), + val paths: List = emptyList(), + val isSaving: Boolean = false, + val saveFailed: Boolean = false +) + +/** The editor keeps a snapshot until Save; incoming settings updates never reset a draft. */ +class LocalMediaFoldersViewModel( + private val settings: StateFlow, + private val persistFolders: suspend (List) -> Unit +) : ViewModel() { + private val draft = MutableStateFlow(FolderDraft()) + + val uiState = combine(settings, draft, ::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, toUiState(settings.value, draft.value)) + + fun open() { + if (draft.value.isOpen || draft.value.isSaving) return + val paths = normalizedFolders(settings.value.localMediaFolders) + draft.value = FolderDraft(isOpen = true, original = paths, paths = paths) + } + + fun addFolder(path: String?) { + val normalized = normalizeMediaFolderPath(path.orEmpty()) + if (normalized.isBlank()) return + draft.update { current -> + if (!current.isOpen || current.isSaving || normalized in current.paths) current + else current.copy(paths = current.paths + normalized, saveFailed = false) + } + } + + fun removeFolder(path: String) { + draft.update { current -> + if (!current.isOpen || current.isSaving) current + else current.copy(paths = current.paths.filterNot { it == path }, saveFailed = false) + } + } + + fun dismiss() { + if (!draft.value.isSaving) draft.value = FolderDraft() + } + + fun save() { + val current = draft.value + if (!current.isOpen || current.isSaving || current.paths == current.original) return + val paths = current.paths.toList() + draft.value = current.copy(isSaving = true, saveFailed = false) + viewModelScope.launch { + try { + persistFolders(paths) + draft.value = FolderDraft() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + draft.update { it.copy(isSaving = false, saveFailed = true) } + } + } + } + + private fun toUiState(settings: UserSettings, draft: FolderDraft): LocalMediaFoldersUiState { + val configured = normalizedFolders(settings.localMediaFolders) + val hasDownloadFolder = !settings.overloadedDownloadFolder.isNullOrBlank() + return LocalMediaFoldersUiState( + isOpen = draft.isOpen, + configuredCount = configured.size, + singleConfiguredPath = configured.singleOrNull(), + folders = draft.paths.map { path -> + LocalMediaFolderRowUi( + path, + path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) + }, + isSaving = draft.isSaving, + canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, + saveFailed = draft.saveFailed, + automaticNote = when { + hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both + hasDownloadFolder -> Res.string.settings_local_media_folders_automatic_downloads + settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_cache + else -> null + } + ) + } +} + +private fun normalizedFolders(paths: List) = + paths.map(::normalizeMediaFolderPath).filter { it.isNotBlank() }.distinct() + +internal fun normalizeMediaFolderPath(path: String): String { + val trimmed = path.trim() + if (trimmed == "/" || trimmed == "\\") return trimmed + if (trimmed.length >= 3 && trimmed[0].isLetter() && trimmed[1] == ':' && + trimmed.substring(2).all { it == '/' || it == '\\' } + ) return trimmed.take(3) + return trimmed.trimEnd('/', '\\') +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt new file mode 100644 index 00000000..581a2dcf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -0,0 +1,257 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import dev.krtirtho.spotube.modules.settings.LocalMediaFolderRowUi +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive +import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen +import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumBevelBrush +import org.spotube.millennium.theme.millenniumSurface +import spotube.composeapp.generated.resources.* + +@Composable +internal fun LocalMediaFoldersEditorContent( + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(Res.string.settings_local_media_folders_description), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + pluralStringResource( + Res.plurals.settings_local_media_folders_count, + state.folders.size, + state.folders.size + ), + style = AppTheme.typography.labelMedium, modifier = Modifier.weight(1f) + ) + MillenniumButton( + onClick = onAddFolder, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary, + padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + minSize = DpSize(0.dp, 36.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon(Iconsax.IconsaxAddSquare, null, modifier = Modifier.size(16.dp)) + Text( + stringResource(Res.string.settings_local_media_folders_add_action), + style = AppTheme.typography.labelMedium + ) + } + } + } + Box( + Modifier.fillMaxWidth() + .heightIn(min = if (state.folders.isEmpty()) 104.dp else 76.dp, max = 240.dp) + .millenniumSurface( + background = SolidColor(MillenniumTheme.colors.background), + shape = RoundedCornerShape(8.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + if (state.folders.isEmpty()) { + Column( + Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(Res.string.settings_local_media_folders_none_added), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } else { + LazyColumn( + Modifier.fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(vertical = 4.dp) + ) { + itemsIndexed( + state.folders, + key = { _, folder -> folder.path }) { index, folder -> + if (index > 0) MillenniumSeparator(Modifier.padding(horizontal = 12.dp)) + LocalMediaFolderEditorRow( + folder, + !state.isSaving + ) { onRemoveFolder(folder.path) } + } + } + if (listState.canScrollForward || listState.canScrollBackward) { + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } + state.automaticNote?.let { note -> + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxDirectboxReceive, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource(note), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } + if (state.saveFailed) Text( + stringResource(Res.string.settings_local_media_folders_save_error), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.error + ) + MillenniumSeparator() + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MillenniumButton( + onClick = onCancel, + enabled = !state.isSaving, + style = MillenniumButtonStyle.Secondary + ) { + Text(stringResource(Res.string.settings_action_cancel)) + } + MillenniumButton(onClick = onSave, enabled = state.canSave) { + Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.settings_action_save)) + } + } + } +} + +@Composable +private fun LocalMediaFolderEditorRow( + folder: LocalMediaFolderRowUi, + enabled: Boolean, + onRemove: () -> Unit +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(36.dp).millenniumSurface( + background = millenniumBevelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(6.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + Iconsax.IconsaxFolderOpen, + null, + modifier = Modifier.size(20.dp), + tint = AppTheme.colors.primary + ) + } + Box(Modifier.weight(1f)) { + MillenniumTooltip(folder.path) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + folder.name, + style = AppTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + folder.path, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp + ), + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + val removeLabel = + stringResource(Res.string.settings_local_media_folders_remove_named, folder.name) + MillenniumTooltip(removeLabel) { + MillenniumIconButton( + onClick = onRemove, + enabled = enabled, + modifier = Modifier.size(32.dp), + style = MillenniumIconButtonStyle.Secondary + ) { + MillenniumIcon(Iconsax.IconsaxTrash, removeLabel, modifier = Modifier.size(16.dp)) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index 62350c83..a4656eea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -18,61 +18,42 @@ package dev.krtirtho.spotube.modules.settings.sections -import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel +import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.settings_action_cancel -import spotube.composeapp.generated.resources.settings_action_save import spotube.composeapp.generated.resources.settings_download_folder_current import spotube.composeapp.generated.resources.settings_download_folder_default import spotube.composeapp.generated.resources.settings_download_folder_title import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_title import spotube.composeapp.generated.resources.settings_download_quality_title -import spotube.composeapp.generated.resources.settings_local_media_folders_add_action -import spotube.composeapp.generated.resources.settings_local_media_folders_description import spotube.composeapp.generated.resources.settings_local_media_folders_manage -import spotube.composeapp.generated.resources.settings_local_media_folders_none_added -import spotube.composeapp.generated.resources.settings_local_media_folders_remove_action import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty import spotube.composeapp.generated.resources.settings_local_media_folders_title @@ -82,11 +63,13 @@ import spotube.composeapp.generated.resources.settings_subtitle_current internal fun LazyListScope.downloadsSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true) { + includeHeader: Boolean = true +) { val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadQualities = availableAudioQualities( format = settings.downloadMusicFormat, - current = settings.downloadMusicQuality) + current = settings.downloadMusicQuality + ) if (includeHeader) { settingsSectionHeader(Res.string.settings_section_downloads) @@ -104,14 +87,7 @@ internal fun LazyListScope.downloadsSection( ) }, { - LocalMediaFoldersSettingCard( - folders = settings.localMediaFolders, - onFoldersSaved = { folders -> - settingsViewModel.updateSettings { - copy(localMediaFolders = folders) - } - } - ) + LocalMediaFoldersSettingCard() }, { SelectionSettingCard( @@ -121,7 +97,10 @@ internal fun LazyListScope.downloadsSection( settings.downloadMusicFormat.displayLabel() ), icon = { - SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title)) + SettingsItemIcon( + Iconsax.IconsaxMusicPlay, + stringResource(Res.string.settings_download_format_title) + ) }, selectedOption = settings.downloadMusicFormat, options = downloadFormats, @@ -130,7 +109,8 @@ internal fun LazyListScope.downloadsSection( settingsViewModel.updateSettings { copy( downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality)) + downloadMusicQuality = format.resolveQuality(downloadMusicQuality) + ) } } ) @@ -143,7 +123,10 @@ internal fun LazyListScope.downloadsSection( settings.downloadMusicQuality.displayLabel() ), icon = { - SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title)) + SettingsItemIcon( + Iconsax.IconsaxVerticalSetting, + stringResource(Res.string.settings_download_quality_title) + ) }, selectedOption = settings.downloadMusicQuality, options = downloadQualities, @@ -161,7 +144,8 @@ internal fun LazyListScope.downloadsSection( @Composable private fun DownloadFolderSettingCard( folder: String?, - onFolderSelected: (String?) -> Unit) { + onFolderSelected: (String?) -> Unit +) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -177,14 +161,18 @@ private fun DownloadFolderSettingCard( stringResource(Res.string.settings_download_folder_current, it) } ?: stringResource(Res.string.settings_download_folder_default), icon = { - SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) + SettingsItemIcon( + Iconsax.IconsaxDirectboxReceive, + stringResource(Res.string.settings_download_folder_title) + ) }, trailingContent = { MillenniumIconButton( icon = Iconsax.IconsaxFolderAdd, contentDescription = stringResource(Res.string.settings_download_folder_title), onClick = { pickerLauncher.launch() }, - style = MillenniumIconButtonStyle.Quiet) + style = MillenniumIconButtonStyle.Quiet + ) }, onClick = { pickerLauncher.launch() @@ -194,17 +182,17 @@ private fun DownloadFolderSettingCard( @Composable private fun LocalMediaFoldersSettingCard( - folders: List, - onFoldersSaved: (List) -> Unit) { - var isDialogOpen by remember { mutableStateOf(false) } - - val subtitlePreview = when { - folders.isEmpty() -> stringResource(Res.string.settings_local_media_folders_subtitle_empty) - folders.size == 1 -> folders.first() - else -> stringResource( - Res.string.settings_local_media_folders_subtitle_current, - folders.size) - } + viewModel: LocalMediaFoldersViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pickerLauncher = + rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) } + val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { + stringResource(Res.string.settings_local_media_folders_subtitle_empty) + } else stringResource( + Res.string.settings_local_media_folders_subtitle_current, + state.configuredCount + ) SettingCardItem( title = stringResource(Res.string.settings_local_media_folders_title), @@ -212,117 +200,44 @@ private fun LocalMediaFoldersSettingCard( icon = { SettingsItemIcon( Iconsax.IconsaxFolder, - stringResource(Res.string.settings_local_media_folders_title)) + stringResource(Res.string.settings_local_media_folders_title) + ) }, trailingContent = { MillenniumButton( - onClick = { isDialogOpen = true }, - style = MillenniumButtonStyle.Secondary) { + onClick = viewModel::open, + style = MillenniumButtonStyle.Secondary + ) { Text(stringResource(Res.string.settings_local_media_folders_manage)) } }, - onClick = { - isDialogOpen = true - } + onClick = viewModel::open ) - if (isDialogOpen) { + if (state.isOpen) { LocalMediaFoldersDialog( - folders = folders, - onDismiss = { isDialogOpen = false }, - onFoldersSaved = onFoldersSaved) + state = state, + onAddFolder = { pickerLauncher.launch() }, + onRemoveFolder = viewModel::removeFolder, + onSave = viewModel::save, + onDismiss = viewModel::dismiss + ) } } @Composable private fun LocalMediaFoldersDialog( - folders: List, - onDismiss: () -> Unit, - onFoldersSaved: (List) -> Unit) { - val draftFolders = remember(folders) { - mutableStateListOf().apply { addAll(folders) } - } - val pickerLauncher = rememberDirectoryPickerLauncher { directory -> - if (directory != null) { - val normalized = normalizePath(directory.path) - if (normalized.isNotEmpty() && draftFolders.none { normalizePath(it) == normalized }) { - draftFolders.add(normalized) - } - } - } - + state: LocalMediaFoldersUiState, + onAddFolder: () -> Unit, + onRemoveFolder: (String) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit +) { MillenniumDialog( visible = true, onDismissRequest = onDismiss, - title = stringResource(Res.string.settings_local_media_folders_title)) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_description), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - - MillenniumButton( - onClick = { pickerLauncher.launch() }, - style = MillenniumButtonStyle.Secondary) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically) { - MillenniumIcon(Iconsax.IconsaxAddSquare, contentDescription = null) - Text(stringResource(Res.string.settings_local_media_folders_add_action)) - } - } - - if (draftFolders.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_none_added), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant) - } - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - draftFolders.forEachIndexed { index, folder -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically) { - Text( - text = folder, - modifier = Modifier.weight(1f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = AppTheme.typography.bodySmall) - MillenniumIconButton( - onClick = { - if (index in draftFolders.indices) { - draftFolders.removeAt(index) - } - }, - style = MillenniumIconButtonStyle.Quiet) { - MillenniumIcon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action)) - } - } - } - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MillenniumButton( - onClick = { - onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) - onDismiss() - }) { - Text(stringResource(Res.string.settings_action_save)) - } - MillenniumButton( - onClick = onDismiss, - style = MillenniumButtonStyle.Secondary) { - Text(stringResource(Res.string.settings_action_cancel)) - } - } - } + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss) } } - diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt index e5ce8585..872ae802 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/LibraryToolbarTest.kt @@ -38,6 +38,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class LibraryToolbarTest { @@ -109,6 +110,29 @@ class LibraryToolbarTest { assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) } + @Test + fun viewModeDefaultsAndOverridesArePerTab() = runTest { + assertEquals(LibraryViewMode.List, defaultLibraryViewMode(true)) + assertEquals(LibraryViewMode.Grid, defaultLibraryViewMode(false)) + library.toggleViewMode(isCompact = true) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Albums) + runCurrent() + assertNull(library.uiState.value.viewModeOverride) + library.toggleViewMode(isCompact = false) + runCurrent() + assertEquals(LibraryViewMode.List, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.Playlists) + runCurrent() + assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride) + library.onTabSelected(LibraryTab.LocalTracks) + library.toggleViewMode(isCompact = true) + runCurrent() + assertFalse(library.uiState.value.canChangeView) + assertNull(library.uiState.value.viewModeOverride) + } + @OptIn(ExperimentalComposeUiApi::class) @Test fun renderExpandedCompactAndSearchToolbars() { @@ -128,14 +152,29 @@ class LibraryToolbarTest { searchMode = searching, searchState = TextFieldState(if (searching) "Muse" else ""), canCreatePlaylist = tab == LibraryTab.Playlists, - canRefresh = tab == LibraryTab.LocalTracks + canRefresh = tab == LibraryTab.LocalTracks, + canChangeView = tab != LibraryTab.LocalTracks ) ImageComposeScene(width, 88) { SpotubeTheme(UserSettings(theme = Theme.DARK)) { MillenniumTopAppBar( - title = { LibraryToolbarTitle(state, expandedDesktop) }, + title = { + LibraryToolbarTitle( + state, + expandedDesktop, + mode.startsWith("compact-desktop") + ) + }, actions = { - LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {}) + LibraryToolbarActions( + state, + expandedDesktop, + {}, + {}, + {}, + {}, + compactDesktop = mode.startsWith("compact-desktop") + ) if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( Modifier.width(132.dp) ) diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt new file mode 100644 index 00000000..dec49ce7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/library/MediaLibraryUiTest.kt @@ -0,0 +1,415 @@ +/* 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.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +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 dev.krtirtho.spotube.core.ui.component.cards.AvatarCard +import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.downloads.DownloadItem +import dev.krtirtho.spotube.modules.downloads.DownloadRowAction +import dev.krtirtho.spotube.modules.downloads.DownloadStatus +import dev.krtirtho.spotube.modules.downloads.DownloadsContent +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarTitle +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarSummary +import dev.krtirtho.spotube.modules.downloads.DownloadsToolbarActions +import dev.krtirtho.spotube.modules.downloads.downloadsUiState +import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksState +import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksContent +import dev.krtirtho.spotube.modules.library.local_tracks.LocalTracksBreadcrumb +import dev.krtirtho.spotube.modules.library.local_tracks.localTracksUiState +import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder +import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack +import dev.krtirtho.spotube.modules.settings.Theme +import dev.krtirtho.spotube.modules.settings.UserSettings +import java.io.File +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumTopAppBar +import org.jetbrains.compose.resources.stringResource +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.media_artist +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class MediaLibraryUiTest { + private val tracks = listOf( + LocalMediaTrack("/music/one.mp3", "Nishongota", listOf("Aurora"), 185_000, "Clancy"), + LocalMediaTrack( + "/music/two.mp3", + "Amay Prashna Kare Neel Dhrubatara", + listOf("Hemanta Mukherjee"), + 195_000 + ), + LocalMediaTrack("/downloads/three.mp3", "Whatever It Takes", emptyList(), 0) + ) + private val folders = listOf( + LocalMediaFolder("/music", "Music", tracks.take(2)), + LocalMediaFolder("/downloads", "Downloads", tracks.takeLast(1)) + ) + private val artists = listOf("Aurora", "Hemanta Mukherjee", "Imagine Dragons") + private val downloadItems = listOf( + DownloadItem("1", tracks[0].name, "Aurora", null, DownloadStatus.Downloading, 0.48f), + DownloadItem("2", tracks[1].name, "Hemanta Mukherjee", null, DownloadStatus.Completed), + DownloadItem( + "3", + tracks[2].name, + "Imagine Dragons", + null, + DownloadStatus.Failed("Connection lost") + ), + DownloadItem("4", "Clancy", "Twenty One Pilots", null, DownloadStatus.Queued) + ) + + @Test + fun downloadRowsHaveReadyProgressAndCorrectActions() { + val state = downloadsUiState(downloadItems) + assertEquals(2, state.activeCount) + assertEquals(1, state.completedCount) + assertTrue(state.canClearCompleted) + assertEquals(48, state.items[0].percent) + assertEquals(DownloadRowAction.Cancel, state.items[0].action) + assertEquals(DownloadRowAction.Remove, state.items[1].action) + assertEquals(DownloadRowAction.Retry, state.items[2].action) + assertEquals("Connection lost", state.items[2].error) + assertEquals( + 0f, + downloadsUiState(listOf(downloadItems[0].copy(progress = Float.NaN))).items.single().progress + ) + } + + @Test + fun localMediaFilteringAndMetadataAreComputedBeforeRendering() { + val base = LibraryLocalTracksState(folders = folders) + val root = localTracksUiState(base, "aurora") + assertEquals(listOf("Music"), root.visibleFolders.map { it.name }) + assertEquals(3, root.totalTrackCount) + val inFolder = localTracksUiState(base.copy(currentFolderPath = "/music"), "clancy") + assertEquals(listOf(tracks[0].name), inFolder.visibleTracks.map { it.title }) + assertEquals("3:05", inFolder.visibleTracks.single().durationLabel) + assertEquals("Aurora • Clancy", inFolder.visibleTracks.single().subtitle) + assertTrue(inFolder.canPlayFolder) + assertFalse( + localTracksUiState( + base.copy( + currentFolderPath = "/music", + canAccessMedia = false + ), "" + ).canPlayFolder + ) + } + + @Test + fun expandedDownloadsHasOneHeaderAndOneClearAction() { + var cleared = 0 + val state = downloadsUiState(downloadItems) + ImageComposeScene(960, 660) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state) { cleared++ } } + ) + DownloadsContent(state, {}, {}, {}, {}, Modifier.weight(1f), showHeader = false) + } + } + }.use { scene -> + repeat(6) { scene.render().close() } + val nodes = allNodes(scene) + val texts = nodes.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .map { it.text } + assertEquals(1, texts.count { it == "Downloads" }) + assertEquals(1, texts.count { it == "Clear completed" }) + val action = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Clear completed" } == true + } + action.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, cleared) + } + } + + @Test + fun breadcrumbIsThinAndItsParentLinkNavigatesUp() { + var height = 0 + var navigatedUp = 0 + val state = localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ) + ImageComposeScene(360, 40) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + LocalTracksBreadcrumb( + state, + { navigatedUp++ }, + {}, + Modifier.onGloballyPositioned { height = it.size.height }) + } + }.use { scene -> + repeat(6) { scene.render().close() } + assertEquals(32, height) + val parent = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Back to folders") == true + } + parent.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, navigatedUp) + } + } + + @Test + fun renderScreensAndCollectionModesInLightAndDark() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 960)) { + render(output, "downloads-$theme-$width", width, theme) { + val state = downloadsUiState(downloadItems) + if (width >= 840) Column(Modifier.fillMaxSize()) { + MillenniumTopAppBar( + title = { DownloadsToolbarTitle() }, + subtitle = { DownloadsToolbarSummary(state) }, + actions = { DownloadsToolbarActions(state, {}) }) + DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.weight(1f).padding(horizontal = 12.dp), + showHeader = false + ) + } else DownloadsContent( + state, + {}, + {}, + {}, + {}, + Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "folders-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState(folders = folders), + "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + render(output, "tracks-$theme-$width", width, theme) { + LocalTracksContent( + localTracksUiState( + LibraryLocalTracksState( + folders = folders, + currentFolderPath = "/music" + ), "" + ), {}, {}, {}, {}, {}, Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "collection-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = tracks, + itemKey = { it.path }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { item, list -> + PlayableCard( + item.name, + item.artists.joinToString(), + onClick = {}, + onPlay = {}, + onAddToQueue = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + for (mode in LibraryViewMode.entries) render( + output, + "artists-$mode-$theme-$width", + width, + theme + ) { + LibraryMediaCollection( + items = artists, + itemKey = { it }, + viewMode = mode, + onViewportChanged = { _, _ -> }, + itemContent = { artist, list -> + AvatarCard( + artist, + stringResource(Res.string.media_artist), + onClick = {}, + listMode = list + ) + }, + skeleton = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + @Test + fun bothCardTypesRespondToHoverAndClick() { + val output = File("build/reports/media-library-ui").apply { mkdirs() } + for (avatar in listOf(false, true)) { + var clicks = 0 + ImageComposeScene(192, 260) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + .padding(16.dp) + ) { + if (avatar) AvatarCard( + tracks[0].artists.first(), + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + else PlayableCard( + tracks[0].name, + onClick = { clicks++ }, + modifier = Modifier.testTag("card") + ) + } + } + }.use { scene -> + repeat(4) { scene.render().close() } + val before = bytes(scene, 0) + scene.sendPointerEvent(PointerEventType.Enter, Offset(80f, 80f), timeMillis = 0) + scene.sendPointerEvent(PointerEventType.Move, Offset(80f, 80f), timeMillis = 1) + repeat(12) { scene.render((it + 1) * 16_000_000L).close() } + val hovered = bytes(scene, 250_000_000) + assertFalse( + before.contentEquals(hovered), + "Hover should visibly change the ${if (avatar) "avatar" else "playable"} card" + ) + File(output, "card-${if (avatar) "avatar" else "playable"}-hover.png").writeBytes( + hovered + ) + scene.sendPointerEvent( + PointerEventType.Press, + Offset(80f, 80f), + timeMillis = 260, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.render(300_000_000).close() + scene.sendPointerEvent( + PointerEventType.Release, Offset(80f, 80f), timeMillis = 310, + buttons = PointerButtons(), button = PointerButton.Primary + ) + scene.render(350_000_000).close() + assertEquals(1, clicks) + } + } + } + + @Test + fun listPlaybackActionsDoNotTriggerNavigation() { + var navigations = 0 + var plays = 0 + ImageComposeScene(360, 100) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + PlayableListTile(tracks[0].name, onClick = { navigations++ }, onPlay = { plays++ }) + } + }.use { scene -> + repeat(4) { scene.render().close() } + val play = allNodes(scene).first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.ContentDescription) + ?.contains("Play") == true + } + val center = play.boundsInRoot.center + scene.sendPointerEvent( + PointerEventType.Press, + center, + buttons = PointerButtons(isPrimaryPressed = true), + button = PointerButton.Primary + ) + scene.sendPointerEvent( + PointerEventType.Release, + center, + buttons = PointerButtons(), + button = PointerButton.Primary + ) + scene.render().close() + assertEquals(1, plays) + assertEquals(0, navigations) + } + } + + private fun render( + output: File, + name: String, + width: Int, + theme: Theme, + content: @Composable () -> Unit + ) { + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { content() } + } + }.use { scene -> + repeat(6) { scene.render().close() } + if (name.startsWith("downloads")) { + val texts = allNodes(scene).flatMap { + it.config.getOrNull(SemanticsProperties.Text).orEmpty() + }.map { it.text } + assertTrue("Downloading 48%" in texts) + } + File(output, "$name.png").writeBytes(bytes(scene, 0)) + } + } + + private fun bytes(scene: ImageComposeScene, time: Long): ByteArray = + scene.render(time).use { image -> + assertNotNull(image.encodeToData()).use { it.bytes } + } + + private fun allNodes(scene: ImageComposeScene): List { + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } +} diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt new file mode 100644 index 00000000..d577fd09 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/settings/LocalMediaFoldersEditorTest.kt @@ -0,0 +1,217 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package dev.krtirtho.spotube.modules.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.use +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme +import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +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.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.theme.MillenniumTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_local_media_folders_title +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalMediaFoldersEditorTest { + private val models = mutableListOf() + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun cleanup() { + models.forEach { it.viewModelScope.cancel() } + models.clear() + Dispatchers.resetMain() + } + + private fun model( + source: MutableStateFlow, + persist: suspend (List) -> Unit = {} + ) = + LocalMediaFoldersViewModel(source, persist).also { models.add(it) } + + @Test + fun addingFoldersDeduplicatesPathsAndCancelDiscardsTheDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/user/Music"))) + var writes = 0 + val editor = model(source) { writes++ } + editor.open() + editor.addFolder(" /home/user/Music/// ") + editor.addFolder(" ") + assertFalse(editor.uiState.value.canSave) + editor.addFolder("/home/user/Documents/Music Production/") + runCurrent() + assertEquals( + listOf("Music", "Music Production"), + editor.uiState.value.folders.map { it.name }) + assertTrue(editor.uiState.value.canSave) + editor.dismiss() + editor.addFolder("/late-picker-result") + editor.open() + runCurrent() + assertEquals(listOf("/home/user/Music"), editor.uiState.value.folders.map { it.path }) + assertEquals(0, writes) + } + + @Test + fun settingsUpdatesDoNotResetAnOpenDraft() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val editor = model(source) + editor.open() + editor.addFolder("/new-music") + source.value = source.value.copy(overloadedDownloadFolder = "/downloads") + runCurrent() + assertEquals(listOf("/music", "/new-music"), editor.uiState.value.folders.map { it.path }) + assertTrue(editor.uiState.value.canSave) + } + + @Test + fun saveLocksTheDraftUntilPersistenceCompletes() = runTest { + val source = MutableStateFlow(UserSettings(localMediaFolders = listOf("/music"))) + val gate = CompletableDeferred() + var writes = 0 + val editor = model(source) { paths -> + writes++ + gate.await() + source.value = source.value.copy(localMediaFolders = paths) + } + editor.open() + editor.addFolder("/new-music") + editor.save() + editor.save() + editor.addFolder("/not-allowed-during-save") + editor.dismiss() + runCurrent() + assertTrue(editor.uiState.value.isSaving) + assertTrue(editor.uiState.value.isOpen) + assertFalse(editor.uiState.value.canSave) + assertEquals(2, editor.uiState.value.folders.size) + gate.complete(Unit) + runCurrent() + assertFalse(editor.uiState.value.isOpen) + assertEquals(listOf("/music", "/new-music"), source.value.localMediaFolders) + assertEquals(1, writes) + } + + @Test + fun failedSaveKeepsChangesForRetry() = runTest { + val source = MutableStateFlow(UserSettings()) + val editor = model(source) { throw IllegalStateException("Test storage failure") } + editor.open() + editor.addFolder("/music") + editor.save() + runCurrent() + assertTrue(editor.uiState.value.isOpen) + assertTrue(editor.uiState.value.saveFailed) + assertTrue(editor.uiState.value.canSave) + assertEquals(listOf("/music"), editor.uiState.value.folders.map { it.path }) + } + + @Test + fun rootPathsStayValid() { + assertEquals("/", normalizeMediaFolderPath(" / ")) + assertEquals("C:\\", normalizeMediaFolderPath("C:\\")) + assertEquals("C:/", normalizeMediaFolderPath("C:///")) + } + + @OptIn(ExperimentalComposeUiApi::class) + @Test + fun renderDialogAtCompactAndWideSizes() { + val output = File("build/reports/local-media-folders").apply { mkdirs() } + val source = + MutableStateFlow(UserSettings(localMediaFolders = listOf("/home/krtirtho/Documents/Music Production"))) + val editor = model(source) + editor.open() + editor.addFolder("/home/krtirtho/Music") + val oneFolder = editor.uiState.value.copy(folders = editor.uiState.value.folders.take(1)) + val cases = mapOf( + "empty" to oneFolder.copy(folders = emptyList()), + "one" to oneFolder, + "many" to oneFolder.copy(folders = (1..8).map { + LocalMediaFolderRowUi( + "/home/user/Music/Collection-$it", + "Collection-$it" + ) + }) + ) + for (theme in listOf(Theme.DARK, Theme.LIGHT)) { + for (width in listOf(360, 640)) { + for ((name, state) in cases) { + var added = 0 + ImageComposeScene(width, 660) { + SpotubeTheme(UserSettings(theme = theme)) { + Box( + Modifier.fillMaxSize().background(MillenniumTheme.colors.background) + ) { + MillenniumDialog( + visible = true, + onDismissRequest = {}, + title = stringResource(Res.string.settings_local_media_folders_title) + ) { + LocalMediaFoldersEditorContent(state, { added++ }, {}, {}, {}) + } + } + } + }.use { scene -> + repeat(24) { scene.render(it * 16_000_000L).close() } + fun flatten(node: SemanticsNode): List = + listOf(node) + node.children.flatMap(::flatten) + + val nodes = scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + val add = nodes.first { + it.config.getOrNull(SemanticsActions.OnClick) != null && + it.config.getOrNull(SemanticsProperties.Text) + ?.any { text -> text.text == "Add folder" } == true + } + add.config[SemanticsActions.OnClick].action?.invoke() + assertEquals(1, added) + assertTrue( + add.boundsInRoot.right <= width, + "Actions must fit inside compact dialogs" + ) + scene.render(400_000_000).use { image -> + image.encodeToData()!!.use { + File( + output, + "$name-$theme-$width.png" + ).writeBytes(it.bytes) + } + } + } + } + } + } + } +}