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