diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 373fc2e4..6de1648b 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -27,6 +27,7 @@
Your Library
+ Library
Playlists
Albums
Artists
@@ -40,6 +41,59 @@
Close filter
Create playlist
Refresh local tracks
+ Switch to grid view
+ Switch to list view
+ No saved playlists
+ No playlists match “%1$s”
+ No saved albums
+ No albums match “%1$s”
+ No saved artists
+ No artists match “%1$s”
+
+
+ Folders
+ Folder
+ %1$s • %2$s
+
+ - %1$d track
+ - %1$d tracks
+
+
+ - %1$d folder
+ - %1$d folders
+
+ Back to folders
+ Play folder
+ Audio access required
+ Allow audio access to browse music on your device.
+ Grant access
+ No local folders found
+ No tracks in this folder
+ No matching folders or tracks
+ Failed to refresh local media
+
+
+ %1$d active • %2$d completed
+ Clear completed
+ No downloads yet
+ Downloading %1$d%
+ Queued
+ Completed
+ Cancelled
+ Failed
+ Failed: %1$s
+ Cancel download
+ Retry download
+ Remove download
+
+
+ Artist
+ Blacklisted
+ Liked Tracks
+ Your saved songs
+ Unknown artist
+ Track title
+ Artist name
Selected
@@ -122,11 +176,21 @@
Local media folders
Custom folders to scan on desktop. Added: %1$d
No custom folder added yet
- Use the picker to add folders. Download folder override is included automatically.
+ Custom folders for your local music library.
Manage
Add folder
Remove folder
No custom folders added.
+ Remove %1$s from the scan list
+ Download and music cache folders are included automatically.
+ Your download folder is included automatically.
+ The music cache folder is included automatically.
+ Saving…
+ Couldn’t save folders. Your changes are still here; try again.
+
+ - %1$d folder
+ - %1$d folders
+
Download music format
Preferred codec and container for downloads. Current: %1$s
Download music quality
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
index a8251a64..769c08ff 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
@@ -82,6 +82,7 @@ import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
+import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
@@ -142,6 +143,13 @@ val sharedModules = module {
singleOf(::SettingsRepository) { bind() }
viewModelOf(::SettingsViewModel) { bind() }
viewModelOf(::JamSettingsViewModel)
+ viewModel {
+ val repository = get()
+ LocalMediaFoldersViewModel(repository.userSettings) { folders ->
+ val current = repository.getCurrentSettings()
+ repository.updateSettings(current.copy(localMediaFolders = folders))
+ }
+ }
// Welcome / Onboarding
singleOf(::WelcomeRepository)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
index 23599f4e..df7145a9 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt
@@ -40,12 +40,15 @@ fun AlbumCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(),
- remotePlaybackController: RemotePlaybackController = koinInject()) {
+ remotePlaybackController: RemotePlaybackController = koinInject(),
+ listMode: Boolean = false
+) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard(
title = album.title,
- subtitle = album.description ?: "${album.albumType} • ${album.artists.joinToString { it.name }}",
+ subtitle = album.description
+ ?: "${album.albumType} • ${album.artists.joinToString { it.name }}",
imageURL = album.thumbnails.firstOrNull()?.url,
isPlaying = currentCollectionEntry?.id == album.id,
onClick = {
@@ -56,14 +59,18 @@ fun AlbumCard(
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album,
album.id,
- album.title)
+ album.title
+ )
},
onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album,
album.id,
- album.title)
+ album.title
+ )
},
- modifier = modifier.width(160.dp),
- sharedElementKey = "album_art_${album.id}")
+ modifier = if (listMode) modifier else modifier.width(160.dp),
+ sharedElementKey = "album_art_${album.id}",
+ listMode = listMode
+ )
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt
index 541facd1..0c9d065c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt
@@ -17,52 +17,34 @@
package dev.krtirtho.spotube.core.ui.component
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-
-import dev.krtirtho.spotube.core.ui.theming.AppTheme
-import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import org.koin.compose.koinInject
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.*
@Composable
fun ArtistCard(
artist: MetadataArtist.Basic,
modifier: Modifier = Modifier,
- isBlacklisted: Boolean = false) {
+ isBlacklisted: Boolean = false,
+ listMode: Boolean = false
+) {
val navigationCommands = koinInject()
AvatarCard(
title = artist.name,
- subtitle = if (isBlacklisted) "Blacklisted" else "Artist",
+ subtitle = stringResource(if (isBlacklisted) Res.string.media_blacklisted else Res.string.media_artist),
imageURL = artist.thumbnails.firstOrNull()?.url,
onClick = {
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
modifier = modifier,
- isBlacklisted = isBlacklisted)
+ isBlacklisted = isBlacklisted,
+ listMode = listMode
+ )
}
-
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
index ac3444e8..456de393 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt
@@ -21,14 +21,7 @@ package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.hoverable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -44,7 +37,6 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -58,19 +50,21 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
-import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
-import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
-import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
-import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle
import dev.krtirtho.spotube.resources.iconsax.Play
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject
-import spotube.composeapp.generated.resources.Res
-import spotube.composeapp.generated.resources.liked_tracks
+import spotube.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.stringResource
+import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile
+import dev.krtirtho.spotube.core.ui.component.cards.MediaCardSurface
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.platform.LocalInputModeManager
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@@ -78,27 +72,49 @@ fun LikedTracksCard(
modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
- navigationCommands: NavigationCommands = koinInject()) {
+ navigationCommands: NavigationCommands = koinInject(),
+ listMode: Boolean = false
+) {
val scope = rememberCoroutineScope()
- val interactionSource = remember { MutableInteractionSource() }
- val isHovered by interactionSource.collectIsHoveredAsState()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
+ val title = stringResource(Res.string.media_liked_tracks)
+ val subtitle = stringResource(Res.string.media_saved_songs)
+ if (listMode) {
+ PlayableListTile(
+ title = title,
+ subtitle = subtitle,
+ isPlaying = isPlaying,
+ onClick = { navigationCommands.navigateTo(Routes.SavedTracks) },
+ onPlay = { scope.launch { playbackHelper.playSavedTracks() } },
+ onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
+ modifier = modifier,
+ sharedElementKey = "saved_tracks_art",
+ artwork = {
+ Image(
+ painterResource(Res.drawable.liked_tracks),
+ title,
+ Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ }
+ )
+ return
+ }
+ val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
- Box(
- modifier = modifier
- .clip(RoundedCornerShape(8.dp))
- .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
- .width(160.dp)
- .hoverable(interactionSource = interactionSource)) {
+ MediaCardSurface(
+ modifier = modifier.width(160.dp),
+ onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }
+ ) { isHovered ->
Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth()) {
androidx.compose.foundation.Image(
painter = painterResource(Res.drawable.liked_tracks),
- contentDescription = "Liked Tracks",
+ contentDescription = title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
@@ -106,14 +122,16 @@ fun LikedTracksCard(
.sharedElementOrNone(
key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope,
- animatedVisibilityScope = animatedVisibilityScope),
- contentScale = ContentScale.Crop)
+ animatedVisibilityScope = animatedVisibilityScope
+ ),
+ contentScale = ContentScale.Crop
+ )
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd
) {
- if (isHovered) {
+ if (isHovered || touchInput || isPlaying) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp)
@@ -125,7 +143,7 @@ fun LikedTracksCard(
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare,
- contentDescription = "Add to queue",
+ contentDescription = stringResource(Res.string.player_add_to_queue_content_description),
modifier = Modifier.size(16.dp)
)
}
@@ -139,7 +157,7 @@ fun LikedTracksCard(
imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle
} else Iconsax.Play,
- contentDescription = "Play",
+ contentDescription = stringResource(Res.string.player_play_content_description),
modifier = Modifier.size(16.dp)
)
}
@@ -151,17 +169,19 @@ fun LikedTracksCard(
modifier = Modifier.fillMaxWidth().padding(12.dp)
) {
Text(
- text = "Liked Tracks",
+ text = title,
style = AppTheme.typography.titleSmall,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "Your saved songs",
+ text = subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
index ffacbb4d..48e82d51 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt
@@ -40,7 +40,9 @@ fun PlaylistCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(),
- remotePlaybackController: RemotePlaybackController = koinInject()) {
+ remotePlaybackController: RemotePlaybackController = koinInject(),
+ listMode: Boolean = false
+) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@@ -57,14 +59,18 @@ fun PlaylistCard(
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist,
playlist.id,
- playlist.title)
+ playlist.title
+ )
},
onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist,
playlist.id,
- playlist.title)
+ playlist.title
+ )
},
modifier = modifier,
- sharedElementKey = "playlist_art_${playlist.id}")
+ sharedElementKey = "playlist_art_${playlist.id}",
+ listMode = listMode
+ )
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt
index ff08d418..db311800 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt
@@ -17,79 +17,67 @@
package dev.krtirtho.spotube.core.ui.component.cards
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
-import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
-import dev.krtirtho.spotube.core.navigation.NavigationCommands
-import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
-import org.koin.compose.koinInject
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.User
@Composable
fun AvatarCard(
title: String,
subtitle: String? = null,
imageURL: String? = null,
- onClick: () -> Unit = {},
+ onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
- isBlacklisted: Boolean = false) {
- val backgroundColor = if (isBlacklisted) {
- AppTheme.colors.error.copy(alpha = 0.1f)
- } else {
- Color.Transparent
+ isBlacklisted: Boolean = false,
+ listMode: Boolean = false
+) {
+ if (listMode) {
+ AvatarListTile(title, subtitle, imageURL, onClick, modifier, isBlacklisted)
+ return
}
-
- Box(
- modifier = modifier
- .clip(RoundedCornerShape(8.dp))
- .background(backgroundColor)
- .clickable(onClick = { onClick.invoke() }, enabled = true)
- .width(160.dp)) {
+
+ MediaCardSurface(modifier.width(160.dp), onClick, isBlacklisted = isBlacklisted) {
Column(
modifier = Modifier
.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally) {
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape)
.padding(12.dp),
- contentAlignment = Alignment.Center) {
- AsyncImage(
+ contentAlignment = Alignment.Center
+ ) {
+ MediaArtwork(
model = imageURL,
- contentDescription = title,
+ title = title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(CircleShape)
.shimmerApply(),
- contentScale = ContentScale.Crop)
+ shape = CircleShape,
+ placeholderIcon = Iconsax.User
+ )
}
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp)
@@ -99,17 +87,20 @@ fun AvatarCard(
style = AppTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth())
+ modifier = Modifier.fillMaxWidth()
+ )
subtitle?.let {
+ Spacer(Modifier.height(4.dp))
TextWithShimmer(
text = subtitle,
- style = AppTheme.typography.bodyMedium,
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth())
+ modifier = Modifier.fillMaxWidth()
+ )
}
}
}
}
}
-
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt
new file mode 100644
index 00000000..b4095530
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarListTile.kt
@@ -0,0 +1,31 @@
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package dev.krtirtho.spotube.core.ui.component.cards
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.User
+
+@Composable
+fun AvatarListTile(
+ title: String,
+ subtitle: String? = null,
+ imageURL: String? = null,
+ onClick: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ isBlacklisted: Boolean = false
+) {
+ PlayableListTile(
+ title = title,
+ subtitle = subtitle,
+ imageURL = imageURL,
+ onClick = onClick,
+ modifier = modifier,
+ imageShape = CircleShape,
+ placeholderIcon = Iconsax.User,
+ isBlacklisted = isBlacklisted
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt
new file mode 100644
index 00000000..43809d5f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaCardSurface.kt
@@ -0,0 +1,84 @@
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package dev.krtirtho.spotube.core.ui.component.cards
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import org.spotube.millennium.theme.MillenniumTheme
+import org.spotube.millennium.theme.millenniumActionPointer
+import org.spotube.millennium.theme.millenniumSurface
+
+/** Interaction state is presentation-only; all navigation/playback stays in event callbacks. */
+@Composable
+internal fun MediaCardSurface(
+ modifier: Modifier,
+ onClick: (() -> Unit)?,
+ interactive: Boolean = onClick != null,
+ isBlacklisted: Boolean = false,
+ content: @Composable (highlighted: Boolean) -> Unit
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val hovered by interaction.collectIsHoveredAsState()
+ val pressed by interaction.collectIsPressedAsState()
+ val focused by interaction.collectIsFocusedAsState()
+ var groupFocused by remember { mutableStateOf(false) }
+ val colors = MillenniumTheme.colors
+ val highlight by animateFloatAsState(
+ if (hovered || focused || groupFocused || pressed) 1f else 0f,
+ tween(140)
+ )
+ val scale by animateFloatAsState(if (pressed) 0.985f else 1f, tween(100))
+ val shape = RoundedCornerShape(10.dp)
+ Box(
+ modifier.graphicsLayer { scaleX = scale; scaleY = scale }
+ .millenniumSurface(
+ background = Brush.verticalGradient(
+ listOf(
+ colors.raised.copy(alpha = highlight),
+ if (isBlacklisted) colors.danger.copy(alpha = 0.12f + highlight * 0.12f)
+ else colors.surface.copy(alpha = highlight)
+ )
+ ),
+ shape = shape,
+ borderColor = (if (focused) colors.accent else colors.border).copy(alpha = highlight),
+ borderWidth = if (focused) 2.dp else 1.dp,
+ innerGlowColor = colors.highlight.copy(alpha = highlight * 0.4f),
+ shadowElevation = (highlight * 4f).dp
+ )
+ .clip(shape)
+ .onFocusChanged { groupFocused = it.hasFocus }
+ .focusGroup()
+ .hoverable(interaction, enabled = interactive)
+ .then(
+ if (onClick != null) Modifier.millenniumActionPointer().clickable(
+ interactionSource = interaction,
+ indication = LocalIndication.current,
+ role = Role.Button,
+ onClick = onClick
+ ) else Modifier
+ )
+ ) { content(hovered || focused || groupFocused) }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt
new file mode 100644
index 00000000..7cd95441
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/MediaEmptyState.kt
@@ -0,0 +1,66 @@
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package dev.krtirtho.spotube.core.ui.component.cards
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.composeunstyled.Text
+import dev.krtirtho.spotube.core.ui.theming.AppTheme
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.theme.MillenniumTheme
+import org.spotube.millennium.theme.millenniumBevelBrush
+import org.spotube.millennium.theme.millenniumSurface
+
+@Composable
+internal fun MediaEmptyState(
+ icon: ImageVector,
+ title: String,
+ modifier: Modifier = Modifier,
+ description: String? = null,
+ action: (@Composable () -> Unit)? = null
+) {
+ Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
+ Column(
+ Modifier.widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ Modifier.size(64.dp).millenniumSurface(
+ background = millenniumBevelBrush(MillenniumTheme.colors),
+ shape = RoundedCornerShape(14.dp),
+ borderColor = MillenniumTheme.colors.border,
+ borderWidth = 1.dp
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ MillenniumIcon(
+ icon,
+ null,
+ modifier = Modifier.size(30.dp),
+ tint = AppTheme.colors.onSurfaceVariant
+ )
+ }
+ Text(title, style = AppTheme.typography.titleMedium, textAlign = TextAlign.Center)
+ if (description != null) Text(
+ description,
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ action?.invoke()
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
index a4674b27..05edce14 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt
@@ -25,10 +25,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.hoverable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -38,21 +34,18 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.InputMode
+import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
@@ -67,6 +60,8 @@ import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
import spotube.composeapp.generated.resources.player_pause_content_description
import spotube.composeapp.generated.resources.player_play_content_description
+import spotube.composeapp.generated.resources.media_preview_title
+import spotube.composeapp.generated.resources.media_preview_subtitle
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@@ -79,22 +74,37 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier,
- sharedElementKey: String? = null) {
- val interactionSource = remember { MutableInteractionSource() }
+ sharedElementKey: String? = null,
+ listMode: Boolean = false
+) {
+ if (listMode) {
+ PlayableListTile(
+ title,
+ subtitle,
+ imageURL,
+ isPlaying,
+ onClick,
+ onPlay,
+ onAddToQueue,
+ modifier,
+ sharedElementKey
+ )
+ return
+ }
+ val touchInput = LocalInputModeManager.current.inputMode == InputMode.Touch
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
- Box(
- modifier = modifier
- .clip(RoundedCornerShape(8.dp))
- .clickable(onClick = { onClick?.invoke() }, enabled = true)
- .width(160.dp)
- .hoverable(interactionSource = interactionSource)) {
+ MediaCardSurface(
+ modifier = modifier.width(160.dp),
+ onClick = onClick ?: onPlay,
+ interactive = onClick != null || onPlay != null || onAddToQueue != null
+ ) { highlighted ->
Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
- AsyncImage(
+ MediaArtwork(
model = imageURL,
- contentDescription = title,
+ title = title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
@@ -102,19 +112,21 @@ fun PlayableCard(
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
- animatedVisibilityScope = animatedVisibilityScope),
- contentScale = ContentScale.Crop)
+ animatedVisibilityScope = animatedVisibilityScope
+ ),
+ shape = RoundedCornerShape(8.dp)
+ )
if (onPlay != null || onAddToQueue != null)
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd
) {
- val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility(
- visible = isHovered || (isPlaying && onPlay != null),
+ visible = highlighted || touchInput || (isPlaying && onPlay != null),
enter = fadeIn(),
- exit = fadeOut()) {
+ exit = fadeOut()
+ ) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp)
@@ -130,7 +142,9 @@ fun PlayableCard(
MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(
- Res.string.player_add_to_queue_content_description))
+ Res.string.player_add_to_queue_content_description
+ )
+ )
}
Spacer(modifier = Modifier.height(8.dp))
}
@@ -150,7 +164,9 @@ fun PlayableCard(
Res.string.player_pause_content_description
} else {
Res.string.player_play_content_description
- }))
+ }
+ )
+ )
}
}
}
@@ -164,7 +180,8 @@ fun PlayableCard(
text = title,
style = AppTheme.typography.titleSmall,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
Spacer(modifier = Modifier.height(4.dp))
subtitle?.let {
TextWithShimmer(
@@ -172,7 +189,8 @@ fun PlayableCard(
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
}
}
}
@@ -185,8 +203,8 @@ fun PlayableCard(
private fun PlayableCardPreview() {
Box {
PlayableCard(
- title = "Sample Title",
- subtitle = "Sample Subtitle",
+ title = stringResource(Res.string.media_preview_title),
+ subtitle = stringResource(Res.string.media_preview_subtitle),
imageURL = "https://placehold.co/600x400",
isPlaying = true,
onClick = {},
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt
new file mode 100644
index 00000000..253f2155
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableListTile.kt
@@ -0,0 +1,175 @@
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package dev.krtirtho.spotube.core.ui.component.cards
+
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
+import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
+import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
+import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
+import dev.krtirtho.spotube.core.ui.misc.shimmerApply
+import dev.krtirtho.spotube.core.ui.theming.AppTheme
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
+import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
+import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
+import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
+import org.jetbrains.compose.resources.stringResource
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.composables.MillenniumIconButton
+import org.spotube.millennium.composables.MillenniumIconButtonStyle
+import org.spotube.millennium.composables.MillenniumListTile
+import org.spotube.millennium.theme.MillenniumTheme
+import org.spotube.millennium.theme.millenniumBevelBrush
+import org.spotube.millennium.theme.millenniumPanelBrush
+import org.spotube.millennium.theme.millenniumSurface
+import spotube.composeapp.generated.resources.*
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun PlayableListTile(
+ title: String,
+ subtitle: String? = null,
+ imageURL: String? = null,
+ isPlaying: Boolean = false,
+ onClick: (() -> Unit)? = null,
+ onPlay: (() -> Unit)? = null,
+ onAddToQueue: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ sharedElementKey: String? = null,
+ imageModel: Any? = imageURL,
+ imageShape: Shape = RoundedCornerShape(6.dp),
+ placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
+ artwork: (@Composable () -> Unit)? = null,
+ trailingContent: (@Composable () -> Unit)? = null,
+ isBlacklisted: Boolean = false
+) {
+ val colors = MillenniumTheme.colors
+ MillenniumListTile(
+ modifier = modifier,
+ onClick = onClick ?: onPlay,
+ selected = isPlaying,
+ background = if (isPlaying) null else millenniumPanelBrush(colors),
+ shape = RoundedCornerShape(8.dp),
+ borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border,
+ titleForeground = if (isBlacklisted) colors.danger else null,
+ borderWidth = 1.dp,
+ contentPadding = PaddingValues(12.dp),
+ minimumHeight = 76.dp,
+ leading = {
+ MediaArtwork(
+ imageModel, title,
+ Modifier.size(52.dp).sharedElementOrNone(
+ sharedElementKey,
+ LocalSharedTransitionScope.current,
+ LocalAnimatedVisibilityScope.current
+ ),
+ imageShape, placeholderIcon, artwork
+ )
+ },
+ title = {
+ TextWithShimmer(
+ title,
+ style = AppTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ subtitle = subtitle?.let { text ->
+ {
+ TextWithShimmer(
+ text,
+ style = AppTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ },
+ trailing = if (onPlay != null || onAddToQueue != null || trailingContent != null) {
+ {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ trailingContent?.invoke()
+ if (onAddToQueue != null && !isPlaying) {
+ MillenniumIconButton(
+ onClick = onAddToQueue,
+ modifier = Modifier.size(40.dp),
+ style = MillenniumIconButtonStyle.Secondary
+ ) {
+ MillenniumIcon(
+ Iconsax.IconsaxAddSquare,
+ stringResource(Res.string.player_add_to_queue_content_description)
+ )
+ }
+ }
+ if (onPlay != null) {
+ MillenniumIconButton(
+ onClick = onPlay,
+ modifier = Modifier.size(40.dp),
+ style = MillenniumIconButtonStyle.Primary
+ ) {
+ MillenniumIcon(
+ if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
+ stringResource(if (isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description)
+ )
+ }
+ }
+ }
+ }
+ } else null
+ )
+}
+
+@Composable
+internal fun MediaArtwork(
+ model: Any?,
+ title: String,
+ modifier: Modifier,
+ shape: Shape = RoundedCornerShape(6.dp),
+ placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
+ artwork: (@Composable () -> Unit)? = null
+) {
+ Box(
+ modifier.millenniumSurface(
+ background = millenniumBevelBrush(MillenniumTheme.colors),
+ shape = shape,
+ borderColor = MillenniumTheme.colors.border,
+ borderWidth = 1.dp
+ ).clip(shape).shimmerApply(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (artwork != null) artwork()
+ else if (model != null) AsyncImage(
+ model,
+ title,
+ Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ else MillenniumIcon(
+ placeholderIcon,
+ null,
+ modifier = Modifier.size(24.dp),
+ tint = AppTheme.colors.onSurfaceVariant
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt
index b8cad5cf..df59bd4b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt
@@ -1,30 +1,8 @@
-/*
- * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
package dev.krtirtho.spotube.modules.downloads
-
-import org.spotube.millennium.composables.MillenniumButton
-import org.spotube.millennium.composables.MillenniumButtonStyle
-import org.spotube.millennium.composables.MillenniumIcon
-import org.spotube.millennium.composables.MillenniumIconButton
-import org.spotube.millennium.composables.MillenniumIconButtonStyle
-import org.spotube.millennium.composables.MillenniumProgress
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,87 +16,153 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-
-import dev.krtirtho.spotube.core.ui.theming.AppTheme
-import com.composeunstyled.Text
-
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil3.compose.AsyncImage
-import coil3.compose.LocalPlatformContext
-import coil3.request.ImageRequest
-import coil3.request.crossfade
+import com.composeunstyled.Text
+import dev.krtirtho.spotube.core.ui.component.cards.MediaArtwork
+import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
+import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
+import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
+import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
+import org.spotube.millennium.composables.MillenniumButton
+import org.spotube.millennium.composables.MillenniumButtonStyle
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.composables.MillenniumIconButton
+import org.spotube.millennium.composables.MillenniumIconButtonStyle
+import org.spotube.millennium.composables.MillenniumProgress
+import org.spotube.millennium.theme.MillenniumTheme
+import org.spotube.millennium.theme.millenniumPanelBrush
+import org.spotube.millennium.theme.millenniumSurface
+import spotube.composeapp.generated.resources.*
@Composable
-fun DownloadsScreen(modifier: Modifier = Modifier) {
- val viewModel = koinViewModel()
- val downloads by viewModel.downloads.collectAsStateWithLifecycle()
-
- val shellBottomInset = LocalAppShellBottomInset.current
- val contentPadding = remember(shellBottomInset) {
- PaddingValues(top = 8.dp, bottom = 16.dp + shellBottomInset)
+fun DownloadsScreen(
+ modifier: Modifier = Modifier,
+ viewModel: DownloadsViewModel = koinViewModel(),
+ headerInAppBar: Boolean = false,
+ topContent: @Composable () -> Unit = {}
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ Column(modifier.fillMaxSize()) {
+ if (headerInAppBar) {
+ ApplicationMainBar(
+ backButton = false,
+ title = { DownloadsToolbarTitle() },
+ subtitle = { DownloadsToolbarSummary(state) },
+ actions = { DownloadsToolbarActions(state, viewModel::clearCompleted) }
+ )
+ topContent()
+ }
+ DownloadsContent(
+ state,
+ viewModel::cancel,
+ viewModel::retry,
+ viewModel::remove,
+ viewModel::clearCompleted,
+ Modifier.weight(1f)
+ .then(if (headerInAppBar) Modifier.padding(horizontal = 12.dp) else Modifier),
+ showHeader = !headerInAppBar
+ )
}
+}
- Box(modifier = Modifier.fillMaxSize()) {
- Column(
- modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) {
- val hasCompleted = downloads.any { it.status == DownloadStatus.Completed }
- if (hasCompleted) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.End) {
- MillenniumButton(
- onClick = viewModel::clearCompleted,
- style = MillenniumButtonStyle.Quiet) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxTrash,
- contentDescription = null,
- modifier = Modifier.size(16.dp))
- Text(
- text = " Clear completed",
- style = AppTheme.typography.labelLarge)
- }
- }
+@Composable
+internal fun DownloadsToolbarTitle() {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MillenniumIcon(
+ Iconsax.IconsaxDirectboxReceive,
+ null,
+ tint = AppTheme.colors.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ stringResource(Res.string.library_tab_downloads),
+ style = AppTheme.typography.titleMedium
+ )
+ }
+}
+
+@Composable
+internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
+ if (state.items.isNotEmpty()) Text(
+ stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
+ modifier = Modifier.padding(start = 28.dp),
+ style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant
+ )
+}
+
+@Composable
+internal fun DownloadsToolbarActions(state: DownloadsUiState, onClearCompleted: () -> Unit) {
+ if (state.canClearCompleted) MillenniumButton(
+ onClick = onClearCompleted,
+ style = MillenniumButtonStyle.Secondary
+ ) {
+ Text(
+ stringResource(Res.string.downloads_clear_completed),
+ style = AppTheme.typography.labelMedium
+ )
+ }
+}
+
+@Composable
+internal fun DownloadsContent(
+ state: DownloadsUiState,
+ onCancel: (String) -> Unit,
+ onRetry: (String) -> Unit,
+ onRemove: (String) -> Unit,
+ onClearCompleted: () -> Unit,
+ modifier: Modifier = Modifier,
+ showHeader: Boolean = true
+) {
+ val inset = LocalAppShellBottomInset.current
+ Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
+ Column(Modifier.widthIn(max = 960.dp).fillMaxSize()) {
+ if (showHeader) Row(
+ Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Column(Modifier.weight(1f)) {
+ DownloadsToolbarTitle()
+ DownloadsToolbarSummary(state)
}
+ DownloadsToolbarActions(state, onClearCompleted)
}
-
- if (downloads.isEmpty()) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center) {
- Text(
- text = "No downloads yet",
- style = AppTheme.typography.bodyMedium,
- color = AppTheme.colors.onSurfaceVariant)
- }
- } else {
- LazyColumn(
- contentPadding = contentPadding,
- verticalArrangement = Arrangement.spacedBy(2.dp)) {
- items(downloads, key = { it.id }) { item ->
- DownloadListRow(
- item = item,
- onCancel = { viewModel.cancel(item.id) },
- onRetry = { viewModel.retry(item.id) },
- onRemove = { viewModel.remove(item.id) })
- }
+ if (state.items.isEmpty()) MediaEmptyState(
+ Iconsax.IconsaxDirectboxReceive,
+ stringResource(Res.string.downloads_empty),
+ Modifier.padding(bottom = inset)
+ )
+ else LazyColumn(
+ Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = PaddingValues(
+ top = if (showHeader) 0.dp else 16.dp,
+ bottom = inset + 16.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(state.items, key = { it.id }) { row ->
+ DownloadListRow(
+ row,
+ { onCancel(row.id) },
+ { onRetry(row.id) },
+ { onRemove(row.id) })
}
}
}
@@ -126,147 +170,82 @@ fun DownloadsScreen(modifier: Modifier = Modifier) {
}
@Composable
-private fun DownloadListRow(
- item: DownloadItem,
+internal fun DownloadListRow(
+ item: DownloadRowUiState,
onCancel: () -> Unit,
onRetry: () -> Unit,
- onRemove: () -> Unit) {
- val rowBackgroundColor = when (item.status) {
- is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f)
- is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f)
- else -> AppTheme.colors.surface
- }
-
+ onRemove: () -> Unit
+) {
+ val colors = MillenniumTheme.colors
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp)
- .clip(AppTheme.shapes.small)
- .background(rowBackgroundColor)
- .padding(horizontal = 8.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails)
- ?.firstOrNull()?.url
-
- val platformContext = LocalPlatformContext.current
- val imageRequest = remember(imageUrl) {
- ImageRequest.Builder(platformContext)
- .data(imageUrl)
- .size(128)
- .crossfade(false)
- .build()
- }
-
- Box(
- modifier = Modifier
- .size(44.dp)
- .clip(AppTheme.shapes.small),
- contentAlignment = Alignment.Center) {
- AsyncImage(
- model = imageRequest,
- contentDescription = item.title,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize())
-
- if (item.status == DownloadStatus.Downloading) {
- MillenniumProgress(
- progress = item.progress,
- modifier = Modifier
- .fillMaxWidth()
- .height(3.dp)
- .align(Alignment.BottomCenter))
- }
- }
-
- Column(modifier = Modifier.weight(1f)) {
+ Modifier.fillMaxWidth().millenniumSurface(
+ background = millenniumPanelBrush(colors),
+ shape = RoundedCornerShape(8.dp),
+ borderColor = colors.border,
+ borderWidth = 1.dp,
+ innerGlowColor = colors.highlight.copy(alpha = 0.2f)
+ ).padding(12.dp), verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ MediaArtwork(item.artworkUrl, item.title, Modifier.size(52.dp))
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
- text = item.title,
- style = AppTheme.typography.bodyLarge,
+ item.title,
+ style = AppTheme.typography.titleSmall,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
-
+ overflow = TextOverflow.Ellipsis
+ )
Text(
- text = item.artists,
+ item.artists,
style = AppTheme.typography.bodySmall,
- color = AppTheme.colors.primary,
+ color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ when (item.status) {
+ DownloadStatus.Downloading -> stringResource(
+ Res.string.downloads_status_downloading,
+ item.percent
+ )
- when (item.status) {
- is DownloadStatus.Downloading -> {
- val percent = (item.progress * 100).toInt()
- Text(
- text = "Downloading... $percent%",
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.onSurfaceVariant)
- }
-
- is DownloadStatus.Queued -> {
- Text(
- text = "Queued",
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.onSurfaceVariant)
- }
-
- is DownloadStatus.Failed -> {
- Text(
- text = "Failed: ${item.errorMessage ?: "Unknown error"}",
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.error,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis)
- }
-
- is DownloadStatus.Cancelled -> {
- Text(
- text = "Cancelled",
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.onSurfaceVariant)
- }
-
- is DownloadStatus.Completed -> {
- Text(
- text = "Completed",
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.primary)
- }
- }
+ DownloadStatus.Queued -> stringResource(Res.string.downloads_status_queued)
+ DownloadStatus.Completed -> stringResource(Res.string.downloads_status_completed)
+ DownloadStatus.Cancelled -> stringResource(Res.string.downloads_status_cancelled)
+ is DownloadStatus.Failed -> if (item.error != null) stringResource(
+ Res.string.downloads_status_failed_error,
+ item.error
+ ) else stringResource(Res.string.downloads_status_failed)
+ },
+ style = AppTheme.typography.labelSmall,
+ color = when (item.status) {
+ is DownloadStatus.Failed -> AppTheme.colors.error
+ DownloadStatus.Completed -> AppTheme.colors.primary
+ else -> AppTheme.colors.onSurfaceVariant
+ },
+ maxLines = 1, overflow = TextOverflow.Ellipsis
+ )
+ if (item.status == DownloadStatus.Downloading) MillenniumProgress(
+ item.progress,
+ Modifier.fillMaxWidth().height(4.dp)
+ )
}
-
- when (item.status) {
- is DownloadStatus.Downloading, is DownloadStatus.Queued -> {
- MillenniumIconButton(
- onClick = onCancel,
- style = MillenniumIconButtonStyle.Quiet) {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxCloseSquare,
- contentDescription = "Cancel",
- tint = AppTheme.colors.onSurfaceVariant)
- }
- }
-
- is DownloadStatus.Failed, is DownloadStatus.Cancelled -> {
- MillenniumIconButton(
- onClick = onRetry,
- style = MillenniumIconButtonStyle.Quiet) {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxRefreshRight,
- contentDescription = "Retry",
- tint = AppTheme.colors.primary)
- }
- }
-
- is DownloadStatus.Completed -> {
- MillenniumIconButton(
- onClick = onRemove,
- style = MillenniumIconButtonStyle.Quiet) {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxTrash,
- contentDescription = "Remove",
- tint = AppTheme.colors.onSurfaceVariant)
- }
- }
+ MillenniumIconButton(
+ onClick = when (item.action) {
+ DownloadRowAction.Cancel -> onCancel; DownloadRowAction.Retry -> onRetry; DownloadRowAction.Remove -> onRemove
+ },
+ modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Secondary
+ ) {
+ MillenniumIcon(
+ when (item.action) {
+ DownloadRowAction.Cancel -> Iconsax.IconsaxCloseSquare; DownloadRowAction.Retry -> Iconsax.IconsaxRefreshRight; DownloadRowAction.Remove -> Iconsax.IconsaxTrash
+ },
+ stringResource(
+ when (item.action) {
+ DownloadRowAction.Cancel -> Res.string.downloads_action_cancel; DownloadRowAction.Retry -> Res.string.downloads_action_retry; DownloadRowAction.Remove -> Res.string.downloads_action_remove
+ }
+ )
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt
index b1aef155..a1b87a73 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt
@@ -23,13 +23,64 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.map
+
+enum class DownloadRowAction { Cancel, Retry, Remove }
+
+data class DownloadRowUiState(
+ val id: String,
+ val title: String,
+ val artists: String,
+ val artworkUrl: String?,
+ val status: DownloadStatus,
+ val progress: Float,
+ val percent: Int,
+ val error: String?,
+ val action: DownloadRowAction
+)
+
+data class DownloadsUiState(
+ val items: List = emptyList(),
+ val activeCount: Int = 0,
+ val completedCount: Int = 0,
+ val canClearCompleted: Boolean = false
+)
+
+internal fun downloadsUiState(items: List): DownloadsUiState {
+ val completedCount = items.count { it.status == DownloadStatus.Completed }
+ return DownloadsUiState(
+ items = items.map { item ->
+ val progress = item.progress.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 0f
+ DownloadRowUiState(
+ item.id, item.title, item.artists,
+ (item.track?.album?.thumbnails
+ ?: item.track?.thumbnails)?.firstOrNull()?.url?.takeIf { it.isNotBlank() },
+ item.status, progress, (progress * 100).toInt(),
+ item.errorMessage ?: (item.status as? DownloadStatus.Failed)?.error,
+ when (item.status) {
+ DownloadStatus.Completed -> DownloadRowAction.Remove
+ is DownloadStatus.Failed, DownloadStatus.Cancelled -> DownloadRowAction.Retry
+ else -> DownloadRowAction.Cancel
+ }
+ )
+ },
+ activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued },
+ completedCount = completedCount,
+ canClearCompleted = completedCount > 0
+ )
+}
class DownloadsViewModel(
- private val downloadManager: DownloadManager) : ViewModel() {
+ private val downloadManager: DownloadManager
+) : ViewModel() {
val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
- emptyList())
+ emptyList()
+ )
+
+ val uiState = downloadManager.downloadsFlow.map(::downloadsUiState)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
fun downloadTrack(track: MetadataTrack) {
downloadManager.enqueue(track)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt
new file mode 100644
index 00000000..374ee5ab
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryMediaCollection.kt
@@ -0,0 +1,79 @@
+/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package dev.krtirtho.spotube.modules.library
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
+import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+/** Only reports viewport geometry. The owning ViewModel decides when to load another page. */
+@Composable
+internal fun LibraryMediaCollection(
+ items: List,
+ itemKey: (T) -> String,
+ viewMode: LibraryViewMode,
+ onViewportChanged: (Int?, Int) -> Unit,
+ itemContent: @Composable (T, Boolean) -> Unit,
+ skeleton: @Composable (Boolean) -> Unit,
+ loading: Boolean = false,
+ loadingMore: Boolean = false,
+ header: (@Composable (Boolean) -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ val gridState = rememberLazyGridState()
+ val listState = rememberLazyListState()
+ val reportViewport by rememberUpdatedState(onViewportChanged)
+ val padding = PaddingValues(top = 16.dp, bottom = LocalAppShellBottomInset.current + 16.dp)
+ LaunchedEffect(viewMode, gridState, listState) {
+ val positions = if (viewMode == LibraryViewMode.List) snapshotFlow {
+ listState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount }
+ } else snapshotFlow {
+ gridState.layoutInfo.let { it.visibleItemsInfo.lastOrNull()?.index to it.totalItemsCount }
+ }
+ positions.distinctUntilChanged().collect { (last, total) -> reportViewport(last, total) }
+ }
+ if (viewMode == LibraryViewMode.List) {
+ LazyColumn(
+ modifier.fillMaxSize(), state = listState, contentPadding = padding,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
+ else {
+ if (header != null) item(key = "library_header") { header(true) }
+ items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) }
+ if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
+ }
+ }
+ } else {
+ LazyVerticalGrid(
+ GridCells.Adaptive(160.dp), modifier.fillMaxSize(), state = gridState,
+ contentPadding = padding, horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
+ else {
+ if (header != null) item(key = "library_header") { header(false) }
+ items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) }
+ if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt
index de796a5b..4b7b24af 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState
@@ -38,6 +39,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import com.composeunstyled.currentWindowWidthBreakpoint
@@ -63,6 +65,9 @@ import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField
+import compose.icons.FeatherIcons
+import compose.icons.feathericons.Grid
+import compose.icons.feathericons.List
import spotube.composeapp.generated.resources.*
@Composable
@@ -70,13 +75,24 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
val state by libraryState.uiState.collectAsStateWithLifecycle()
val expandedDesktop = getPlatform().isDesktop() &&
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
+ val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Expanded
+ val compactDesktop = getPlatform().isDesktop() && isCompact
+ val viewMode = state.viewModeOverride ?: defaultLibraryViewMode(isCompact)
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
+ if (state.currentTab == LibraryTab.Downloads && !isCompact) {
+ DownloadsScreen(
+ headerInAppBar = true,
+ topContent = { if (!expandedDesktop) LibraryTabs(state, libraryState::onTabSelected) }
+ )
+ return
+ }
+
Column(Modifier.fillMaxSize()) {
ApplicationMainBar(
backButton = false,
- title = { LibraryToolbarTitle(state, expandedDesktop) },
+ title = { LibraryToolbarTitle(state, expandedDesktop, compactDesktop) },
actions = {
LibraryToolbarActions(
state = state,
@@ -88,32 +104,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
libraryState.closeSearch()
},
onCreatePlaylist = libraryState::showCreatePlaylist,
- onRefresh = libraryState::refreshLocalTracks
+ onRefresh = libraryState::refreshLocalTracks,
+ onToggleView = { libraryState.toggleViewMode(isCompact) },
+ viewMode = viewMode,
+ compactDesktop = compactDesktop
)
}
)
if (!expandedDesktop) {
- LazyRow(
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- items(LibraryTab.entries) { tab ->
- MillenniumButton(
- onClick = { libraryState.onTabSelected(tab) },
- style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
- ) { Text(stringResource(tab.titleResource)) }
- }
- }
+ LibraryTabs(state, libraryState::onTabSelected)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
- onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
+ onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
+ viewMode = viewMode
)
- LibraryTab.Albums -> LibraryAlbumsScreen()
- LibraryTab.Artists -> LibraryArtistsScreen()
+ LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
+ LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
LibraryTab.Downloads -> DownloadsScreen()
}
@@ -122,7 +132,26 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
}
@Composable
-internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
+private fun LibraryTabs(state: LibraryToolbarUiState, onTabSelected: (LibraryTab) -> Unit) {
+ LazyRow(
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(LibraryTab.entries) { tab ->
+ MillenniumButton(
+ onClick = { onTabSelected(tab) },
+ style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
+ ) { Text(stringResource(tab.titleResource)) }
+ }
+ }
+}
+
+@Composable
+internal fun LibraryToolbarTitle(
+ state: LibraryToolbarUiState,
+ expandedDesktop: Boolean,
+ compactDesktop: Boolean = false
+) {
if (!expandedDesktop && state.searchMode) {
LibraryFilterField(
state.searchState,
@@ -131,7 +160,11 @@ internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop:
autoFocus = true
)
} else if (!expandedDesktop) {
- Text(stringResource(Res.string.library_screen_title))
+ Text(
+ stringResource(if (compactDesktop) Res.string.library_toolbar_compact_title else Res.string.library_screen_title),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
}
}
@@ -142,13 +175,18 @@ internal fun RowScope.LibraryToolbarActions(
onOpenSearch: () -> Unit,
onCloseSearch: () -> Unit,
onCreatePlaylist: () -> Unit,
- onRefresh: () -> Unit
+ onRefresh: () -> Unit,
+ onToggleView: () -> Unit = {},
+ viewMode: LibraryViewMode = state.viewModeOverride ?: defaultLibraryViewMode(!expandedDesktop),
+ compactDesktop: Boolean = false
) {
+ val actionModifier = Modifier.size(if (compactDesktop) 32.dp else 40.dp)
if (!expandedDesktop && state.searchMode) {
MillenniumIconButton(
icon = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.library_action_close_search),
onClick = onCloseSearch,
+ modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet
)
} else {
@@ -164,6 +202,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxSearchBroken,
contentDescription = stringResource(Res.string.library_action_search),
onClick = onOpenSearch,
+ modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet
)
}
@@ -173,6 +212,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = onCreatePlaylist,
+ modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet
)
}
@@ -181,6 +221,16 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh,
+ modifier = actionModifier,
+ style = MillenniumIconButtonStyle.Quiet
+ )
+ }
+ if (state.canChangeView) {
+ MillenniumIconButton(
+ icon = if (viewMode == LibraryViewMode.List) FeatherIcons.Grid else FeatherIcons.List,
+ contentDescription = stringResource(if (viewMode == LibraryViewMode.List) Res.string.library_action_grid_view else Res.string.library_action_list_view),
+ onClick = onToggleView,
+ modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt
index 9002784f..4e7effbf 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryState.kt
@@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+
+enum class LibraryViewMode { Grid, List }
data class LibraryToolbarUiState(
val currentTab: LibraryTab = LibraryTab.Playlists,
@@ -39,7 +42,9 @@ data class LibraryToolbarUiState(
val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false,
- val showCreatePlaylist: Boolean = false
+ val showCreatePlaylist: Boolean = false,
+ val canChangeView: Boolean = true,
+ val viewModeOverride: LibraryViewMode? = null
)
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
@@ -49,14 +54,16 @@ class LibraryState : ViewModel() {
val currentTab = MutableStateFlow(LibraryTab.Playlists)
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
private val createPlaylistVisible = MutableStateFlow(false)
+ private val viewModes = MutableStateFlow