mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
cf9476ab08
commit
58dfa2de92
@ -27,6 +27,7 @@
|
||||
|
||||
<!-- Library toolbar -->
|
||||
<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_albums">Albums</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_create_playlist">Create playlist</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>
|
||||
|
||||
@ -122,11 +176,21 @@
|
||||
<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_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_add_action">Add 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_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_subtitle_current">Preferred codec and container for downloads. Current: %1$s</string>
|
||||
<string name="settings_download_quality_title">Download music quality</string>
|
||||
|
||||
@ -82,6 +82,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
@ -142,6 +143,13 @@ val sharedModules = module {
|
||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
viewModel {
|
||||
val repository = get<SettingsRepository>()
|
||||
LocalMediaFoldersViewModel(repository.userSettings) { folders ->
|
||||
val current = repository.getCurrentSettings()
|
||||
repository.updateSettings(current.copy(localMediaFolders = folders))
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome / Onboarding
|
||||
singleOf(::WelcomeRepository)
|
||||
|
||||
@ -40,12 +40,15 @@ fun AlbumCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
PlayableCard(
|
||||
title = album.title,
|
||||
subtitle = album.description ?: "${album.albumType} • ${album.artists.joinToString { it.name }}",
|
||||
subtitle = album.description
|
||||
?: "${album.albumType} • ${album.artists.joinToString { it.name }}",
|
||||
imageURL = album.thumbnails.firstOrNull()?.url,
|
||||
isPlaying = currentCollectionEntry?.id == album.id,
|
||||
onClick = {
|
||||
@ -56,14 +59,18 @@ fun AlbumCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title)
|
||||
album.title
|
||||
)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title)
|
||||
album.title
|
||||
)
|
||||
},
|
||||
modifier = modifier.width(160.dp),
|
||||
sharedElementKey = "album_art_${album.id}")
|
||||
modifier = if (listMode) modifier else modifier.width(160.dp),
|
||||
sharedElementKey = "album_art_${album.id}",
|
||||
listMode = listMode
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,52 +17,34 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
import org.koin.compose.koinInject
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun ArtistCard(
|
||||
artist: MetadataArtist.Basic,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false) {
|
||||
isBlacklisted: Boolean = false,
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
AvatarCard(
|
||||
title = artist.name,
|
||||
subtitle = if (isBlacklisted) "Blacklisted" else "Artist",
|
||||
subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist),
|
||||
imageURL = artist.thumbnails.firstOrNull()?.url,
|
||||
onClick = {
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
modifier = modifier,
|
||||
isBlacklisted = isBlacklisted)
|
||||
isBlacklisted = isBlacklisted,
|
||||
listMode = listMode
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -21,14 +21,7 @@ package dev.krtirtho.spotube.core.ui.component
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -44,7 +37,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -58,19 +50,21 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.Play
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.koin.compose.koinInject
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.liked_tracks
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.MediaCardSurface
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@ -78,27 +72,49 @@ fun LikedTracksCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject()) {
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
|
||||
val title = stringResource(Res.string.media_liked_tracks)
|
||||
val subtitle = stringResource(Res.string.media_saved_songs)
|
||||
if (listMode) {
|
||||
PlayableListTile(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
isPlaying = isPlaying,
|
||||
onClick = { navigationCommands.navigateTo(Routes.SavedTracks) },
|
||||
onPlay = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||
onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||
modifier = modifier,
|
||||
sharedElementKey = "saved_tracks_art",
|
||||
artwork = {
|
||||
Image(
|
||||
painterResource(Res.drawable.liked_tracks),
|
||||
title,
|
||||
Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch
|
||||
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
MediaCardSurface(
|
||||
modifier = modifier.width(160.dp),
|
||||
onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }
|
||||
) { isHovered ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(Res.drawable.liked_tracks),
|
||||
contentDescription = "Liked Tracks",
|
||||
contentDescription = title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
@ -106,14 +122,16 @@ fun LikedTracksCard(
|
||||
.sharedElementOrNone(
|
||||
key = "saved_tracks_art",
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
if (isHovered) {
|
||||
if (isHovered || touchInput || isPlaying) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
@ -125,7 +143,7 @@ fun LikedTracksCard(
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
contentDescription = stringResource(Res.string.player_add_to_queue_content_description),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@ -139,7 +157,7 @@ fun LikedTracksCard(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPauseCircle
|
||||
} else Iconsax.Play,
|
||||
contentDescription = "Play",
|
||||
contentDescription = stringResource(Res.string.player_play_content_description),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@ -151,17 +169,19 @@ fun LikedTracksCard(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Liked Tracks",
|
||||
text = title,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Your saved songs",
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,9 @@ fun PlaylistCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -57,14 +59,18 @@ fun PlaylistCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title)
|
||||
playlist.title
|
||||
)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title)
|
||||
playlist.title
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
sharedElementKey = "playlist_art_${playlist.id}")
|
||||
sharedElementKey = "playlist_art_${playlist.id}",
|
||||
listMode = listMode
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,79 +17,67 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import org.koin.compose.koinInject
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
|
||||
@Composable
|
||||
fun AvatarCard(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
imageURL: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false) {
|
||||
val backgroundColor = if (isBlacklisted) {
|
||||
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
isBlacklisted: Boolean = false,
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
if (listMode) {
|
||||
AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted)
|
||||
return
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||
.width(160.dp)) {
|
||||
MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MediaArtwork(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
title = title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop)
|
||||
shape = CircleShape,
|
||||
placeholderIcon = Iconsax.User
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
@ -99,17 +87,20 @@ fun AvatarCard(
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
subtitle?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,10 +25,6 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -38,21 +34,18 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||
@ -67,6 +60,8 @@ import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
|
||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
||||
import spotube.composeapp.generated.resources.player_play_content_description
|
||||
import spotube.composeapp.generated.resources.media_preview_title
|
||||
import spotube.composeapp.generated.resources.media_preview_subtitle
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@ -79,22 +74,37 @@ fun PlayableCard(
|
||||
onPlay: (() -> Unit)? = null,
|
||||
onAddToQueue: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
sharedElementKey: String? = null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
sharedElementKey: String? = null,
|
||||
listMode: Boolean = false
|
||||
) {
|
||||
if (listMode) {
|
||||
PlayableListTile(
|
||||
title,
|
||||
subtitle,
|
||||
imageURL,
|
||||
isPlaying,
|
||||
onClick,
|
||||
onPlay,
|
||||
onAddToQueue,
|
||||
modifier,
|
||||
sharedElementKey
|
||||
)
|
||||
return
|
||||
}
|
||||
val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
MediaCardSurface(
|
||||
modifier = modifier.width(160.dp),
|
||||
onClick = onClick ?: onPlay,
|
||||
interactive = onClick != null || onPlay != null || onAddToQueue != null
|
||||
) { highlighted ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||
AsyncImage(
|
||||
MediaArtwork(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
title = title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
@ -102,19 +112,21 @@ fun PlayableCard(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
if (onPlay != null || onAddToQueue != null)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
this@Column.AnimatedVisibility(
|
||||
visible = isHovered || (isPlaying && onPlay != null),
|
||||
visible = highlighted || touchInput || (isPlaying && onPlay != null),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()) {
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
@ -130,7 +142,9 @@ fun PlayableCard(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = stringResource(
|
||||
Res.string.player_add_to_queue_content_description))
|
||||
Res.string.player_add_to_queue_content_description
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
@ -150,7 +164,9 @@ fun PlayableCard(
|
||||
Res.string.player_pause_content_description
|
||||
} else {
|
||||
Res.string.player_play_content_description
|
||||
}))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -164,7 +180,8 @@ fun PlayableCard(
|
||||
text = title,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
@ -172,7 +189,8 @@ fun PlayableCard(
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,8 +203,8 @@ fun PlayableCard(
|
||||
private fun PlayableCardPreview() {
|
||||
Box {
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
title = stringResource(Res.string.media_preview_title),
|
||||
subtitle = stringResource(Res.string.media_preview_subtitle),
|
||||
imageURL = "https://placehold.co/600x400",
|
||||
isPlaying = true,
|
||||
onClick = {},
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,8 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.downloads
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumProgress
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -38,87 +16,153 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumProgress
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen(modifier: Modifier = Modifier) {
|
||||
val viewModel = koinViewModel<DownloadsViewModel>()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset)
|
||||
fun DownloadsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DownloadsViewModel = koinViewModel(),
|
||||
headerInAppBar: Boolean = false,
|
||||
topContent: @Composable () -> Unit = {}
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Column(modifier.fillMaxSize()) {
|
||||
if (headerInAppBar) {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
title = { DownloadsToolbarTitle() },
|
||||
subtitle = { DownloadsToolbarSummary(state) },
|
||||
actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) }
|
||||
)
|
||||
topContent()
|
||||
}
|
||||
DownloadsContent(
|
||||
state,
|
||||
viewModel::cancel,
|
||||
viewModel::retry,
|
||||
viewModel::remove,
|
||||
viewModel::clearCompleted,
|
||||
Modifier.weight(1f)
|
||||
.then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier),
|
||||
showHeader = !headerInAppBar
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) {
|
||||
val hasCompleted = downloads.any { it.status == DownloadStatus.Completed }
|
||||
if (hasCompleted) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.End) {
|
||||
MillenniumButton(
|
||||
onClick = viewModel::clearCompleted,
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = " Clear completed",
|
||||
style = AppTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
internal fun DownloadsToolbarTitle() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
null,
|
||||
tint = AppTheme.colors.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.library_tab_downloads),
|
||||
style = AppTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
|
||||
if (state.items.isNotEmpty()) Text(
|
||||
stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) {
|
||||
if (state.canClearCompleted) MillenniumButton(
|
||||
onClick = onClearCompleted,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.downloads_clear_completed),
|
||||
style = AppTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DownloadsContent(
|
||||
state: DownloadsUiState,
|
||||
onCancel: (String) -> Unit,
|
||||
onRetry: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
onClearCompleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true
|
||||
) {
|
||||
val inset = LocalAppShellBottomInset.current
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) {
|
||||
if (showHeader) Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
DownloadsToolbarTitle()
|
||||
DownloadsToolbarSummary(state)
|
||||
}
|
||||
DownloadsToolbarActions(state, onClearCompleted)
|
||||
}
|
||||
|
||||
if (downloads.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "No downloads yet",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(downloads, key = { it.id }) { item ->
|
||||
DownloadListRow(
|
||||
item = item,
|
||||
onCancel = { viewModel.cancel(item.id) },
|
||||
onRetry = { viewModel.retry(item.id) },
|
||||
onRemove = { viewModel.remove(item.id) })
|
||||
}
|
||||
if (state.items.isEmpty()) MediaEmptyState(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
stringResource(Res.string.downloads_empty),
|
||||
Modifier.padding(bottom = inset)
|
||||
)
|
||||
else LazyColumn(
|
||||
Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(
|
||||
top = if (showHeader) 0.dp else 16.dp,
|
||||
bottom = inset + 16.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.items, key = { it.id }) { row ->
|
||||
DownloadListRow(
|
||||
row,
|
||||
{ onCancel(row.id) },
|
||||
{ onRetry(row.id) },
|
||||
{ onRemove(row.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,147 +170,82 @@ fun DownloadsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadListRow(
|
||||
item: DownloadItem,
|
||||
internal fun DownloadListRow(
|
||||
item: DownloadRowUiState,
|
||||
onCancel: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRemove: () -> Unit) {
|
||||
val rowBackgroundColor = when (item.status) {
|
||||
is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f)
|
||||
is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f)
|
||||
else -> AppTheme.colors.surface
|
||||
}
|
||||
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val colors = MillenniumTheme.colors
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.background(rowBackgroundColor)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails)
|
||||
?.firstOrNull()?.url
|
||||
|
||||
val platformContext = LocalPlatformContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
ImageRequest.Builder(platformContext)
|
||||
.data(imageUrl)
|
||||
.size(128)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(AppTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = item.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
|
||||
if (item.status == DownloadStatus.Downloading) {
|
||||
MillenniumProgress(
|
||||
progress = item.progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = millenniumPanelBrush(colors),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
borderColor = colors.border,
|
||||
borderWidth = 1.dp,
|
||||
innerGlowColor = colors.highlight.copy(alpha = 0.2f)
|
||||
).padding(12.dp), verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
item.title,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = item.artists,
|
||||
item.artists,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
when (item.status) {
|
||||
DownloadStatus.Downloading -> stringResource(
|
||||
Res.string.downloads_status_downloading,
|
||||
item.percent
|
||||
)
|
||||
|
||||
when (item.status) {
|
||||
is DownloadStatus.Downloading -> {
|
||||
val percent = (item.progress * 100).toInt()
|
||||
Text(
|
||||
text = "Downloading... $percent%",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
is DownloadStatus.Queued -> {
|
||||
Text(
|
||||
text = "Queued",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
is DownloadStatus.Failed -> {
|
||||
Text(
|
||||
text = "Failed: ${item.errorMessage ?: "Unknown error"}",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.error,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
is DownloadStatus.Cancelled -> {
|
||||
Text(
|
||||
text = "Cancelled",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
is DownloadStatus.Completed -> {
|
||||
Text(
|
||||
text = "Completed",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued)
|
||||
DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed)
|
||||
DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled)
|
||||
is DownloadStatus.Failed -> if (item.error != null) stringResource(
|
||||
Res.string.downloads_status_failed_error,
|
||||
item.error
|
||||
) else stringResource(Res.string.downloads_status_failed)
|
||||
},
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = when (item.status) {
|
||||
is DownloadStatus.Failed -> AppTheme.colors.error
|
||||
DownloadStatus.Completed -> AppTheme.colors.primary
|
||||
else -> AppTheme.colors.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (item.status == DownloadStatus.Downloading) MillenniumProgress(
|
||||
item.progress,
|
||||
Modifier.fillMaxWidth().height(4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
when (item.status) {
|
||||
is DownloadStatus.Downloading, is DownloadStatus.Queued -> {
|
||||
MillenniumIconButton(
|
||||
onClick = onCancel,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = "Cancel",
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadStatus.Failed, is DownloadStatus.Cancelled -> {
|
||||
MillenniumIconButton(
|
||||
onClick = onRetry,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = "Retry",
|
||||
tint = AppTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadStatus.Completed -> {
|
||||
MillenniumIconButton(
|
||||
onClick = onRemove,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = "Remove",
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = when (item.action) {
|
||||
DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove
|
||||
},
|
||||
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
MillenniumIcon(
|
||||
when (item.action) {
|
||||
DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash
|
||||
},
|
||||
stringResource(
|
||||
when (item.action) {
|
||||
DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,13 +23,64 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class DownloadRowAction { Cancel, Retry, Remove }
|
||||
|
||||
data class DownloadRowUiState(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artists: String,
|
||||
val artworkUrl: String?,
|
||||
val status: DownloadStatus,
|
||||
val progress: Float,
|
||||
val percent: Int,
|
||||
val error: String?,
|
||||
val action: DownloadRowAction
|
||||
)
|
||||
|
||||
data class DownloadsUiState(
|
||||
val items: List<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(
|
||||
private val downloadManager: DownloadManager) : ViewModel() {
|
||||
private val downloadManager: DownloadManager
|
||||
) : ViewModel() {
|
||||
val downloads: StateFlow<List<DownloadItem>> = downloadManager.downloadsFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
emptyList())
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val uiState = downloadManager.downloadsFlow.map(::downloadsUiState)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
|
||||
|
||||
fun downloadTrack(track: MetadataTrack) {
|
||||
downloadManager.enqueue(track)
|
||||
|
||||
@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
@ -63,6 +65,9 @@ import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.Grid
|
||||
import compose.icons.feathericons.List
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
@ -70,13 +75,24 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
|
||||
val state by libraryState.uiState.collectAsStateWithLifecycle()
|
||||
val expandedDesktop = getPlatform().isDesktop() &&
|
||||
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
|
||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded
|
||||
val compactDesktop = getPlatform().isDesktop() && isCompact
|
||||
val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
if (state.currentTab == LibraryTab.Downloads && !isCompact) {
|
||||
DownloadsScreen(
|
||||
headerInAppBar = true,
|
||||
topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||
title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) },
|
||||
actions = {
|
||||
LibraryToolbarActions(
|
||||
state = state,
|
||||
@ -88,32 +104,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
|
||||
libraryState.closeSearch()
|
||||
},
|
||||
onCreatePlaylist = libraryState::showCreatePlaylist,
|
||||
onRefresh = libraryState::refreshLocalTracks
|
||||
onRefresh = libraryState::refreshLocalTracks,
|
||||
onToggleView = { libraryState.toggleViewMode(isCompact) },
|
||||
viewMode = viewMode,
|
||||
compactDesktop = compactDesktop
|
||||
)
|
||||
}
|
||||
)
|
||||
if (!expandedDesktop) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(LibraryTab.entries) { tab ->
|
||||
MillenniumButton(
|
||||
onClick = { libraryState.onTabSelected(tab) },
|
||||
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
|
||||
) { Text(stringResource(tab.titleResource)) }
|
||||
}
|
||||
}
|
||||
LibraryTabs(state, libraryState::onTabSelected)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
when (state.currentTab) {
|
||||
LibraryTab.Playlists -> LibraryPlaylistsScreen(
|
||||
showCreatePlaylist = state.showCreatePlaylist,
|
||||
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
|
||||
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
|
||||
viewMode = viewMode
|
||||
)
|
||||
|
||||
LibraryTab.Albums -> LibraryAlbumsScreen()
|
||||
LibraryTab.Artists -> LibraryArtistsScreen()
|
||||
LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
|
||||
LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
|
||||
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
|
||||
LibraryTab.Downloads -> DownloadsScreen()
|
||||
}
|
||||
@ -122,7 +132,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
|
||||
private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(LibraryTab.entries) { tab ->
|
||||
MillenniumButton(
|
||||
onClick = { onTabSelected(tab) },
|
||||
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
|
||||
) { Text(stringResource(tab.titleResource)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryToolbarTitle(
|
||||
state: LibraryToolbarUiState,
|
||||
expandedDesktop: Boolean,
|
||||
compactDesktop: Boolean = false
|
||||
) {
|
||||
if (!expandedDesktop && state.searchMode) {
|
||||
LibraryFilterField(
|
||||
state.searchState,
|
||||
@ -131,7 +160,11 @@ internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop:
|
||||
autoFocus = true
|
||||
)
|
||||
} else if (!expandedDesktop) {
|
||||
Text(stringResource(Res.string.library_screen_title))
|
||||
Text(
|
||||
stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,13 +175,18 @@ internal fun RowScope.LibraryToolbarActions(
|
||||
onOpenSearch: () -> Unit,
|
||||
onCloseSearch: () -> Unit,
|
||||
onCreatePlaylist: () -> Unit,
|
||||
onRefresh: () -> Unit
|
||||
onRefresh: () -> Unit,
|
||||
onToggleView: () -> Unit = {},
|
||||
viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop),
|
||||
compactDesktop: Boolean = false
|
||||
) {
|
||||
val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp)
|
||||
if (!expandedDesktop && state.searchMode) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = stringResource(Res.string.library_action_close_search),
|
||||
onClick = onCloseSearch,
|
||||
modifier = actionModifier,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
} else {
|
||||
@ -164,6 +202,7 @@ internal fun RowScope.LibraryToolbarActions(
|
||||
icon = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = stringResource(Res.string.library_action_search),
|
||||
onClick = onOpenSearch,
|
||||
modifier = actionModifier,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
@ -173,6 +212,7 @@ internal fun RowScope.LibraryToolbarActions(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||
onClick = onCreatePlaylist,
|
||||
modifier = actionModifier,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
@ -181,6 +221,16 @@ internal fun RowScope.LibraryToolbarActions(
|
||||
icon = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
|
||||
onClick = onRefresh,
|
||||
modifier = actionModifier,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
if (state.canChangeView) {
|
||||
MillenniumIconButton(
|
||||
icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List,
|
||||
contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view),
|
||||
onClick = onToggleView,
|
||||
modifier = actionModifier,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
enum class LibraryViewMode { Grid, List }
|
||||
|
||||
data class LibraryToolbarUiState(
|
||||
val currentTab: LibraryTab = LibraryTab.Playlists,
|
||||
@ -39,7 +42,9 @@ data class LibraryToolbarUiState(
|
||||
val canFilter: Boolean = true,
|
||||
val canCreatePlaylist: Boolean = true,
|
||||
val canRefresh: Boolean = false,
|
||||
val showCreatePlaylist: Boolean = false
|
||||
val showCreatePlaylist: Boolean = false,
|
||||
val canChangeView: Boolean = true,
|
||||
val viewModeOverride: LibraryViewMode? = null
|
||||
)
|
||||
|
||||
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
|
||||
@ -49,14 +54,16 @@ class LibraryState : ViewModel() {
|
||||
val currentTab = MutableStateFlow(LibraryTab.Playlists)
|
||||
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
|
||||
private val createPlaylistVisible = MutableStateFlow(false)
|
||||
private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap())
|
||||
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val localRefreshRequests = refreshRequests.asSharedFlow()
|
||||
|
||||
val uiState = combine(
|
||||
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
|
||||
searchMode,
|
||||
createPlaylistVisible
|
||||
) { (tab, query), searching, creating ->
|
||||
createPlaylistVisible,
|
||||
viewModes
|
||||
) { (tab, query), searching, creating, modes ->
|
||||
LibraryToolbarUiState(
|
||||
currentTab = tab,
|
||||
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
|
||||
@ -64,7 +71,9 @@ class LibraryState : ViewModel() {
|
||||
canFilter = tab != LibraryTab.Downloads,
|
||||
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||
canRefresh = tab == LibraryTab.LocalTracks,
|
||||
showCreatePlaylist = tab == LibraryTab.Playlists && creating
|
||||
showCreatePlaylist = tab == LibraryTab.Playlists && creating,
|
||||
canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists,
|
||||
viewModeOverride = modes[tab]
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly,
|
||||
@ -103,4 +112,16 @@ class LibraryState : ViewModel() {
|
||||
fun refreshLocalTracks() {
|
||||
refreshRequests.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun toggleViewMode(isCompact: Boolean) {
|
||||
val tab = currentTab.value
|
||||
if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return
|
||||
viewModes.update { modes ->
|
||||
val current = modes[tab] ?: defaultLibraryViewMode(isCompact)
|
||||
modes + (tab to if (current == LibraryViewMode.Grid) LibraryViewMode.List else LibraryViewMode.Grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun defaultLibraryViewMode(isCompact: Boolean) =
|
||||
if (isCompact) LibraryViewMode.List else LibraryViewMode.Grid
|
||||
|
||||
@ -1,158 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.library.album
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
|
||||
import dev.krtirtho.spotube.modules.library.LibraryViewMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun LibraryAlbumsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
||||
viewModel: LibraryAlbumsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
when (val current = state) {
|
||||
is LibraryAlbumsState.Error -> Box(
|
||||
modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
Pair(lastVisibleIndex, totalItems)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (lastVisibleIndex, totalItems) ->
|
||||
if (
|
||||
lastVisibleIndex != null &&
|
||||
totalItems > 0 &&
|
||||
lastVisibleIndex >= totalItems - 4
|
||||
) {
|
||||
viewModel.loadMoreData()
|
||||
else -> {
|
||||
val data = current as? LibraryAlbumsState.Data
|
||||
if (data != null && data.items.isEmpty() && current !is LibraryAlbumsState.Data.LoadingMore) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (data.query.isNotBlank()) stringResource(
|
||||
Res.string.library_albums_no_results,
|
||||
data.query
|
||||
)
|
||||
else stringResource(Res.string.library_albums_empty)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
when (state) {
|
||||
is LibraryAlbumsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(8) {
|
||||
SkeletonTree(true) {
|
||||
PlayableCard(
|
||||
title = "Album Title",
|
||||
subtitle = "Artist Name",
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryAlbumsState.Error -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
ErrorDisplay(
|
||||
errorMessage = (state as LibraryAlbumsState.Error).message,
|
||||
onRetry = { viewModel.refresh() })
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryAlbumsState.Data -> {
|
||||
val dataState = state as LibraryAlbumsState.Data
|
||||
if (dataState.items.isEmpty() && state !is LibraryAlbumsState.Data.LoadingMore) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
dataState.query.isNotBlank() -> Text("No albums found for '${dataState.query}'")
|
||||
else -> Text("No saved albums")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(dataState.items, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (state is LibraryAlbumsState.Data.LoadingMore) {
|
||||
items(4) {
|
||||
SkeletonTree(true) {
|
||||
PlayableCard(
|
||||
title = "Album Title",
|
||||
subtitle = "Artist Name",
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else LibraryMediaCollection<MetadataAlbum.Detailed>(
|
||||
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = current is LibraryAlbumsState.Loading,
|
||||
loadingMore = current is LibraryAlbumsState.Data.LoadingMore,
|
||||
onViewportChanged = viewModel::onItemsVisible,
|
||||
itemContent = { item, list ->
|
||||
AlbumCard(
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
PlayableCard(
|
||||
stringResource(Res.string.media_preview_title),
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,17 +51,16 @@ sealed interface LibraryAlbumsState {
|
||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataAlbum.Detailed>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
album.title.contains(query, ignoreCase = true) ||
|
||||
album.artists.any { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataAlbum.Detailed> = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
album.title.contains(query, ignoreCase = true) ||
|
||||
album.artists.any { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
@ -75,17 +74,16 @@ sealed interface LibraryAlbumsState {
|
||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataAlbum.Detailed>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
album.title.contains(query, ignoreCase = true) ||
|
||||
album.artists.any { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataAlbum.Detailed> = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
album.title.contains(query, ignoreCase = true) ||
|
||||
album.artists.any { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,6 +154,12 @@ class LibraryAlbumsViewModel(
|
||||
libraryState.setQuery(LibraryTab.Albums, query)
|
||||
}
|
||||
|
||||
fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) {
|
||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) {
|
||||
viewModelScope.launch { loadMoreData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
repository.invalidateCaches()
|
||||
|
||||
@ -1,158 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.library.artist
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
|
||||
import dev.krtirtho.spotube.modules.library.LibraryViewMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun LibraryArtistsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
||||
viewModel: LibraryArtistsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
when (val current = state) {
|
||||
is LibraryArtistsState.Error -> Box(
|
||||
modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
Pair(lastVisibleIndex, totalItems)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (lastVisibleIndex, totalItems) ->
|
||||
if (
|
||||
lastVisibleIndex != null &&
|
||||
totalItems > 0 &&
|
||||
lastVisibleIndex >= totalItems - 4
|
||||
) {
|
||||
viewModel.loadMoreData()
|
||||
else -> {
|
||||
val data = current as? LibraryArtistsState.Data
|
||||
if (data != null && data.items.isEmpty() && current !is LibraryArtistsState.Data.LoadingMore) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (data.query.isNotBlank()) stringResource(
|
||||
Res.string.library_artists_no_results,
|
||||
data.query
|
||||
)
|
||||
else stringResource(Res.string.library_artists_empty)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
when (state) {
|
||||
is LibraryArtistsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(8) {
|
||||
SkeletonTree(true) {
|
||||
AvatarCard(
|
||||
title = "Artist Name",
|
||||
subtitle = "Artist",
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryArtistsState.Error -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
ErrorDisplay(
|
||||
errorMessage = (state as LibraryArtistsState.Error).message,
|
||||
onRetry = { viewModel.refresh() })
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryArtistsState.Data -> {
|
||||
val dataState = state as LibraryArtistsState.Data
|
||||
if (dataState.items.isEmpty() && state !is LibraryArtistsState.Data.LoadingMore) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
dataState.query.isNotBlank() -> Text("No artists found for '${dataState.query}'")
|
||||
else -> Text("No saved artists")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(dataState.items, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// if (state is LibraryArtistsState.Data.LoadingMore) {
|
||||
items(4) {
|
||||
SkeletonTree(true) {
|
||||
AvatarCard(
|
||||
title = "Artist Name",
|
||||
subtitle = "Artist",
|
||||
imageURL = "https://placehold.co/400x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else LibraryMediaCollection<MetadataArtist.Basic>(
|
||||
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = current is LibraryArtistsState.Loading,
|
||||
loadingMore = current is LibraryArtistsState.Data.LoadingMore,
|
||||
onViewportChanged = viewModel::onItemsVisible,
|
||||
itemContent = { item, list ->
|
||||
ArtistCard(
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
AvatarCard(
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
stringResource(Res.string.media_artist),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,14 +51,13 @@ sealed interface LibraryArtistsState {
|
||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataArtist.Basic>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataArtist.Basic> = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
@ -72,14 +71,13 @@ sealed interface LibraryArtistsState {
|
||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataArtist.Basic>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataArtist.Basic> = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
artist.name.contains(query, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +162,12 @@ class LibraryArtistsViewModel(
|
||||
libraryState.setQuery(LibraryTab.Artists, query)
|
||||
}
|
||||
|
||||
fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) {
|
||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) {
|
||||
viewModelScope.launch { loadMoreData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
repository.invalidateCaches()
|
||||
|
||||
@ -1,31 +1,8 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.library.local_tracks
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -33,161 +10,288 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
||||
import com.composeunstyled.Text
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import com.composeunstyled.Text
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||
import dev.krtirtho.spotube.resources.iconsax.Play
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
|
||||
val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
|
||||
fun LibraryLocalTracksScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LibraryLocalTracksViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val permissionState = rememberLocalMediaPermissionState()
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isAndroid = platformType == PlatformType.Android
|
||||
val permission = rememberLocalMediaPermissionState()
|
||||
// Platform permission events only; the ViewModel decides whether/when to index media.
|
||||
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
|
||||
LocalTracksContent(
|
||||
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
|
||||
viewModel::playCurrentFolder, permission.requestPermission, modifier
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(permissionState.isGranted, isAndroid) {
|
||||
if (!isAndroid || permissionState.isGranted) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
@Composable
|
||||
internal fun LocalTracksContent(
|
||||
state: LibraryLocalTracksState,
|
||||
onOpenFolder: (String) -> Unit,
|
||||
onPlayTrack: (String) -> Unit,
|
||||
onNavigateUp: () -> Unit,
|
||||
onPlayFolder: () -> Unit,
|
||||
onRequestPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val inset = LocalAppShellBottomInset.current
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Column(
|
||||
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Modifier.widthIn(max = 960.dp).fillMaxSize()
|
||||
) {
|
||||
if (state.currentFolderPath != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.ArrowLeft3,
|
||||
contentDescription = "Back",
|
||||
onClick = viewModel::navigateUp,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
Text(viewModel.currentFolder()?.name ?: "Folder")
|
||||
}
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = "Play folder",
|
||||
onClick = {
|
||||
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val folders = viewModel.visibleFolders()
|
||||
val tracks = viewModel.visibleTracks()
|
||||
|
||||
if (isAndroid && !permissionState.isGranted) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text("Allow audio permission to show local tracks")
|
||||
MillenniumButton(
|
||||
onClick = permissionState.requestPermission,
|
||||
style = MillenniumButtonStyle.Quiet
|
||||
) {
|
||||
Text("Grant access")
|
||||
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder)
|
||||
when {
|
||||
!state.canAccessMedia -> MediaEmptyState(
|
||||
Iconsax.IconsaxFolderOpen,
|
||||
stringResource(Res.string.library_local_permission_title),
|
||||
Modifier.padding(bottom = inset),
|
||||
stringResource(Res.string.library_local_permission_description),
|
||||
action = {
|
||||
MillenniumButton(onClick = onRequestPermission) {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.library_local_grant_access
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
})
|
||||
|
||||
state.isRefreshing && state.visibleFolders.isEmpty() && state.visibleTracks.isEmpty() -> Box(
|
||||
Modifier.fillMaxSize().padding(bottom = inset),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MillenniumIndeterminateProgress()
|
||||
}
|
||||
} else if (state.currentFolderPath == null) {
|
||||
if (folders.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(state.error ?: "No local folders found")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(contentPadding = contentPadding) {
|
||||
items(folders, key = { it.path }) { folder ->
|
||||
MillenniumListTile(
|
||||
title = { Text(folder.name) },
|
||||
subtitle = { Text("${folder.trackCount} track(s)") },
|
||||
leading = {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxFolderOpen,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.openFolder(folder.path) })
|
||||
}
|
||||
|
||||
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
|
||||
Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.visibleFolders, key = { it.path }) { folder ->
|
||||
LocalFolderRow(folder) { onOpenFolder(folder.path) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (tracks.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No tracks in this folder")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) {
|
||||
items(tracks, key = { it.path }) { track ->
|
||||
MillenniumListTile(
|
||||
title = { Text(track.name) },
|
||||
subtitle = {
|
||||
val artists =
|
||||
track.artists.joinToString().ifBlank { "Unknown artist" }
|
||||
val album = track.album?.takeIf { it.isNotBlank() }
|
||||
Text(if (album == null) artists else "$artists - $album")
|
||||
},
|
||||
leading = {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxMusicCircle,
|
||||
contentDescription = null
|
||||
|
||||
state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
|
||||
Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.visibleTracks, key = { it.path }) { track ->
|
||||
PlayableListTile(
|
||||
track.title,
|
||||
track.subtitle ?: stringResource(Res.string.media_unknown_artist),
|
||||
imageModel = track.artwork,
|
||||
onClick = { onPlayTrack(track.path) },
|
||||
onPlay = { onPlayTrack(track.path) },
|
||||
trailingContent = {
|
||||
track.durationLabel?.let {
|
||||
Text(
|
||||
it,
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.Play,
|
||||
contentDescription = "Play",
|
||||
onClick = { viewModel.playTrack(track.path) },
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.playTrack(track.path) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
else -> MediaEmptyState(
|
||||
Iconsax.IconsaxFolderOpen,
|
||||
state.error ?: stringResource(
|
||||
when {
|
||||
state.query.isNotBlank() -> Res.string.library_local_no_results
|
||||
state.currentFolderPath == null -> Res.string.library_local_empty_folders
|
||||
else -> Res.string.library_local_empty_tracks
|
||||
}
|
||||
), Modifier.padding(bottom = inset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LocalTracksBreadcrumb(
|
||||
state: LibraryLocalTracksState,
|
||||
onNavigateUp: () -> Unit,
|
||||
onPlayFolder: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backDescription = stringResource(Res.string.library_local_back)
|
||||
Row(
|
||||
modifier.fillMaxWidth().height(32.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxFolderOpen,
|
||||
null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
if (state.currentFolderPath != null) {
|
||||
MillenniumButton(
|
||||
onClick = onNavigateUp,
|
||||
style = MillenniumButtonStyle.Quiet,
|
||||
modifier = Modifier.height(28.dp)
|
||||
.semantics { contentDescription = backDescription },
|
||||
padding = PaddingValues(0.dp),
|
||||
minSize = DpSize(0.dp, 28.dp),
|
||||
foreground = AppTheme.colors.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.library_local_folders),
|
||||
style = AppTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronRight,
|
||||
null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
state.currentFolder?.name ?: stringResource(Res.string.library_local_folder),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else Text(
|
||||
stringResource(Res.string.library_local_folders),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (state.currentFolderPath != null) pluralStringResource(
|
||||
Res.plurals.library_local_tracks_count,
|
||||
state.visibleTracks.size,
|
||||
state.visibleTracks.size
|
||||
)
|
||||
else stringResource(
|
||||
Res.string.library_local_summary,
|
||||
pluralStringResource(
|
||||
Res.plurals.library_local_folders_count,
|
||||
state.totalFolderCount,
|
||||
state.totalFolderCount
|
||||
),
|
||||
pluralStringResource(
|
||||
Res.plurals.library_local_tracks_count,
|
||||
state.totalTrackCount,
|
||||
state.totalTrackCount
|
||||
)
|
||||
),
|
||||
style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp))
|
||||
if (state.currentFolderPath != null) MillenniumIconButton(
|
||||
onClick = onPlayFolder, enabled = state.canPlayFolder,
|
||||
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxPlay,
|
||||
stringResource(Res.string.library_local_play_folder),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) {
|
||||
MillenniumListTile(
|
||||
onClick = onClick,
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp,
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
minimumHeight = 76.dp,
|
||||
leading = {
|
||||
MediaArtwork(
|
||||
folder.artwork,
|
||||
folder.name,
|
||||
Modifier.size(52.dp),
|
||||
placeholderIcon = Iconsax.IconsaxFolderOpen
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
folder.name,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
Res.plurals.library_local_tracks_count,
|
||||
folder.trackCount,
|
||||
folder.trackCount
|
||||
), style = AppTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronRight,
|
||||
null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -33,25 +33,103 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.library_local_refresh_failed
|
||||
|
||||
data class LocalFolderUi(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val trackCount: Int,
|
||||
val artwork: ByteArray?
|
||||
)
|
||||
|
||||
data class LocalTrackUi(
|
||||
val path: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val durationLabel: String?,
|
||||
val artwork: ByteArray?
|
||||
)
|
||||
|
||||
data class LibraryLocalTracksState(
|
||||
val isRefreshing: Boolean = false,
|
||||
val folders: List<LocalMediaFolder> = emptyList(),
|
||||
val currentFolderPath: String? = null,
|
||||
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(
|
||||
private val coordinator: LocalMediaLibraryCoordinator,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val libraryState: LibraryState
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LibraryLocalTracksState())
|
||||
private val requiresPermission = getPlatform().type == PlatformType.Android
|
||||
private val _state =
|
||||
MutableStateFlow(LibraryLocalTracksState(canAccessMedia = !requiresPermission))
|
||||
val state: StateFlow<LibraryLocalTracksState> = combine(
|
||||
_state, libraryState.queryFlow(LibraryTab.LocalTracks)
|
||||
) { state, query -> state.copy(query = query) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState())
|
||||
) { state, query -> localTracksUiState(state, query) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _state.value)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@ -85,14 +163,28 @@ class LibraryLocalTracksViewModel(
|
||||
_state.update { it.copy(currentFolderPath = null) }
|
||||
}
|
||||
|
||||
fun onPermissionChanged(granted: Boolean) {
|
||||
val accessible = !requiresPermission || granted
|
||||
val wasAccessible = _state.value.canAccessMedia
|
||||
_state.update { it.copy(canAccessMedia = accessible) }
|
||||
if (accessible && !wasAccessible) refresh()
|
||||
}
|
||||
|
||||
fun playCurrentFolder() {
|
||||
_state.value.currentFolderPath?.let(::playFolder)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (!_state.value.canAccessMedia) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isRefreshing = true, error = null) }
|
||||
runCatching {
|
||||
coordinator.refreshNow("manual")
|
||||
}.onFailure { throwable ->
|
||||
val message =
|
||||
throwable.message ?: getString(Res.string.library_local_refresh_failed)
|
||||
_state.update {
|
||||
it.copy(error = throwable.message ?: "Failed to refresh local media")
|
||||
it.copy(error = message)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(isRefreshing = false) }
|
||||
@ -100,6 +192,7 @@ class LibraryLocalTracksViewModel(
|
||||
}
|
||||
|
||||
fun playFolder(folderPath: String) {
|
||||
if (!_state.value.canAccessMedia) return
|
||||
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
|
||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||
if (tracks.isEmpty()) return
|
||||
@ -115,6 +208,7 @@ class LibraryLocalTracksViewModel(
|
||||
}
|
||||
|
||||
fun playTrack(trackPath: String) {
|
||||
if (!_state.value.canAccessMedia) return
|
||||
val folder = currentFolder() ?: return
|
||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||
val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
|
||||
@ -130,28 +224,7 @@ class LibraryLocalTracksViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun visibleFolders(): List<LocalMediaFolder> {
|
||||
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 currentFolder(): LocalMediaFolder? = state.value.currentFolder
|
||||
|
||||
private fun refreshIfEmpty() {
|
||||
if (_state.value.folders.isNotEmpty()) return
|
||||
@ -159,6 +232,7 @@ class LibraryLocalTracksViewModel(
|
||||
}
|
||||
|
||||
private fun refreshIfStale() {
|
||||
if (!_state.value.canAccessMedia) return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
coordinator.refreshIfStale()
|
||||
@ -170,11 +244,7 @@ class LibraryLocalTracksViewModel(
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isBlank()) return tracks
|
||||
|
||||
return tracks.filter { track ->
|
||||
track.name.contains(normalizedQuery, ignoreCase = true) ||
|
||||
track.album?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
track.artists.any { it.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
return tracks.filter { localTrackMatches(it, normalizedQuery) }
|
||||
}
|
||||
|
||||
private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack {
|
||||
|
||||
@ -1,180 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.library.playlist
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
|
||||
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.modules.library.LibraryMediaCollection
|
||||
import dev.krtirtho.spotube.modules.library.LibraryViewMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun LibraryPlaylistsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
showCreatePlaylist: Boolean = false,
|
||||
onDismissCreatePlaylist: () -> Unit = {},
|
||||
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
Pair(lastVisibleIndex, totalItems)
|
||||
Column(modifier) {
|
||||
when (val current = state) {
|
||||
is LibraryPlaylistsState.Error -> Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ErrorDisplay(errorMessage = current.message, onRetry = viewModel::refresh)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (lastVisibleIndex, totalItems) ->
|
||||
if (
|
||||
lastVisibleIndex != null &&
|
||||
totalItems > 0 &&
|
||||
lastVisibleIndex >= totalItems - 4
|
||||
) {
|
||||
viewModel.loadMoreData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
when (state) {
|
||||
is LibraryPlaylistsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(8) {
|
||||
SkeletonTree(true) {
|
||||
PlayableCard(
|
||||
title = "Playlist Name",
|
||||
subtitle = "By Artist",
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
else -> {
|
||||
val data = current as? LibraryPlaylistsState.Data
|
||||
if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (data.query.isNotBlank()) stringResource(
|
||||
Res.string.library_playlists_no_results,
|
||||
data.query
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryPlaylistsState.Error -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
ErrorDisplay(
|
||||
errorMessage = (state as LibraryPlaylistsState.Error).message,
|
||||
onRetry = { viewModel.refresh() })
|
||||
}
|
||||
}
|
||||
|
||||
is LibraryPlaylistsState.Data -> {
|
||||
val dataState = state as LibraryPlaylistsState.Data
|
||||
if (dataState.items.isEmpty() && state !is LibraryPlaylistsState.Data.LoadingMore) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
dataState.query.isNotEmpty() -> Text("No playlists found for '${dataState.query}'")
|
||||
else -> Text("No saved playlists")
|
||||
}
|
||||
else stringResource(Res.string.library_playlists_empty)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
LibraryMediaCollection<MetadataPlaylist>(
|
||||
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = current is LibraryPlaylistsState.Loading,
|
||||
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
|
||||
onViewportChanged = viewModel::onItemsVisible,
|
||||
header = { list ->
|
||||
LikedTracksCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
}
|
||||
|
||||
items(dataState.items, key = { it.id }) { playlist ->
|
||||
},
|
||||
itemContent = { item, list ->
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
PlayableCard(
|
||||
stringResource(Res.string.media_preview_title),
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
}
|
||||
|
||||
if (state is LibraryPlaylistsState.Data.LoadingMore) {
|
||||
items(4) {
|
||||
SkeletonTree(true) {
|
||||
PlayableCard(
|
||||
title = "Playlist Name",
|
||||
subtitle = "By Artist",
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistFormSheet(
|
||||
visible = showCreatePlaylist,
|
||||
onDismiss = onDismissCreatePlaylist,
|
||||
onSubmit = { data ->
|
||||
viewModel.createPlaylist(
|
||||
name = data.name,
|
||||
description = data.description.ifBlank { null },
|
||||
isPublic = data.isPublic,
|
||||
isCollaborating = data.isCollaborating,
|
||||
imageBase64 = data.imageBase64,
|
||||
trackIds = emptyList()
|
||||
)
|
||||
})
|
||||
onSubmit = viewModel::createPlaylist
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,15 +51,14 @@ sealed interface LibraryPlaylistsState {
|
||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataPlaylist>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataPlaylist> = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
@ -73,15 +72,14 @@ sealed interface LibraryPlaylistsState {
|
||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataPlaylist>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
override val items: List<MetadataPlaylist> = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,6 +150,17 @@ class LibraryPlaylistsViewModel(
|
||||
libraryState.setQuery(LibraryTab.Playlists, query)
|
||||
}
|
||||
|
||||
fun onItemsVisible(lastVisibleIndex: Int?, totalItems: Int) {
|
||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 4) {
|
||||
viewModelScope.launch { loadMoreData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylist(data: PlaylistFormData) = createPlaylist(
|
||||
data.name, data.description.ifBlank { null }, data.isPublic,
|
||||
data.isCollaborating, data.imageBase64, emptyList()
|
||||
)
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
repository.invalidateCaches()
|
||||
|
||||
@ -74,7 +74,8 @@ data class PlaylistFormData(
|
||||
val isPublic: Boolean = true,
|
||||
val isCollaborating: Boolean = false,
|
||||
val imageBase64: String = "",
|
||||
val imagePreviewUrl: String? = null)
|
||||
val imagePreviewUrl: String? = null
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlaylistFormSheet(
|
||||
@ -83,7 +84,8 @@ fun PlaylistFormSheet(
|
||||
onSubmit: (PlaylistFormData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialData: PlaylistFormData? = null,
|
||||
isEditing: Boolean = false) {
|
||||
isEditing: Boolean = false
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
@ -91,7 +93,11 @@ fun PlaylistFormSheet(
|
||||
var name by remember(initialData) { mutableStateOf(initialData?.name ?: "") }
|
||||
var description by remember(initialData) { mutableStateOf(initialData?.description ?: "") }
|
||||
var isPublic by remember(initialData) { mutableStateOf(initialData?.isPublic ?: true) }
|
||||
var isCollaborating by remember(initialData) { mutableStateOf(initialData?.isCollaborating ?: false) }
|
||||
var isCollaborating by remember(initialData) {
|
||||
mutableStateOf(
|
||||
initialData?.isCollaborating ?: false
|
||||
)
|
||||
}
|
||||
var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") }
|
||||
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) }
|
||||
|
||||
@ -131,7 +137,8 @@ fun PlaylistFormSheet(
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl)
|
||||
imagePreviewUrl = imagePreviewUrl
|
||||
)
|
||||
|
||||
val handleImagePick = { imagePicker.launch() }
|
||||
|
||||
@ -140,7 +147,8 @@ fun PlaylistFormSheet(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = if (isEditing) "Edit Playlist" else "Create Playlist",
|
||||
modifier = modifier) {
|
||||
modifier = modifier
|
||||
) {
|
||||
PlaylistFormFields(
|
||||
nameState = nameState,
|
||||
descriptionState = descriptionState,
|
||||
@ -150,19 +158,22 @@ fun PlaylistFormSheet(
|
||||
onIsCollaboratingChange = { isCollaborating = it },
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl,
|
||||
onPickImage = handleImagePick)
|
||||
onPickImage = handleImagePick
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
onSubmit(currentData)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = name.isNotBlank()) {
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text(if (isEditing) "Save" else "Create")
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
@ -173,11 +184,13 @@ fun PlaylistFormSheet(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isEditing) "Edit Playlist" else "Create Playlist",
|
||||
style = AppTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp))
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
PlaylistFormFields(
|
||||
nameState = nameState,
|
||||
descriptionState = descriptionState,
|
||||
@ -187,14 +200,17 @@ fun PlaylistFormSheet(
|
||||
onIsCollaboratingChange = { isCollaborating = it },
|
||||
imageBase64 = imageBase64,
|
||||
imagePreviewUrl = imagePreviewUrl,
|
||||
onPickImage = handleImagePick)
|
||||
onPickImage = handleImagePick
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
MillenniumButton(
|
||||
@ -202,7 +218,8 @@ fun PlaylistFormSheet(
|
||||
onSubmit(currentData)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = name.isNotBlank()) {
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text(if (isEditing) "Save" else "Create")
|
||||
}
|
||||
}
|
||||
@ -222,23 +239,27 @@ private fun PlaylistFormFields(
|
||||
onIsCollaboratingChange: (Boolean) -> Unit,
|
||||
imageBase64: String,
|
||||
imagePreviewUrl: String?,
|
||||
onPickImage: () -> Unit) {
|
||||
onPickImage: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
MillenniumTextField(
|
||||
state = nameState,
|
||||
label = "Name",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Playlist name")
|
||||
placeholder = "Playlist name"
|
||||
)
|
||||
|
||||
MillenniumTextField(
|
||||
state = descriptionState,
|
||||
label = "Description",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Description (optional)")
|
||||
placeholder = "Description (optional)"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -253,7 +274,8 @@ private fun PlaylistFormFields(
|
||||
Text(
|
||||
"Anyone can see this playlist",
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange)
|
||||
}
|
||||
@ -271,40 +293,49 @@ private fun PlaylistFormFields(
|
||||
Text(
|
||||
"Others can add tracks to this playlist",
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text("Cover Image", style = AppTheme.typography.bodyLarge)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center) {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
imagePreviewUrl != null -> AsyncImage(
|
||||
model = imagePreviewUrl,
|
||||
contentDescription = "Playlist cover",
|
||||
modifier = Modifier.size(64.dp),
|
||||
contentScale = ContentScale.Crop)
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
imageBase64.isNotBlank() -> {
|
||||
Text(
|
||||
"✓",
|
||||
style = AppTheme.typography.titleMedium,
|
||||
color = AppTheme.colors.primary)
|
||||
color = AppTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
"No image",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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('/', '\\')
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,61 +18,42 @@
|
||||
package dev.krtirtho.spotube.modules.settings.sections
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumDialog
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState
|
||||
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolder
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVerticalSetting
|
||||
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
|
||||
import io.github.vinceglb.filekit.path
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.settings_action_cancel
|
||||
import spotube.composeapp.generated.resources.settings_action_save
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_current
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_default
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_title
|
||||
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current
|
||||
import spotube.composeapp.generated.resources.settings_download_format_title
|
||||
import spotube.composeapp.generated.resources.settings_download_quality_title
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_add_action
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_description
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_manage
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_none_added
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_remove_action
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_current
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_subtitle_empty
|
||||
import spotube.composeapp.generated.resources.settings_local_media_folders_title
|
||||
@ -82,11 +63,13 @@ import spotube.composeapp.generated.resources.settings_subtitle_current
|
||||
internal fun LazyListScope.downloadsSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true) {
|
||||
includeHeader: Boolean = true
|
||||
) {
|
||||
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
|
||||
val downloadQualities = availableAudioQualities(
|
||||
format = settings.downloadMusicFormat,
|
||||
current = settings.downloadMusicQuality)
|
||||
current = settings.downloadMusicQuality
|
||||
)
|
||||
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_downloads)
|
||||
@ -104,14 +87,7 @@ internal fun LazyListScope.downloadsSection(
|
||||
)
|
||||
},
|
||||
{
|
||||
LocalMediaFoldersSettingCard(
|
||||
folders = settings.localMediaFolders,
|
||||
onFoldersSaved = { folders ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(localMediaFolders = folders)
|
||||
}
|
||||
}
|
||||
)
|
||||
LocalMediaFoldersSettingCard()
|
||||
},
|
||||
{
|
||||
SelectionSettingCard(
|
||||
@ -121,7 +97,10 @@ internal fun LazyListScope.downloadsSection(
|
||||
settings.downloadMusicFormat.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_download_format_title))
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMusicPlay,
|
||||
stringResource(Res.string.settings_download_format_title)
|
||||
)
|
||||
},
|
||||
selectedOption = settings.downloadMusicFormat,
|
||||
options = downloadFormats,
|
||||
@ -130,7 +109,8 @@ internal fun LazyListScope.downloadsSection(
|
||||
settingsViewModel.updateSettings {
|
||||
copy(
|
||||
downloadMusicFormat = format,
|
||||
downloadMusicQuality = format.resolveQuality(downloadMusicQuality))
|
||||
downloadMusicQuality = format.resolveQuality(downloadMusicQuality)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -143,7 +123,10 @@ internal fun LazyListScope.downloadsSection(
|
||||
settings.downloadMusicQuality.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_download_quality_title))
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxVerticalSetting,
|
||||
stringResource(Res.string.settings_download_quality_title)
|
||||
)
|
||||
},
|
||||
selectedOption = settings.downloadMusicQuality,
|
||||
options = downloadQualities,
|
||||
@ -161,7 +144,8 @@ internal fun LazyListScope.downloadsSection(
|
||||
@Composable
|
||||
private fun DownloadFolderSettingCard(
|
||||
folder: String?,
|
||||
onFolderSelected: (String?) -> Unit) {
|
||||
onFolderSelected: (String?) -> Unit
|
||||
) {
|
||||
val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
|
||||
if (directory != null) {
|
||||
val normalized = normalizePath(directory.path)
|
||||
@ -177,14 +161,18 @@ private fun DownloadFolderSettingCard(
|
||||
stringResource(Res.string.settings_download_folder_current, it)
|
||||
} ?: stringResource(Res.string.settings_download_folder_default),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title))
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
stringResource(Res.string.settings_download_folder_title)
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxFolderAdd,
|
||||
contentDescription = stringResource(Res.string.settings_download_folder_title),
|
||||
onClick = { pickerLauncher.launch() },
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
pickerLauncher.launch()
|
||||
@ -194,17 +182,17 @@ private fun DownloadFolderSettingCard(
|
||||
|
||||
@Composable
|
||||
private fun LocalMediaFoldersSettingCard(
|
||||
folders: List<String>,
|
||||
onFoldersSaved: (List<String>) -> Unit) {
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val subtitlePreview = when {
|
||||
folders.isEmpty() -> stringResource(Res.string.settings_local_media_folders_subtitle_empty)
|
||||
folders.size == 1 -> folders.first()
|
||||
else -> stringResource(
|
||||
Res.string.settings_local_media_folders_subtitle_current,
|
||||
folders.size)
|
||||
}
|
||||
viewModel: LocalMediaFoldersViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val pickerLauncher =
|
||||
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
|
||||
val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) {
|
||||
stringResource(Res.string.settings_local_media_folders_subtitle_empty)
|
||||
} else stringResource(
|
||||
Res.string.settings_local_media_folders_subtitle_current,
|
||||
state.configuredCount
|
||||
)
|
||||
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_local_media_folders_title),
|
||||
@ -212,117 +200,44 @@ private fun LocalMediaFoldersSettingCard(
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxFolder,
|
||||
stringResource(Res.string.settings_local_media_folders_title))
|
||||
stringResource(Res.string.settings_local_media_folders_title)
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
MillenniumButton(
|
||||
onClick = { isDialogOpen = true },
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
onClick = viewModel::open,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_local_media_folders_manage))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
isDialogOpen = true
|
||||
}
|
||||
onClick = viewModel::open
|
||||
)
|
||||
|
||||
if (isDialogOpen) {
|
||||
if (state.isOpen) {
|
||||
LocalMediaFoldersDialog(
|
||||
folders = folders,
|
||||
onDismiss = { isDialogOpen = false },
|
||||
onFoldersSaved = onFoldersSaved)
|
||||
state = state,
|
||||
onAddFolder = { pickerLauncher.launch() },
|
||||
onRemoveFolder = viewModel::removeFolder,
|
||||
onSave = viewModel::save,
|
||||
onDismiss = viewModel::dismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalMediaFoldersDialog(
|
||||
folders: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onFoldersSaved: (List<String>) -> Unit) {
|
||||
val draftFolders = remember(folders) {
|
||||
mutableStateListOf<String>().apply { addAll(folders) }
|
||||
}
|
||||
val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
|
||||
if (directory != null) {
|
||||
val normalized = normalizePath(directory.path)
|
||||
if (normalized.isNotEmpty() && draftFolders.none { normalizePath(it) == normalized }) {
|
||||
draftFolders.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state: LocalMediaFoldersUiState,
|
||||
onAddFolder: () -> Unit,
|
||||
onRemoveFolder: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(Res.string.settings_local_media_folders_title)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_local_media_folders_description),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
MillenniumButton(
|
||||
onClick = { pickerLauncher.launch() },
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIcon(Iconsax.IconsaxAddSquare, contentDescription = null)
|
||||
Text(stringResource(Res.string.settings_local_media_folders_add_action))
|
||||
}
|
||||
}
|
||||
|
||||
if (draftFolders.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_local_media_folders_none_added),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
draftFolders.forEachIndexed { index, folder ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = folder,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = AppTheme.typography.bodySmall)
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
if (index in draftFolders.indices) {
|
||||
draftFolders.removeAt(index)
|
||||
}
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = stringResource(
|
||||
Res.string.settings_local_media_folders_remove_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(Res.string.settings_action_save))
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(stringResource(Res.string.settings_action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
title = stringResource(Res.string.settings_local_media_folders_title)
|
||||
) {
|
||||
LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertNull
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryToolbarTest {
|
||||
@ -109,6 +110,29 @@ class LibraryToolbarTest {
|
||||
assertEquals("Radiohead", library.uiState.value.searchState.text.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewModeDefaultsAndOverridesArePerTab() = runTest {
|
||||
assertEquals(LibraryViewMode.List, defaultLibraryViewMode(true))
|
||||
assertEquals(LibraryViewMode.Grid, defaultLibraryViewMode(false))
|
||||
library.toggleViewMode(isCompact = true)
|
||||
runCurrent()
|
||||
assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride)
|
||||
library.onTabSelected(LibraryTab.Albums)
|
||||
runCurrent()
|
||||
assertNull(library.uiState.value.viewModeOverride)
|
||||
library.toggleViewMode(isCompact = false)
|
||||
runCurrent()
|
||||
assertEquals(LibraryViewMode.List, library.uiState.value.viewModeOverride)
|
||||
library.onTabSelected(LibraryTab.Playlists)
|
||||
runCurrent()
|
||||
assertEquals(LibraryViewMode.Grid, library.uiState.value.viewModeOverride)
|
||||
library.onTabSelected(LibraryTab.LocalTracks)
|
||||
library.toggleViewMode(isCompact = true)
|
||||
runCurrent()
|
||||
assertFalse(library.uiState.value.canChangeView)
|
||||
assertNull(library.uiState.value.viewModeOverride)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Test
|
||||
fun renderExpandedCompactAndSearchToolbars() {
|
||||
@ -128,14 +152,29 @@ class LibraryToolbarTest {
|
||||
searchMode = searching,
|
||||
searchState = TextFieldState(if (searching) "Muse" else ""),
|
||||
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||
canRefresh = tab == LibraryTab.LocalTracks
|
||||
canRefresh = tab == LibraryTab.LocalTracks,
|
||||
canChangeView = tab != LibraryTab.LocalTracks
|
||||
)
|
||||
ImageComposeScene(width, 88) {
|
||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||
MillenniumTopAppBar(
|
||||
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||
title = {
|
||||
LibraryToolbarTitle(
|
||||
state,
|
||||
expandedDesktop,
|
||||
mode.startsWith("compact-desktop")
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {})
|
||||
LibraryToolbarActions(
|
||||
state,
|
||||
expandedDesktop,
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
compactDesktop = mode.startsWith("compact-desktop")
|
||||
)
|
||||
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
|
||||
Modifier.width(132.dp)
|
||||
)
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user