feat(library): add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement

This commit is contained in:
Kingkor Roy Tirtho 2026-10-15 11:36:39 +06:00
parent cf9476ab08
commit 58dfa2de92
32 changed files with 2757 additions and 1182 deletions

View File

@ -27,6 +27,7 @@
<!-- Library toolbar --> <!-- Library toolbar -->
<string name="library_screen_title">Your Library</string> <string name="library_screen_title">Your Library</string>
<string name="library_toolbar_compact_title">Library</string>
<string name="library_tab_playlists">Playlists</string> <string name="library_tab_playlists">Playlists</string>
<string name="library_tab_albums">Albums</string> <string name="library_tab_albums">Albums</string>
<string name="library_tab_artists">Artists</string> <string name="library_tab_artists">Artists</string>
@ -40,6 +41,59 @@
<string name="library_action_close_search">Close filter</string> <string name="library_action_close_search">Close filter</string>
<string name="library_action_create_playlist">Create playlist</string> <string name="library_action_create_playlist">Create playlist</string>
<string name="library_action_refresh_local_tracks">Refresh local tracks</string> <string name="library_action_refresh_local_tracks">Refresh local tracks</string>
<string name="library_action_grid_view">Switch to grid view</string>
<string name="library_action_list_view">Switch to list view</string>
<string name="library_playlists_empty">No saved playlists</string>
<string name="library_playlists_no_results">No playlists match “%1$s”</string>
<string name="library_albums_empty">No saved albums</string>
<string name="library_albums_no_results">No albums match “%1$s”</string>
<string name="library_artists_empty">No saved artists</string>
<string name="library_artists_no_results">No artists match “%1$s”</string>
<!-- Local media -->
<string name="library_local_folders">Folders</string>
<string name="library_local_folder">Folder</string>
<string name="library_local_summary">%1$s • %2$s</string>
<plurals name="library_local_tracks_count">
<item quantity="one">%1$d track</item>
<item quantity="other">%1$d tracks</item>
</plurals>
<plurals name="library_local_folders_count">
<item quantity="one">%1$d folder</item>
<item quantity="other">%1$d folders</item>
</plurals>
<string name="library_local_back">Back to folders</string>
<string name="library_local_play_folder">Play folder</string>
<string name="library_local_permission_title">Audio access required</string>
<string name="library_local_permission_description">Allow audio access to browse music on your device.</string>
<string name="library_local_grant_access">Grant access</string>
<string name="library_local_empty_folders">No local folders found</string>
<string name="library_local_empty_tracks">No tracks in this folder</string>
<string name="library_local_no_results">No matching folders or tracks</string>
<string name="library_local_refresh_failed">Failed to refresh local media</string>
<!-- Downloads -->
<string name="downloads_summary">%1$d active • %2$d completed</string>
<string name="downloads_clear_completed">Clear completed</string>
<string name="downloads_empty">No downloads yet</string>
<string name="downloads_status_downloading">Downloading %1$d%</string>
<string name="downloads_status_queued">Queued</string>
<string name="downloads_status_completed">Completed</string>
<string name="downloads_status_cancelled">Cancelled</string>
<string name="downloads_status_failed">Failed</string>
<string name="downloads_status_failed_error">Failed: %1$s</string>
<string name="downloads_action_cancel">Cancel download</string>
<string name="downloads_action_retry">Retry download</string>
<string name="downloads_action_remove">Remove download</string>
<!-- Shared media items -->
<string name="media_artist">Artist</string>
<string name="media_blacklisted">Blacklisted</string>
<string name="media_liked_tracks">Liked Tracks</string>
<string name="media_saved_songs">Your saved songs</string>
<string name="media_unknown_artist">Unknown artist</string>
<string name="media_preview_title">Track title</string>
<string name="media_preview_subtitle">Artist name</string>
<string name="accessibility_selected">Selected</string> <string name="accessibility_selected">Selected</string>
@ -122,11 +176,21 @@
<string name="settings_local_media_folders_title">Local media folders</string> <string name="settings_local_media_folders_title">Local media folders</string>
<string name="settings_local_media_folders_subtitle_current">Custom folders to scan on desktop. Added: %1$d</string> <string name="settings_local_media_folders_subtitle_current">Custom folders to scan on desktop. Added: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">No custom folder added yet</string> <string name="settings_local_media_folders_subtitle_empty">No custom folder added yet</string>
<string name="settings_local_media_folders_description">Use the picker to add folders. Download folder override is included automatically.</string> <string name="settings_local_media_folders_description">Custom folders for your local music library.</string>
<string name="settings_local_media_folders_manage">Manage</string> <string name="settings_local_media_folders_manage">Manage</string>
<string name="settings_local_media_folders_add_action">Add folder</string> <string name="settings_local_media_folders_add_action">Add folder</string>
<string name="settings_local_media_folders_remove_action">Remove folder</string> <string name="settings_local_media_folders_remove_action">Remove folder</string>
<string name="settings_local_media_folders_none_added">No custom folders added.</string> <string name="settings_local_media_folders_none_added">No custom folders added.</string>
<string name="settings_local_media_folders_remove_named">Remove %1$s from the scan list</string>
<string name="settings_local_media_folders_automatic_both">Download and music cache folders are included automatically.</string>
<string name="settings_local_media_folders_automatic_downloads">Your download folder is included automatically.</string>
<string name="settings_local_media_folders_automatic_cache">The music cache folder is included automatically.</string>
<string name="settings_local_media_folders_saving">Saving…</string>
<string name="settings_local_media_folders_save_error">Couldn’t save folders. Your changes are still here; try again.</string>
<plurals name="settings_local_media_folders_count">
<item quantity="one">%1$d folder</item>
<item quantity="other">%1$d folders</item>
</plurals>
<string name="settings_download_format_title">Download music format</string> <string name="settings_download_format_title">Download music format</string>
<string name="settings_download_format_subtitle_current">Preferred codec and container for downloads. Current: %1$s</string> <string name="settings_download_format_subtitle_current">Preferred codec and container for downloads. Current: %1$s</string>
<string name="settings_download_quality_title">Download music quality</string> <string name="settings_download_quality_title">Download music quality</string>

View File

@ -82,6 +82,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel 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.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
@ -142,6 +143,13 @@ val sharedModules = module {
singleOf(::SettingsRepository) { bind<UserSettingsSource>() } singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel) viewModelOf(::JamSettingsViewModel)
viewModel {
val repository = get<SettingsRepository>()
LocalMediaFoldersViewModel(repository.userSettings) { folders ->
val current = repository.getCurrentSettings()
repository.updateSettings(current.copy(localMediaFolders = folders))
}
}
// Welcome / Onboarding // Welcome / Onboarding
singleOf(::WelcomeRepository) singleOf(::WelcomeRepository)

View File

@ -40,12 +40,15 @@ fun AlbumCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject()) { remotePlaybackController: RemotePlaybackController = koinInject(),
listMode: Boolean = false
) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
title = album.title, 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, imageURL = album.thumbnails.firstOrNull()?.url,
isPlaying = currentCollectionEntry?.id == album.id, isPlaying = currentCollectionEntry?.id == album.id,
onClick = { onClick = {
@ -56,14 +59,18 @@ fun AlbumCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title) album.title
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title) album.title
)
}, },
modifier = modifier.width(160.dp), modifier = if (listMode) modifier else modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}") sharedElementKey = "album_art_${album.id}",
listMode = listMode
)
} }

View File

@ -17,52 +17,34 @@
package dev.krtirtho.spotube.core.ui.component 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.runtime.Composable
import androidx.compose.ui.Modifier 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.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.*
@Composable @Composable
fun ArtistCard( fun ArtistCard(
artist: MetadataArtist.Basic, artist: MetadataArtist.Basic,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false) { isBlacklisted: Boolean = false,
listMode: Boolean = false
) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
AvatarCard( AvatarCard(
title = artist.name, 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, imageURL = artist.thumbnails.firstOrNull()?.url,
onClick = { onClick = {
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
modifier = modifier, modifier = modifier,
isBlacklisted = isBlacklisted) isBlacklisted = isBlacklisted,
listMode = listMode
)
} }

View File

@ -21,14 +21,7 @@ package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -44,7 +37,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle
import dev.krtirtho.spotube.resources.iconsax.Play import dev.krtirtho.spotube.resources.iconsax.Play
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.*
import spotube.composeapp.generated.resources.liked_tracks 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) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@ -78,27 +72,49 @@ fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject()) { navigationCommands: NavigationCommands = koinInject(),
listMode: Boolean = false
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks 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 sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box( MediaCardSurface(
modifier = modifier modifier = modifier.width(160.dp),
.clip(RoundedCornerShape(8.dp)) onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) ) { isHovered ->
.width(160.dp)
.hoverable(interactionSource = interactionSource)) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(Res.drawable.liked_tracks), painter = painterResource(Res.drawable.liked_tracks),
contentDescription = "Liked Tracks", contentDescription = title,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
@ -106,14 +122,16 @@ fun LikedTracksCard(
.sharedElementOrNone( .sharedElementOrNone(
key = "saved_tracks_art", key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope), animatedVisibilityScope = animatedVisibilityScope
contentScale = ContentScale.Crop) ),
contentScale = ContentScale.Crop
)
Box( Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
if (isHovered) { if (isHovered || touchInput || isPlaying) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
@ -125,7 +143,7 @@ fun LikedTracksCard(
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue", contentDescription = stringResource(Res.string.player_add_to_queue_content_description),
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@ -139,7 +157,7 @@ fun LikedTracksCard(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle Iconsax.IconsaxPauseCircle
} else Iconsax.Play, } else Iconsax.Play,
contentDescription = "Play", contentDescription = stringResource(Res.string.player_play_content_description),
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@ -151,17 +169,19 @@ fun LikedTracksCard(
modifier = Modifier.fillMaxWidth().padding(12.dp) modifier = Modifier.fillMaxWidth().padding(12.dp)
) { ) {
Text( Text(
text = "Liked Tracks", text = title,
style = AppTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Your saved songs", text = subtitle,
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
} }
} }
} }

View File

@ -40,7 +40,9 @@ fun PlaylistCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject()) { remotePlaybackController: RemotePlaybackController = koinInject(),
listMode: Boolean = false
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -57,14 +59,18 @@ fun PlaylistCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title) playlist.title
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title) playlist.title
)
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}") sharedElementKey = "playlist_art_${playlist.id}",
listMode = listMode
)
} }

View File

@ -17,79 +17,67 @@
package dev.krtirtho.spotube.core.ui.component.cards 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply 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 @Composable
fun AvatarCard( fun AvatarCard(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
imageURL: String? = null, imageURL: String? = null,
onClick: () -> Unit = {}, onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false) { isBlacklisted: Boolean = false,
val backgroundColor = if (isBlacklisted) { listMode: Boolean = false
AppTheme.colors.error.copy(alpha = 0.1f) ) {
} else { if (listMode) {
Color.Transparent AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted)
return
} }
Box( MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) {
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(onClick = { onClick.invoke() }, enabled = true)
.width(160.dp)) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(CircleShape) .clip(CircleShape)
.padding(12.dp), .padding(12.dp),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center
AsyncImage( ) {
MediaArtwork(
model = imageURL, model = imageURL,
contentDescription = title, title = title,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
.clip(CircleShape) .clip(CircleShape)
.shimmerApply(), .shimmerApply(),
contentScale = ContentScale.Crop) shape = CircleShape,
placeholderIcon = Iconsax.User
)
} }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(12.dp) modifier = Modifier.fillMaxWidth().padding(12.dp)
@ -99,17 +87,20 @@ fun AvatarCard(
style = AppTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
subtitle?.let { subtitle?.let {
Spacer(Modifier.height(4.dp))
TextWithShimmer( TextWithShimmer(
text = subtitle, text = subtitle,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
} }
} }
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -25,10 +25,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp 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.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone 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_add_to_queue_content_description
import spotube.composeapp.generated.resources.player_pause_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.player_play_content_description
import spotube.composeapp.generated.resources.media_preview_title
import spotube.composeapp.generated.resources.media_preview_subtitle
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@ -79,22 +74,37 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null, onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null) { sharedElementKey: String? = null,
val interactionSource = remember { MutableInteractionSource() } 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 sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box( MediaCardSurface(
modifier = modifier modifier = modifier.width(160.dp),
.clip(RoundedCornerShape(8.dp)) onClick = onClick ?: onPlay,
.clickable(onClick = { onClick?.invoke() }, enabled = true) interactive = onClick != null || onPlay != null || onAddToQueue != null
.width(160.dp) ) { highlighted ->
.hoverable(interactionSource = interactionSource)) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
AsyncImage( MediaArtwork(
model = imageURL, model = imageURL,
contentDescription = title, title = title,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
@ -102,19 +112,21 @@ fun PlayableCard(
.sharedElementOrNone( .sharedElementOrNone(
key = sharedElementKey, key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope), animatedVisibilityScope = animatedVisibilityScope
contentScale = ContentScale.Crop) ),
shape = RoundedCornerShape(8.dp)
)
if (onPlay != null || onAddToQueue != null) if (onPlay != null || onAddToQueue != null)
Box( Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = isHovered || (isPlaying && onPlay != null), visible = highlighted || touchInput || (isPlaying && onPlay != null),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut()) { exit = fadeOut()
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
@ -130,7 +142,9 @@ fun PlayableCard(
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = stringResource( contentDescription = stringResource(
Res.string.player_add_to_queue_content_description)) Res.string.player_add_to_queue_content_description
)
)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
@ -150,7 +164,9 @@ fun PlayableCard(
Res.string.player_pause_content_description Res.string.player_pause_content_description
} else { } else {
Res.string.player_play_content_description Res.string.player_play_content_description
})) }
)
)
} }
} }
} }
@ -164,7 +180,8 @@ fun PlayableCard(
text = title, text = title,
style = AppTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
subtitle?.let { subtitle?.let {
TextWithShimmer( TextWithShimmer(
@ -172,7 +189,8 @@ fun PlayableCard(
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
} }
} }
} }
@ -185,8 +203,8 @@ fun PlayableCard(
private fun PlayableCardPreview() { private fun PlayableCardPreview() {
Box { Box {
PlayableCard( PlayableCard(
title = "Sample Title", title = stringResource(Res.string.media_preview_title),
subtitle = "Sample Subtitle", subtitle = stringResource(Res.string.media_preview_subtitle),
imageURL = "https://placehold.co/600x400", imageURL = "https://placehold.co/600x400",
isPlaying = true, isPlaying = true,
onClick = {}, onClick = {},

View File

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

View File

@ -1,30 +1,8 @@
/* /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later
*
* 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 <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.modules.downloads 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import com.composeunstyled.Text
import coil3.compose.LocalPlatformContext import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork
import coil3.request.ImageRequest import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
import coil3.request.crossfade 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.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel 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 @Composable
fun DownloadsScreen(modifier: Modifier = Modifier) { fun DownloadsScreen(
val viewModel = koinViewModel<DownloadsViewModel>() modifier: Modifier = Modifier,
val downloads by viewModel.downloads.collectAsStateWithLifecycle() viewModel: DownloadsViewModel = koinViewModel(),
headerInAppBar: Boolean = false,
val shellBottomInset = LocalAppShellBottomInset.current topContent: @Composable () -> Unit = {}
val contentPadding = remember(shellBottomInset) { ) {
PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset) 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()) { @Composable
Column( internal fun DownloadsToolbarTitle() {
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) {
val hasCompleted = downloads.any { it.status == DownloadStatus.Completed }
if (hasCompleted) {
Row( Row(
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(8.dp)
.padding(horizontal = 12.dp, vertical = 4.dp), ) {
horizontalArrangement = Arrangement.End) {
MillenniumButton(
onClick = viewModel::clearCompleted,
style = MillenniumButtonStyle.Quiet) {
Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxTrash, Iconsax.IconsaxDirectboxReceive,
contentDescription = null, null,
modifier = Modifier.size(16.dp)) tint = AppTheme.colors.primary,
modifier = Modifier.size(20.dp)
)
Text( Text(
text = " Clear completed", stringResource(Res.string.library_tab_downloads),
style = AppTheme.typography.labelLarge) style = AppTheme.typography.titleMedium
} )
}
} }
} }
if (downloads.isEmpty()) { @Composable
Box( internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
modifier = Modifier.fillMaxSize(), if (state.items.isNotEmpty()) Text(
contentAlignment = Alignment.Center) { 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( Text(
text = "No downloads yet", stringResource(Res.string.downloads_clear_completed),
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.labelMedium
color = AppTheme.colors.onSurfaceVariant) )
} }
} else { }
LazyColumn(
contentPadding = contentPadding, @Composable
verticalArrangement = Arrangement.spacedBy(2.dp)) { internal fun DownloadsContent(
items(downloads, key = { it.id }) { item -> 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 (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( DownloadListRow(
item = item, row,
onCancel = { viewModel.cancel(item.id) }, { onCancel(row.id) },
onRetry = { viewModel.retry(item.id) }, { onRetry(row.id) },
onRemove = { viewModel.remove(item.id) }) { onRemove(row.id) })
}
} }
} }
} }
@ -126,147 +170,82 @@ fun DownloadsScreen(modifier: Modifier = Modifier) {
} }
@Composable @Composable
private fun DownloadListRow( internal fun DownloadListRow(
item: DownloadItem, item: DownloadRowUiState,
onCancel: () -> Unit, onCancel: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onRemove: () -> Unit) { onRemove: () -> Unit
val rowBackgroundColor = when (item.status) { ) {
is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f) val colors = MillenniumTheme.colors
is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f)
else -> AppTheme.colors.surface
}
Row( Row(
modifier = Modifier Modifier.fillMaxWidth().millenniumSurface(
.fillMaxWidth() background = millenniumPanelBrush(colors),
.padding(horizontal = 12.dp) shape = RoundedCornerShape(8.dp),
.clip(AppTheme.shapes.small) borderColor = colors.border,
.background(rowBackgroundColor) borderWidth = 1.dp,
.padding(horizontal = 8.dp, vertical = 6.dp), innerGlowColor = colors.highlight.copy(alpha = 0.2f)
verticalAlignment = Alignment.CenterVertically, ).padding(12.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(12.dp)
val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) ) {
?.firstOrNull()?.url MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
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)) {
Text( Text(
text = item.title, item.title,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Text( Text(
text = item.artists, item.artists,
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.primary, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Text(
when (item.status) { when (item.status) {
is DownloadStatus.Downloading -> { DownloadStatus.Downloading -> stringResource(
val percent = (item.progress * 100).toInt() Res.string.downloads_status_downloading,
Text( item.percent
text = "Downloading... $percent%", )
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
is DownloadStatus.Queued -> { DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued)
Text( DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed)
text = "Queued", 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, style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) 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)
)
} }
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)
}
}
}
when (item.status) {
is DownloadStatus.Downloading, is DownloadStatus.Queued -> {
MillenniumIconButton( MillenniumIconButton(
onClick = onCancel, onClick = when (item.action) {
style = MillenniumIconButtonStyle.Quiet) { DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove
},
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxCloseSquare, when (item.action) {
contentDescription = "Cancel", DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash
tint = AppTheme.colors.onSurfaceVariant) },
} 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
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)
}
} }
)
)
} }
} }
} }

View File

@ -23,13 +23,64 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn 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<DownloadRowUiState> = emptyList(),
val activeCount: Int = 0,
val completedCount: Int = 0,
val canClearCompleted: Boolean = false
)
internal fun downloadsUiState(items: List<DownloadItem>): 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( class DownloadsViewModel(
private val downloadManager: DownloadManager) : ViewModel() { private val downloadManager: DownloadManager
) : ViewModel() {
val downloads: StateFlow<List<DownloadItem>> = downloadManager.downloadsFlow.stateIn( val downloads: StateFlow<List<DownloadItem>> = downloadManager.downloadsFlow.stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(5000), SharingStarted.WhileSubscribed(5000),
emptyList()) emptyList()
)
val uiState = downloadManager.downloadsFlow.map(::downloadsUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
fun downloadTrack(track: MetadataTrack) { fun downloadTrack(track: MetadataTrack) {
downloadManager.enqueue(track) downloadManager.enqueue(track)

View File

@ -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 <T> LibraryMediaCollection(
items: List<T>,
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) } }
}
}
}
}

View File

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState 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.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text import com.composeunstyled.Text
import com.composeunstyled.currentWindowWidthBreakpoint 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.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField 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.* import spotube.composeapp.generated.resources.*
@Composable @Composable
@ -70,13 +75,24 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
val state by libraryState.uiState.collectAsStateWithLifecycle() val state by libraryState.uiState.collectAsStateWithLifecycle()
val expandedDesktop = getPlatform().isDesktop() && val expandedDesktop = getPlatform().isDesktop() &&
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded) (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 focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.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()) { Column(Modifier.fillMaxSize()) {
ApplicationMainBar( ApplicationMainBar(
backButton = false, backButton = false,
title = { LibraryToolbarTitle(state, expandedDesktop) }, title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) },
actions = { actions = {
LibraryToolbarActions( LibraryToolbarActions(
state = state, state = state,
@ -88,32 +104,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
libraryState.closeSearch() libraryState.closeSearch()
}, },
onCreatePlaylist = libraryState::showCreatePlaylist, onCreatePlaylist = libraryState::showCreatePlaylist,
onRefresh = libraryState::refreshLocalTracks onRefresh = libraryState::refreshLocalTracks,
onToggleView = { libraryState.toggleViewMode(isCompact) },
viewMode = viewMode,
compactDesktop = compactDesktop
) )
} }
) )
if (!expandedDesktop) { if (!expandedDesktop) {
LazyRow( LibraryTabs(state, libraryState::onTabSelected)
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(LibraryTab.entries) { tab ->
MillenniumButton(
onClick = { libraryState.onTabSelected(tab) },
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
) { Text(stringResource(tab.titleResource)) }
}
}
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) { when (state.currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen( LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist, showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
viewMode = viewMode
) )
LibraryTab.Albums -> LibraryAlbumsScreen() LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
LibraryTab.Artists -> LibraryArtistsScreen() LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
LibraryTab.Downloads -> DownloadsScreen() LibraryTab.Downloads -> DownloadsScreen()
} }
@ -122,7 +132,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
} }
@Composable @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) { if (!expandedDesktop && state.searchMode) {
LibraryFilterField( LibraryFilterField(
state.searchState, state.searchState,
@ -131,7 +160,11 @@ internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop:
autoFocus = true autoFocus = true
) )
} else if (!expandedDesktop) { } 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, onOpenSearch: () -> Unit,
onCloseSearch: () -> Unit, onCloseSearch: () -> Unit,
onCreatePlaylist: () -> 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) { if (!expandedDesktop && state.searchMode) {
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.IconsaxCloseSquare, icon = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.library_action_close_search), contentDescription = stringResource(Res.string.library_action_close_search),
onClick = onCloseSearch, onClick = onCloseSearch,
modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) )
} else { } else {
@ -164,6 +202,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxSearchBroken, icon = Iconsax.IconsaxSearchBroken,
contentDescription = stringResource(Res.string.library_action_search), contentDescription = stringResource(Res.string.library_action_search),
onClick = onOpenSearch, onClick = onOpenSearch,
modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) )
} }
@ -173,6 +212,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist), contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = onCreatePlaylist, onClick = onCreatePlaylist,
modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) )
} }
@ -181,6 +221,16 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxRefreshRight, icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh, 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 style = MillenniumIconButtonStyle.Quiet
) )
} }

View File

@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
enum class LibraryViewMode { Grid, List }
data class LibraryToolbarUiState( data class LibraryToolbarUiState(
val currentTab: LibraryTab = LibraryTab.Playlists, val currentTab: LibraryTab = LibraryTab.Playlists,
@ -39,7 +42,9 @@ data class LibraryToolbarUiState(
val canFilter: Boolean = true, val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true, val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false, 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. */ /** 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) val currentTab = MutableStateFlow(LibraryTab.Playlists)
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() } private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
private val createPlaylistVisible = MutableStateFlow(false) private val createPlaylistVisible = MutableStateFlow(false)
private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap())
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow() val localRefreshRequests = refreshRequests.asSharedFlow()
val uiState = combine( val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode, searchMode,
createPlaylistVisible createPlaylistVisible,
) { (tab, query), searching, creating -> viewModes
) { (tab, query), searching, creating, modes ->
LibraryToolbarUiState( LibraryToolbarUiState(
currentTab = tab, currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
@ -64,7 +71,9 @@ class LibraryState : ViewModel() {
canFilter = tab != LibraryTab.Downloads, canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists, canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks, 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( }.stateIn(
viewModelScope, SharingStarted.Eagerly, viewModelScope, SharingStarted.Eagerly,
@ -103,4 +112,16 @@ class LibraryState : ViewModel() {
fun refreshLocalTracks() { fun refreshLocalTracks() {
refreshRequests.tryEmit(Unit) 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

View File

@ -1,158 +1,76 @@
/* /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later
*
* 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 <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.modules.library.album package dev.krtirtho.spotube.modules.library.album
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.library.LibraryViewMode
import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryAlbumsScreen( fun LibraryAlbumsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryAlbumsViewModel = koinViewModel() viewModel: LibraryAlbumsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() when (val current = state) {
is LibraryAlbumsState.Error -> Box(
modifier.fillMaxSize(),
LaunchedEffect(gridState) { contentAlignment = Alignment.Center
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() ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
}
}
} }
Column(modifier = modifier) { else -> {
when (state) { val data = current as? LibraryAlbumsState.Data
is LibraryAlbumsState.Loading -> { if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) {
val shellBottomInset = LocalAppShellBottomInset.current Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val contentPadding = remember(shellBottomInset) { Text(
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) if (data.query.isNotBlank()) stringResource(
} Res.string.library_albums_no_results,
LazyVerticalGrid( data.query
columns = GridCells.Adaptive(160.dp), )
modifier = Modifier.fillMaxSize(), else stringResource(Res.string.library_albums_empty)
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"
) )
} }
} } else LibraryMediaCollection<MetadataAlbum.Detailed>(
} items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
} loading = current is LibraryAlbumsState.Loading,
loadingMore = current is LibraryAlbumsState.Data.LoadingMore,
is LibraryAlbumsState.Error -> { onViewportChanged = viewModel::onItemsVisible,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { itemContent = { item, list ->
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( AlbumCard(
album = album, item,
modifier = Modifier.fillMaxWidth() Modifier.fillMaxWidth(),
listMode = list
) )
} },
skeleton = { list ->
if (state is LibraryAlbumsState.Data.LoadingMore) {
items(4) {
SkeletonTree(true) {
PlayableCard( PlayableCard(
title = "Album Title", stringResource(Res.string.media_preview_title),
subtitle = "Artist Name", stringResource(Res.string.media_preview_subtitle),
imageURL = "https://placehold.co/600x400" modifier = Modifier.fillMaxWidth(),
listMode = list
)
},
modifier = modifier
) )
} }
} }
} }
}
}
}
}
}
}

View File

@ -51,8 +51,7 @@ sealed interface LibraryAlbumsState {
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataAlbum.Detailed> override val items: List<MetadataAlbum.Detailed> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
album.title.contains(query, ignoreCase = true) || album.title.contains(query, ignoreCase = true) ||
album.artists.any { artist -> album.artists.any { artist ->
@ -75,8 +74,7 @@ sealed interface LibraryAlbumsState {
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataAlbum.Detailed> override val items: List<MetadataAlbum.Detailed> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
album.title.contains(query, ignoreCase = true) || album.title.contains(query, ignoreCase = true) ||
album.artists.any { artist -> album.artists.any { artist ->
@ -156,6 +154,12 @@ class LibraryAlbumsViewModel(
libraryState.setQuery(LibraryTab.Albums, query) libraryState.setQuery(LibraryTab.Albums, query)
} }
fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) {
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) {
viewModelScope.launch { loadMoreData() }
}
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
repository.invalidateCaches() repository.invalidateCaches()

View File

@ -1,158 +1,76 @@
/* /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later
*
* 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 <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.modules.library.artist package dev.krtirtho.spotube.modules.library.artist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text 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.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.library.LibraryViewMode
import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryArtistsScreen( fun LibraryArtistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryArtistsViewModel = koinViewModel() viewModel: LibraryArtistsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() when (val current = state) {
is LibraryArtistsState.Error -> Box(
modifier.fillMaxSize(),
LaunchedEffect(gridState) { contentAlignment = Alignment.Center
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() ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
}
}
} }
Column(modifier = modifier) { else -> {
when (state) { val data = current as? LibraryArtistsState.Data
is LibraryArtistsState.Loading -> { if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) {
val shellBottomInset = LocalAppShellBottomInset.current Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val contentPadding = remember(shellBottomInset) { Text(
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) if (data.query.isNotBlank()) stringResource(
} Res.string.library_artists_no_results,
LazyVerticalGrid( data.query
columns = GridCells.Adaptive(160.dp), )
modifier = Modifier.fillMaxSize(), else stringResource(Res.string.library_artists_empty)
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"
) )
} }
} } else LibraryMediaCollection<MetadataArtist.Basic>(
} items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
} loading = current is LibraryArtistsState.Loading,
loadingMore = current is LibraryArtistsState.Data.LoadingMore,
is LibraryArtistsState.Error -> { onViewportChanged = viewModel::onItemsVisible,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { itemContent = { item, list ->
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( ArtistCard(
artist = artist, item,
modifier = Modifier.fillMaxWidth() Modifier.fillMaxWidth(),
listMode = list
) )
} },
skeleton = { list ->
// if (state is LibraryArtistsState.Data.LoadingMore) {
items(4) {
SkeletonTree(true) {
AvatarCard( AvatarCard(
title = "Artist Name", stringResource(Res.string.media_preview_subtitle),
subtitle = "Artist", stringResource(Res.string.media_artist),
imageURL = "https://placehold.co/400x400" modifier = Modifier.fillMaxWidth(),
listMode = list
)
},
modifier = modifier
) )
} }
} }
// }
}
}
}
}
}
} }

View File

@ -51,8 +51,7 @@ sealed interface LibraryArtistsState {
override val allItems: List<MetadataArtist.Basic> = emptyList(), override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataArtist.Basic> override val items: List<MetadataArtist.Basic> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
artist.name.contains(query, ignoreCase = true) artist.name.contains(query, ignoreCase = true)
} }
@ -72,8 +71,7 @@ sealed interface LibraryArtistsState {
override val allItems: List<MetadataArtist.Basic> = emptyList(), override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataArtist.Basic> override val items: List<MetadataArtist.Basic> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
artist.name.contains(query, ignoreCase = true) artist.name.contains(query, ignoreCase = true)
} }
@ -164,6 +162,12 @@ class LibraryArtistsViewModel(
libraryState.setQuery(LibraryTab.Artists, query) libraryState.setQuery(LibraryTab.Artists, query)
} }
fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) {
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) {
viewModelScope.launch { loadMoreData() }
}
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
repository.invalidateCaches() repository.invalidateCaches()

View File

@ -1,31 +1,8 @@
/* /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later
*
* 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 <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.modules.library.local_tracks 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.DpSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.PlatformType import com.composeunstyled.Text
import dev.krtirtho.spotube.getPlatform 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.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import org.jetbrains.compose.resources.stringResource
import dev.krtirtho.spotube.resources.iconsax.Play import org.jetbrains.compose.resources.pluralStringResource
import org.koin.compose.viewmodel.koinViewModel 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 @Composable
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) { fun LibraryLocalTracksScreen(
val viewModel = koinViewModel<LibraryLocalTracksViewModel>() modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permissionState = rememberLocalMediaPermissionState() val permission = rememberLocalMediaPermissionState()
val platformType = remember { getPlatform().type } // Platform permission events only; the ViewModel decides whether/when to index media.
val isAndroid = platformType == PlatformType.Android LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent(
LaunchedEffect(permissionState.isGranted, isAndroid) { state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
if (!isAndroid || permissionState.isGranted) { viewModel::playCurrentFolder, permission.requestPermission, modifier
viewModel.refresh()
}
}
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
}
Box(modifier = modifier.fillMaxWidth()) {
Column(
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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() @Composable
val tracks = viewModel.visibleTracks() internal fun LocalTracksContent(
state: LibraryLocalTracksState,
if (isAndroid && !permissionState.isGranted) { onOpenFolder: (String) -> Unit,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onPlayTrack: (String) -> Unit,
onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit,
onRequestPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val inset = LocalAppShellBottomInset.current
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, Modifier.widthIn(max = 960.dp).fillMaxSize()
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text("Allow audio permission to show local tracks") LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder)
MillenniumButton( when {
onClick = permissionState.requestPermission, !state.canAccessMedia -> MediaEmptyState(
style = MillenniumButtonStyle.Quiet 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
)
)
}
})
state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box(
Modifier.fillMaxSize().padding(bottom = inset),
contentAlignment = Alignment.Center
) { ) {
Text("Grant access")
}
}
}
} else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
MillenniumIndeterminateProgress() MillenniumIndeterminateProgress()
} }
} else if (state.currentFolderPath == null) {
if (folders.isEmpty()) { state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Modifier.weight(1f).fillMaxWidth(),
Text(state.error ?: "No local folders found") contentPadding = PaddingValues(bottom = inset + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.visibleFolders, key = { it.path }) { folder ->
LocalFolderRow(folder) { onOpenFolder(folder.path) }
} }
} else { }
LazyColumn(contentPadding = contentPadding) {
items(folders, key = { it.path }) { folder -> state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
MillenniumListTile( Modifier.weight(1f).fillMaxWidth(),
title = { Text(folder.name) }, contentPadding = PaddingValues(bottom = inset + 16.dp),
subtitle = { Text("${folder.trackCount} track(s)") }, verticalArrangement = Arrangement.spacedBy(8.dp)
leading = { ) {
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
)
}
})
}
}
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( MillenniumIcon(
imageVector = Iconsax.IconsaxFolderOpen, Iconsax.IconsaxFolderOpen,
contentDescription = null 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
) )
}, },
onClick = { viewModel.openFolder(folder.path) }) title = {
} Text(
} folder.name,
} style = AppTheme.typography.titleSmall,
} else { maxLines = 1,
if (tracks.isEmpty()) { overflow = TextOverflow.Ellipsis
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 = { subtitle = {
MillenniumIcon( Text(
imageVector = Iconsax.IconsaxMusicCircle, pluralStringResource(
contentDescription = null Res.plurals.library_local_tracks_count,
folder.trackCount,
folder.trackCount
), style = AppTheme.typography.bodySmall
) )
}, },
trailing = { trailing = {
MillenniumIconButton( MillenniumIcon(
icon = Iconsax.Play, FeatherIcons.ChevronRight,
contentDescription = "Play", null,
onClick = { viewModel.playTrack(track.path) }, modifier = Modifier.size(18.dp)
style = MillenniumIconButtonStyle.Quiet
) )
},
onClick = { viewModel.playTrack(track.path) })
}
}
}
}
}
} }
)
} }

View File

@ -33,25 +33,103 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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( data class LibraryLocalTracksState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val folders: List<LocalMediaFolder> = emptyList(), val folders: List<LocalMediaFolder> = emptyList(),
val currentFolderPath: String? = null, val currentFolderPath: String? = null,
val query: String = "", val query: String = "",
val error: String? = null val error: String? = null,
val canAccessMedia: Boolean = true,
val visibleFolders: List<LocalFolderUi> = emptyList(),
val visibleTracks: List<LocalTrackUi> = 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( class LibraryLocalTracksViewModel(
private val coordinator: LocalMediaLibraryCoordinator, private val coordinator: LocalMediaLibraryCoordinator,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val libraryState: LibraryState private val libraryState: LibraryState
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(LibraryLocalTracksState()) private val requiresPermission = getPlatform().type == PlatformType.Android
private val _state =
MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission))
val state: StateFlow<LibraryLocalTracksState> = combine( val state: StateFlow<LibraryLocalTracksState> = combine(
_state, libraryState.queryFlow(LibraryTab.LocalTracks) _state, libraryState.queryFlow(LibraryTab.LocalTracks)
) { state, query -> state.copy(query = query) } ) { state, query -> localTracksUiState(state, query) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -85,14 +163,28 @@ class LibraryLocalTracksViewModel(
_state.update { it.copy(currentFolderPath = null) } _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() { fun refresh() {
if (!_state.value.canAccessMedia) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isRefreshing = true, error = null) } _state.update { it.copy(isRefreshing = true, error = null) }
runCatching { runCatching {
coordinator.refreshNow("manual") coordinator.refreshNow("manual")
}.onFailure { throwable -> }.onFailure { throwable ->
val message =
throwable.message ?: getString(Res.string.library_local_refresh_failed)
_state.update { _state.update {
it.copy(error = throwable.message ?: "Failed to refresh local media") it.copy(error = message)
} }
} }
_state.update { it.copy(isRefreshing = false) } _state.update { it.copy(isRefreshing = false) }
@ -100,6 +192,7 @@ class LibraryLocalTracksViewModel(
} }
fun playFolder(folderPath: String) { fun playFolder(folderPath: String) {
if (!_state.value.canAccessMedia) return
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
val tracks = filterTracks(folder.tracks, state.value.query) val tracks = filterTracks(folder.tracks, state.value.query)
if (tracks.isEmpty()) return if (tracks.isEmpty()) return
@ -115,6 +208,7 @@ class LibraryLocalTracksViewModel(
} }
fun playTrack(trackPath: String) { fun playTrack(trackPath: String) {
if (!_state.value.canAccessMedia) return
val folder = currentFolder() ?: return val folder = currentFolder() ?: return
val tracks = filterTracks(folder.tracks, state.value.query) val tracks = filterTracks(folder.tracks, state.value.query)
val selectedIndex = tracks.indexOfFirst { it.path == trackPath } val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
@ -130,28 +224,7 @@ class LibraryLocalTracksViewModel(
} }
} }
fun visibleFolders(): List<LocalMediaFolder> { private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder
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<LocalMediaTrack> {
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 refreshIfEmpty() { private fun refreshIfEmpty() {
if (_state.value.folders.isNotEmpty()) return if (_state.value.folders.isNotEmpty()) return
@ -159,6 +232,7 @@ class LibraryLocalTracksViewModel(
} }
private fun refreshIfStale() { private fun refreshIfStale() {
if (!_state.value.canAccessMedia) return
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
coordinator.refreshIfStale() coordinator.refreshIfStale()
@ -170,11 +244,7 @@ class LibraryLocalTracksViewModel(
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
if (normalizedQuery.isBlank()) return tracks if (normalizedQuery.isBlank()) return tracks
return tracks.filter { track -> return tracks.filter { localTrackMatches(it, normalizedQuery) }
track.name.contains(normalizedQuery, ignoreCase = true) ||
track.album?.contains(normalizedQuery, ignoreCase = true) == true ||
track.artists.any { it.contains(normalizedQuery, ignoreCase = true) }
}
} }
private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack {

View File

@ -1,180 +1,94 @@
/* /* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * SPDX-License-Identifier: AGPL-3.0-or-later
*
* 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 <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.modules.library.playlist package dev.krtirtho.spotube.modules.library.playlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text 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.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.library.LibraryViewMode
import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryPlaylistsScreen( fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showCreatePlaylist: Boolean = false, showCreatePlaylist: Boolean = false,
onDismissCreatePlaylist: () -> Unit = {}, onDismissCreatePlaylist: () -> Unit = {},
viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryPlaylistsViewModel = koinViewModel() viewModel: LibraryPlaylistsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() Column(modifier) {
when (val current = state) {
LaunchedEffect(gridState) { is LibraryPlaylistsState.Error -> Box(
snapshotFlow { gridState.layoutInfo } Modifier.fillMaxSize(),
.map { layoutInfo -> contentAlignment = Alignment.Center
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() ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
}
}
} }
Column(modifier = modifier) { else -> {
when (state) { val data = current as? LibraryPlaylistsState.Data
is LibraryPlaylistsState.Loading -> { if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) {
val shellBottomInset = LocalAppShellBottomInset.current Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val contentPadding = remember(shellBottomInset) { Text(
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) if (data.query.isNotBlank()) stringResource(
} Res.string.library_playlists_no_results,
LazyVerticalGrid( data.query
columns = GridCells.Adaptive(160.dp), )
modifier = Modifier.fillMaxSize(), else stringResource(Res.string.library_playlists_empty)
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"
) )
}
}
}
}
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 { } else {
val shellBottomInset = LocalAppShellBottomInset.current LibraryMediaCollection<MetadataPlaylist>(
val contentPadding = remember(shellBottomInset) { items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) loading = current is LibraryPlaylistsState.Loading,
} loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
onViewportChanged = viewModel::onItemsVisible,
LazyVerticalGrid( header = { list ->
columns = GridCells.Adaptive(160.dp),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
LikedTracksCard( LikedTracksCard(
modifier = Modifier.fillMaxWidth() Modifier.fillMaxWidth(),
listMode = list
) )
} },
itemContent = { item, list ->
items(dataState.items, key = { it.id }) { playlist ->
PlaylistCard( PlaylistCard(
playlist = playlist, item,
modifier = Modifier.fillMaxWidth() Modifier.fillMaxWidth(),
listMode = list
) )
} },
skeleton = { list ->
if (state is LibraryPlaylistsState.Data.LoadingMore) {
items(4) {
SkeletonTree(true) {
PlayableCard( PlayableCard(
title = "Playlist Name", stringResource(Res.string.media_preview_title),
subtitle = "By Artist", stringResource(Res.string.media_preview_subtitle),
imageURL = "https://placehold.co/600x400" modifier = Modifier.fillMaxWidth(),
listMode = list
)
}
) )
} }
} }
} }
}
}
}
}
PlaylistFormSheet( PlaylistFormSheet(
visible = showCreatePlaylist, visible = showCreatePlaylist,
onDismiss = onDismissCreatePlaylist, onDismiss = onDismissCreatePlaylist,
onSubmit = { data -> onSubmit = viewModel::createPlaylist
viewModel.createPlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64,
trackIds = emptyList()
) )
})
} }
} }

View File

@ -51,8 +51,7 @@ sealed interface LibraryPlaylistsState {
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true) (it.description?.contains(query, ignoreCase = true) == true)
@ -73,8 +72,7 @@ sealed interface LibraryPlaylistsState {
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null override val nextPagination: PaginationStrategy? = null
) : Data { ) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist> = if (query.isNotBlank()) {
get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true) (it.description?.contains(query, ignoreCase = true) == true)
@ -152,6 +150,17 @@ class LibraryPlaylistsViewModel(
libraryState.setQuery(LibraryTab.Playlists, query) 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() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
repository.invalidateCaches() repository.invalidateCaches()

View File

@ -74,7 +74,8 @@ data class PlaylistFormData(
val isPublic: Boolean = true, val isPublic: Boolean = true,
val isCollaborating: Boolean = false, val isCollaborating: Boolean = false,
val imageBase64: String = "", val imageBase64: String = "",
val imagePreviewUrl: String? = null) val imagePreviewUrl: String? = null
)
@Composable @Composable
fun PlaylistFormSheet( fun PlaylistFormSheet(
@ -83,7 +84,8 @@ fun PlaylistFormSheet(
onSubmit: (PlaylistFormData) -> Unit, onSubmit: (PlaylistFormData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialData: PlaylistFormData? = null, initialData: PlaylistFormData? = null,
isEditing: Boolean = false) { isEditing: Boolean = false
) {
if (!visible) return if (!visible) return
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
@ -91,7 +93,11 @@ fun PlaylistFormSheet(
var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") } var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") }
var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") } var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") }
var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) } 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 imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") }
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) }
@ -131,7 +137,8 @@ fun PlaylistFormSheet(
isPublic = isPublic, isPublic = isPublic,
isCollaborating = isCollaborating, isCollaborating = isCollaborating,
imageBase64 = imageBase64, imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl) imagePreviewUrl = imagePreviewUrl
)
val handleImagePick = { imagePicker.launch() } val handleImagePick = { imagePicker.launch() }
@ -140,7 +147,8 @@ fun PlaylistFormSheet(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = if (isEditing) "Edit Playlist" else "Create Playlist", title = if (isEditing) "Edit Playlist" else "Create Playlist",
modifier = modifier) { modifier = modifier
) {
PlaylistFormFields( PlaylistFormFields(
nameState = nameState, nameState = nameState,
descriptionState = descriptionState, descriptionState = descriptionState,
@ -150,19 +158,22 @@ fun PlaylistFormSheet(
onIsCollaboratingChange = { isCollaborating = it }, onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64, imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl, imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick) onPickImage = handleImagePick
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton( MillenniumButton(
onClick = { onClick = {
onSubmit(currentData) onSubmit(currentData)
onDismiss() onDismiss()
}, },
enabled = name.isNotBlank()) { enabled = name.isNotBlank()
) {
Text(if (isEditing) "Save" else "Create") Text(if (isEditing) "Save" else "Create")
} }
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary
) {
Text("Cancel") Text("Cancel")
} }
} }
@ -173,11 +184,13 @@ fun PlaylistFormSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)) { .padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text( Text(
text = if (isEditing) "Edit Playlist" else "Create Playlist", text = if (isEditing) "Edit Playlist" else "Create Playlist",
style = AppTheme.typography.headlineSmall, style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)) modifier = Modifier.padding(bottom = 16.dp)
)
PlaylistFormFields( PlaylistFormFields(
nameState = nameState, nameState = nameState,
descriptionState = descriptionState, descriptionState = descriptionState,
@ -187,14 +200,17 @@ fun PlaylistFormSheet(
onIsCollaboratingChange = { isCollaborating = it }, onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64, imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl, imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick) onPickImage = handleImagePick
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary
) {
Text("Cancel") Text("Cancel")
} }
MillenniumButton( MillenniumButton(
@ -202,7 +218,8 @@ fun PlaylistFormSheet(
onSubmit(currentData) onSubmit(currentData)
onDismiss() onDismiss()
}, },
enabled = name.isNotBlank()) { enabled = name.isNotBlank()
) {
Text(if (isEditing) "Save" else "Create") Text(if (isEditing) "Save" else "Create")
} }
} }
@ -222,23 +239,27 @@ private fun PlaylistFormFields(
onIsCollaboratingChange: (Boolean) -> Unit, onIsCollaboratingChange: (Boolean) -> Unit,
imageBase64: String, imageBase64: String,
imagePreviewUrl: String?, imagePreviewUrl: String?,
onPickImage: () -> Unit) { onPickImage: () -> Unit
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)
) {
MillenniumTextField( MillenniumTextField(
state = nameState, state = nameState,
label = "Name", label = "Name",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = "Playlist name") placeholder = "Playlist name"
)
MillenniumTextField( MillenniumTextField(
state = descriptionState, state = descriptionState,
label = "Description", label = "Description",
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = "Description (optional)") placeholder = "Description (optional)"
)
Row( Row(
modifier = Modifier modifier = Modifier
@ -253,7 +274,8 @@ private fun PlaylistFormFields(
Text( Text(
"Anyone can see this playlist", "Anyone can see this playlist",
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange) MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange)
} }
@ -271,40 +293,49 @@ private fun PlaylistFormFields(
Text( Text(
"Others can add tracks to this playlist", "Others can add tracks to this playlist",
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
} }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Cover Image", style = AppTheme.typography.bodyLarge) Text("Cover Image", style = AppTheme.typography.bodyLarge)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(64.dp) .size(64.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center
) {
when { when {
imagePreviewUrl != null -> AsyncImage( imagePreviewUrl != null -> AsyncImage(
model = imagePreviewUrl, model = imagePreviewUrl,
contentDescription = "Playlist cover", contentDescription = "Playlist cover",
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
contentScale = ContentScale.Crop) contentScale = ContentScale.Crop
)
imageBase64.isNotBlank() -> { imageBase64.isNotBlank() -> {
Text( Text(
"✓", "✓",
style = AppTheme.typography.titleMedium, style = AppTheme.typography.titleMedium,
color = AppTheme.colors.primary) color = AppTheme.colors.primary
)
} }
else -> { else -> {
Text( Text(
"No image", "No image",
style = AppTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
} }
} }

View File

@ -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<LocalMediaFolderRowUi> = 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<String> = emptyList(),
val paths: List<String> = 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<UserSettings>,
private val persistFolders: suspend (List<String>) -> 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<String>) =
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('/', '\\')
}

View File

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

View File

@ -18,61 +18,42 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle 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 androidx.compose.foundation.lazy.LazyListScope
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings 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.SelectionSettingCard
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.path
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res 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_current
import spotube.composeapp.generated.resources.settings_download_folder_default 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_folder_title
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current 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_format_title
import spotube.composeapp.generated.resources.settings_download_quality_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_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_current
import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty
import spotube.composeapp.generated.resources.settings_local_media_folders_title 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( internal fun LazyListScope.downloadsSection(
settings: UserSettings, settings: UserSettings,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true) { includeHeader: Boolean = true
) {
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
val downloadQualities = availableAudioQualities( val downloadQualities = availableAudioQualities(
format = settings.downloadMusicFormat, format = settings.downloadMusicFormat,
current = settings.downloadMusicQuality) current = settings.downloadMusicQuality
)
if (includeHeader) { if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_downloads) settingsSectionHeader(Res.string.settings_section_downloads)
@ -104,14 +87,7 @@ internal fun LazyListScope.downloadsSection(
) )
}, },
{ {
LocalMediaFoldersSettingCard( LocalMediaFoldersSettingCard()
folders = settings.localMediaFolders,
onFoldersSaved = { folders ->
settingsViewModel.updateSettings {
copy(localMediaFolders = folders)
}
}
)
}, },
{ {
SelectionSettingCard( SelectionSettingCard(
@ -121,7 +97,10 @@ internal fun LazyListScope.downloadsSection(
settings.downloadMusicFormat.displayLabel() settings.downloadMusicFormat.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title)) SettingsItemIcon(
Iconsax.IconsaxMusicPlay,
stringResource(Res.string.settings_download_format_title)
)
}, },
selectedOption = settings.downloadMusicFormat, selectedOption = settings.downloadMusicFormat,
options = downloadFormats, options = downloadFormats,
@ -130,7 +109,8 @@ internal fun LazyListScope.downloadsSection(
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy( copy(
downloadMusicFormat = format, downloadMusicFormat = format,
downloadMusicQuality = format.resolveQuality(downloadMusicQuality)) downloadMusicQuality = format.resolveQuality(downloadMusicQuality)
)
} }
} }
) )
@ -143,7 +123,10 @@ internal fun LazyListScope.downloadsSection(
settings.downloadMusicQuality.displayLabel() settings.downloadMusicQuality.displayLabel()
), ),
icon = { icon = {
SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title)) SettingsItemIcon(
Iconsax.IconsaxVerticalSetting,
stringResource(Res.string.settings_download_quality_title)
)
}, },
selectedOption = settings.downloadMusicQuality, selectedOption = settings.downloadMusicQuality,
options = downloadQualities, options = downloadQualities,
@ -161,7 +144,8 @@ internal fun LazyListScope.downloadsSection(
@Composable @Composable
private fun DownloadFolderSettingCard( private fun DownloadFolderSettingCard(
folder: String?, folder: String?,
onFolderSelected: (String?) -> Unit) { onFolderSelected: (String?) -> Unit
) {
val pickerLauncher = rememberDirectoryPickerLauncher { directory -> val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
if (directory != null) { if (directory != null) {
val normalized = normalizePath(directory.path) 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_current, it)
} ?: stringResource(Res.string.settings_download_folder_default), } ?: stringResource(Res.string.settings_download_folder_default),
icon = { icon = {
SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) SettingsItemIcon(
Iconsax.IconsaxDirectboxReceive,
stringResource(Res.string.settings_download_folder_title)
)
}, },
trailingContent = { trailingContent = {
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.IconsaxFolderAdd, icon = Iconsax.IconsaxFolderAdd,
contentDescription = stringResource(Res.string.settings_download_folder_title), contentDescription = stringResource(Res.string.settings_download_folder_title),
onClick = { pickerLauncher.launch() }, onClick = { pickerLauncher.launch() },
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
}, },
onClick = { onClick = {
pickerLauncher.launch() pickerLauncher.launch()
@ -194,17 +182,17 @@ private fun DownloadFolderSettingCard(
@Composable @Composable
private fun LocalMediaFoldersSettingCard( private fun LocalMediaFoldersSettingCard(
folders: List<String>, viewModel: LocalMediaFoldersViewModel = koinViewModel()
onFoldersSaved: (List<String>) -> Unit) { ) {
var isDialogOpen by remember { mutableStateOf(false) } val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
val subtitlePreview = when { rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
folders.isEmpty() -> stringResource(Res.string.settings_local_media_folders_subtitle_empty) val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) {
folders.size == 1 -> folders.first() stringResource(Res.string.settings_local_media_folders_subtitle_empty)
else -> stringResource( } else stringResource(
Res.string.settings_local_media_folders_subtitle_current, Res.string.settings_local_media_folders_subtitle_current,
folders.size) state.configuredCount
} )
SettingCardItem( SettingCardItem(
title = stringResource(Res.string.settings_local_media_folders_title), title = stringResource(Res.string.settings_local_media_folders_title),
@ -212,117 +200,44 @@ private fun LocalMediaFoldersSettingCard(
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxFolder, Iconsax.IconsaxFolder,
stringResource(Res.string.settings_local_media_folders_title)) stringResource(Res.string.settings_local_media_folders_title)
)
}, },
trailingContent = { trailingContent = {
MillenniumButton( MillenniumButton(
onClick = { isDialogOpen = true }, onClick = viewModel::open,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.settings_local_media_folders_manage)) Text(stringResource(Res.string.settings_local_media_folders_manage))
} }
}, },
onClick = { onClick = viewModel::open
isDialogOpen = true
}
) )
if (isDialogOpen) { if (state.isOpen) {
LocalMediaFoldersDialog( LocalMediaFoldersDialog(
folders = folders, state = state,
onDismiss = { isDialogOpen = false }, onAddFolder = { pickerLauncher.launch() },
onFoldersSaved = onFoldersSaved) onRemoveFolder = viewModel::removeFolder,
onSave = viewModel::save,
onDismiss = viewModel::dismiss
)
} }
} }
@Composable @Composable
private fun LocalMediaFoldersDialog( private fun LocalMediaFoldersDialog(
folders: List<String>, state: LocalMediaFoldersUiState,
onDismiss: () -> Unit, onAddFolder: () -> Unit,
onFoldersSaved: (List<String>) -> Unit) { onRemoveFolder: (String) -> Unit,
val draftFolders = remember(folders) { onSave: () -> Unit,
mutableStateListOf<String>().apply { addAll(folders) } onDismiss: () -> Unit
} ) {
val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
if (directory != null) {
val normalized = normalizePath(directory.path)
if (normalized.isNotEmpty() && draftFolders.none { normalizePath(it) == normalized }) {
draftFolders.add(normalized)
}
}
}
MillenniumDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = stringResource(Res.string.settings_local_media_folders_title)) { title = stringResource(Res.string.settings_local_media_folders_title)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { ) {
Text( LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss)
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))
}
}
}
}
}

View File

@ -38,6 +38,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryToolbarTest { class LibraryToolbarTest {
@ -109,6 +110,29 @@ class LibraryToolbarTest {
assertEquals("Radiohead", library.uiState.value.searchState.text.toString()) 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) @OptIn(ExperimentalComposeUiApi::class)
@Test @Test
fun renderExpandedCompactAndSearchToolbars() { fun renderExpandedCompactAndSearchToolbars() {
@ -128,14 +152,29 @@ class LibraryToolbarTest {
searchMode = searching, searchMode = searching,
searchState = TextFieldState(if (searching) "Muse" else ""), searchState = TextFieldState(if (searching) "Muse" else ""),
canCreatePlaylist = tab == LibraryTab.Playlists, canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks canRefresh = tab == LibraryTab.LocalTracks,
canChangeView = tab != LibraryTab.LocalTracks
) )
ImageComposeScene(width, 88) { ImageComposeScene(width, 88) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) { SpotubeTheme(UserSettings(theme = Theme.DARK)) {
MillenniumTopAppBar( MillenniumTopAppBar(
title = { LibraryToolbarTitle(state, expandedDesktop) }, title = {
LibraryToolbarTitle(
state,
expandedDesktop,
mode.startsWith("compact-desktop")
)
},
actions = { actions = {
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {}) LibraryToolbarActions(
state,
expandedDesktop,
{},
{},
{},
{},
compactDesktop = mode.startsWith("compact-desktop")
)
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer( if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
Modifier.width(132.dp) Modifier.width(132.dp)
) )

View File

@ -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<SemanticsNode> {
fun flatten(node: SemanticsNode): List<SemanticsNode> =
listOf(node) + node.children.flatMap(::flatten)
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
}
}

View File

@ -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<LocalMediaFoldersViewModel>()
@BeforeTest
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun cleanup() {
models.forEach { it.viewModelScope.cancel() }
models.clear()
Dispatchers.resetMain()
}
private fun model(
source: MutableStateFlow<UserSettings>,
persist: suspend (List<String>) -> 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<Unit>()
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<SemanticsNode> =
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)
}
}
}
}
}
}
}
}