From 75ced1285ee4b2ed5b9824bcd4d1b07a63ba4bc0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Oct 2026 18:04:53 +0600 Subject: [PATCH] feat(local-tracks): implement LocalFolder screen and view model for folder navigation --- .../core/audioplayer/AudioPlayer.android.kt | 4 +- .../AndroidLocalMediaDiscoveryService.kt | 230 +++++++++++------- .../core/audioplayer/AudioPlayerModels.kt | 22 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 5 +- .../org/spotube/spotube/core/di/Modules.kt | 2 + .../core/navigation/NavigationModule.kt | 13 + .../core/navigation/NavigationState.kt | 1 + .../core/remote/RemoteControlHandler.kt | 10 +- .../ui/component/cards/PlayableListTile.kt | 13 +- .../local_tracks/LibraryLocalTracksScreen.kt | 35 +-- .../LibraryLocalTracksViewModel.kt | 68 ------ .../library/local_tracks/LocalFolderScreen.kt | 35 +++ .../local_tracks/LocalFolderViewModel.kt | 66 +++++ .../modules/shell/AppExpandedPlayer.kt | 2 +- .../modules/shell/AppFloatingPlayer.kt | 2 +- .../spotube/modules/shell/AppLargePlayer.kt | 2 +- .../spotube/modules/shell/AppPlayerState.kt | 15 +- .../shell/player_queue/PlayerQueueContent.kt | 4 +- .../PlayerQueueContentViewModel.kt | 8 +- .../modules/library/MediaLibraryUiTest.kt | 6 +- 20 files changed, 332 insertions(+), 211 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt index 390c834d..df055370 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt @@ -202,7 +202,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface .setArtist(artist) .setAlbumTitle(album) .apply { - if (coverURL.isNotBlank()) { + if (coverBytes != null) { + setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER) + } else if (coverURL.isNotBlank()) { setArtworkUri(android.net.Uri.parse(coverURL)) } } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index 0b8eb37b..f5d1d118 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -25,7 +25,18 @@ import android.os.Build import android.os.Environment import android.os.Handler import android.os.Looper +import android.graphics.BitmapFactory +import android.graphics.Bitmap import android.provider.MediaStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent import java.io.File @@ -35,14 +46,39 @@ class AndroidLocalMediaDiscoveryService( private val context: Context) : LocalMediaDiscoveryService, KoinComponent { val logger by injectLogger() - override suspend fun discoverFolders(roots: List): List { + private class TagInfo( + val title: String?, + val artists: List, + val album: String?, + val durationMs: Long, + val cover: ByteArray?) + + private class PendingTrack( + val uri: String, + val filePath: String?, + val modifiedAt: Long, + val folderPath: String, + val fallbackName: String, + val mediaStoreArtists: List, + val mediaStoreAlbum: String?, + val mediaStoreDurationMs: Long) + + // Parsing tags + cover with lofty is the expensive part, so keep results across rescans. + private val tagCache = ConcurrentHashMap() + + override suspend fun discoverFolders(roots: List): List = withContext(Dispatchers.IO) { + discoverFoldersBlocking(roots) + } + + private suspend fun discoverFoldersBlocking(roots: List): List { logger.i { "discoverFolders: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "discoverFolders: missing media permission, returning empty result" } return emptyList() } - val tracksByFolder = linkedMapOf>() + val pending = mutableListOf() + val knownFilePaths = hashSetOf() val resolver = context.contentResolver val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI var totalRows = 0 @@ -58,7 +94,9 @@ class AndroidLocalMediaDiscoveryService( MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) { + MediaStore.Audio.Media.MIME_TYPE, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.DATE_MODIFIED) + if (Build.VERSION.SDK_INT >= 29) { arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) } else { emptyArray() @@ -77,6 +115,8 @@ class AndroidLocalMediaDiscoveryService( val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE) val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH) + val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + val modifiedCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED) logger.d { "discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)" @@ -118,20 +158,23 @@ class AndroidLocalMediaDiscoveryService( .takeUnless { it.equals("", true) } val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() } val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L) + val filePath = if (dataCol >= 0) cursor.getString(dataCol)?.takeIf { it.isNotBlank() } else null + val modifiedAt = if (modifiedCol >= 0) cursor.getLong(modifiedCol) else 0L + filePath?.let(knownFilePaths::add) val name = rawTitle.ifBlank { displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName } - val track = LocalMediaTrack( - path = uri, - name = name.ifBlank { "Unknown track" }, - artists = listOfNotNull(artist), - durationMs = durationMs, - album = album, - coverBytes = null) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + pending += PendingTrack( + uri = uri, + filePath = filePath, + modifiedAt = modifiedAt, + folderPath = folderPath, + fallbackName = name.ifBlank { "Unknown track" }, + mediaStoreArtists = listOfNotNull(artist), + mediaStoreAlbum = album, + mediaStoreDurationMs = durationMs) acceptedRows += 1 } } ?: run { @@ -142,20 +185,13 @@ class AndroidLocalMediaDiscoveryService( logger.w { "discoverFolders: MediaStore query returned null cursor" } } - val mediaStoreFolders = tracksByFolder.entries - .map { (folderPath, tracks) -> - LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } + // Files MediaStore already indexed must not be listed a second time by the filesystem scan. + discoverFileSystemFallback(roots, knownFilePaths, pending) - val fallbackFolders = discoverFileSystemFallback(roots) - val merged = mergeFolders(mediaStoreFolders, fallbackFolders) + val merged = buildFolders(pending) logger.i { - "discoverFolders: done folders=${merged.size} mediaStoreFolders=${mediaStoreFolders.size} fallbackFolders=${fallbackFolders.size} tracks=$acceptedRows totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" + "discoverFolders: done folders=${merged.size} mediaStoreTracks=$acceptedRows tracks=${pending.size} totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows" } if (merged.isNotEmpty()) { @@ -221,56 +257,109 @@ class AndroidLocalMediaDiscoveryService( return extension in SUPPORTED_EXTENSIONS } - private fun discoverFileSystemFallback(roots: List): List { + private fun discoverFileSystemFallback( + roots: List, + knownFilePaths: Set, + out: MutableList) { val candidateRoots = resolveCandidateRoots(roots) if (candidateRoots.isEmpty()) { logger.d { "discoverFileSystemFallback: no candidate roots" } - return emptyList() + return } - - logger.d { - "discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}" - } - - val tracksByFolder = linkedMapOf>() - var scannedFiles = 0 - var acceptedFiles = 0 + val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/" + val seen = hashSetOf() + var added = 0 candidateRoots.forEach { root -> scanDirectory(root) { file -> - scannedFiles += 1 - val extension = file.extension.lowercase(Locale.getDefault()) - if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory - acceptedFiles += 1 + if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory + val absolute = file.absolutePath + if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory - val folderPath = file.parentFile?.absolutePath ?: "Unknown" - val name = file.nameWithoutExtension.ifBlank { file.name } - val track = LocalMediaTrack( - path = file.absolutePath, - name = name, - artists = emptyList(), - durationMs = 0L, - album = null, - coverBytes = null) - - tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) + // Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge. + val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/" + val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent + out += PendingTrack( + uri = absolute, + filePath = absolute, + modifiedAt = file.lastModified() / 1000, + folderPath = folderPath, + fallbackName = file.nameWithoutExtension.ifBlank { file.name }, + mediaStoreArtists = emptyList(), + mediaStoreAlbum = null, + mediaStoreDurationMs = 0L) + added += 1 } } + logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" } + } - val folders = tracksByFolder.entries - .map { (folderPath, tracks) -> + private suspend fun buildFolders(pending: List): List { + val permits = Semaphore(4) + val tracks = coroutineScope { + pending.map { item -> + async(Dispatchers.IO) { + val tag = permits.withPermit { readTags(item) } + item.folderPath to LocalMediaTrack( + path = item.uri, + name = tag?.title?.takeIf { it.isNotBlank() } ?: item.fallbackName, + artists = tag?.artists?.takeIf { it.isNotEmpty() } ?: item.mediaStoreArtists, + durationMs = tag?.durationMs?.takeIf { it > 0 } ?: item.mediaStoreDurationMs, + album = tag?.album?.takeIf { it.isNotBlank() } ?: item.mediaStoreAlbum, + coverBytes = tag?.cover) + } + }.awaitAll() + } + return tracks.groupBy({ it.first }, { it.second }) + .map { (path, list) -> LocalMediaFolder( - path = folderPath, - name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) + path = path, + name = normalizeFolderName(path), + tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } + } - logger.i { - "discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}" + private fun readTags(item: PendingTrack): TagInfo? { + val path = item.filePath ?: return null + val key = "$path:${item.modifiedAt}" + tagCache[key]?.let { return it } + val file = File(path) + if (!file.isFile || !file.canRead()) return null + val info = try { + val meta = uniffi.compose_app.readAudioMetadata(path) + TagInfo( + title = meta.title, + artists = meta.artists, + album = meta.album, + durationMs = meta.durationMs, + cover = meta.coverBytes?.let(::thumbnail)) + } catch (t: Throwable) { + logger.d { "readTags: failed path=$path reason=${t.message}" } + null } + if (info != null) tagCache[key] = info + return info + } - return folders + // Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG. + private fun thumbnail(bytes: ByteArray): ByteArray? { + if (bytes.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2 + val decoded = BitmapFactory.decodeByteArray( + bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null + val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height) + val bitmap = if (scale < 1f) Bitmap.createScaledBitmap( + decoded, (decoded.width * scale).toInt().coerceAtLeast(1), + (decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded + return ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + it.toByteArray() + } } private fun resolveCandidateRoots(roots: List): List { @@ -309,35 +398,8 @@ class AndroidLocalMediaDiscoveryService( } } - private fun mergeFolders( - mediaStoreFolders: List, - fallbackFolders: List): List { - if (fallbackFolders.isEmpty()) return mediaStoreFolders - if (mediaStoreFolders.isEmpty()) return fallbackFolders - - val merged = linkedMapOf>() - val seenTrackPaths = mutableSetOf() - - (mediaStoreFolders + fallbackFolders).forEach { folder -> - val bucket = merged.getOrPut(folder.path) { mutableListOf() } - folder.tracks.forEach { track -> - if (seenTrackPaths.add(track.path)) { - bucket.add(track) - } - } - } - - return merged.entries - .map { (path, tracks) -> - LocalMediaFolder( - path = path, - name = normalizeFolderName(path), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) - } - .sortedBy { it.name.lowercase(Locale.getDefault()) } - } - companion object { + private const val THUMBNAIL_SIZE = 160 private val SUPPORTED_EXTENSIONS = setOf( "mp3", "m4a", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt index 9d6e6a2d..8840f8b3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -30,7 +30,8 @@ data class MediaItem( val duration: Duration, val coverURL: String, val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + val coverBytes: ByteArray? = null) enum class LoopState { NONE, ONE, ALL; @@ -53,13 +54,28 @@ sealed interface QueueEntry { val url: String val addedBy: String + /** Remote artwork URL, for consumers that can only take a link (remote control, media session). */ + val artworkUrl: String? + + /** + * Single source of truth for a queue entry's artwork, in a form Coil can load directly: + * a URL `String` for streaming tracks, embedded cover `ByteArray` for local ones. + * Every UI surface (players, queue, lists) must use this instead of reading thumbnails itself. + */ + val artworkModel: Any? + get() = (this as? LocalTrack)?.coverBytes?.takeIf { it.isNotEmpty() } ?: artworkUrl + @Serializable @SerialName("streaming") data class StreamingTrack( val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - override val addedBy: String = "") : QueueEntry + override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? + get() = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + } @Serializable @SerialName("local") @@ -71,6 +87,8 @@ sealed interface QueueEntry { val coverBytes: ByteArray?, override val url: String, override val addedBy: String = "") : QueueEntry { + override val artworkUrl: String? get() = null + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 4fc6e165..fb283055 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -463,7 +463,7 @@ class DeviceAudioPlayerQueue( artist = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title.orEmpty(), duration = track.durationMs.milliseconds, - coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), + coverURL = artworkUrl.orEmpty(), url = resolvedUrl, protocol = protocol) } @@ -476,7 +476,8 @@ class DeviceAudioPlayerQueue( album = album.orEmpty(), duration = duration.milliseconds, coverURL = "", - url = url) + url = url, + coverBytes = coverBytes?.takeIf { it.isNotEmpty() }) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index bcc986dc..cd8e2742 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -60,6 +60,7 @@ import org.spotube.spotube.modules.library.LibraryState import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator @@ -133,6 +134,7 @@ val sharedModules = module { viewModelOf(::LibraryAlbumsViewModel) viewModelOf(::LibraryArtistsViewModel) viewModelOf(::LibraryLocalTracksViewModel) + viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) } // Plugin system singleOf(::PluginManager) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt index 93c707c1..aef9d397 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt @@ -26,6 +26,8 @@ import org.spotube.spotube.modules.devices.RemoteControlScreen import org.spotube.spotube.modules.jam.JamScreen import org.spotube.spotube.modules.home.HomeScreen import org.spotube.spotube.modules.library.LibraryScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderScreen +import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel import org.spotube.spotube.modules.lyrics.LyricsScreen import org.spotube.spotube.modules.playlist.PlaylistScreen import org.spotube.spotube.modules.plugin.PluginScreen @@ -96,6 +98,9 @@ sealed interface Routes : NavKey { @Serializable data object Welcome : Routes + + @Serializable + data class LocalFolder(val folderPath: String) : Routes } @OptIn(KoinExperimentalAPI::class) @@ -115,6 +120,14 @@ val navigationModule = module { // Not guarded as a whole: Local tracks and Downloads work without a plugin. LibraryScreen() } + navigation { + LocalFolderScreen( + viewModel = koinViewModel( + key = it.folderPath, + parameters = { parametersOf(it.folderPath) } + ), + onBack = { get().pop() }) + } navigation { SettingsScreen(settingsViewModel = koinViewModel()) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt index bd35c0ff..f92c91d5 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt @@ -64,6 +64,7 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Devices::class, Routes.Devices.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer()) + subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt index 0fe817eb..fc5a672d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlHandler.kt @@ -354,7 +354,7 @@ class RemoteControlHandler( currentTrackTitle = current?.titleOrNull(), currentTrackArtists = current?.artistsOrNull(), currentTrackAlbum = current?.albumOrNull(), - currentTrackCoverUrl = current?.coverUrlOrNull()) + currentTrackCoverUrl = current?.artworkUrl) val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } @@ -395,7 +395,7 @@ class RemoteControlHandler( title = track.title, artists = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title, - coverUrl = coverUrlOrNull(), + coverUrl = artworkUrl, durationMs = track.durationMs) is QueueEntry.LocalTrack -> RemoteQueueEntry( @@ -427,12 +427,6 @@ class RemoteControlHandler( is QueueEntry.StreamingTrack -> track.album?.title is QueueEntry.LocalTrack -> album } - - private fun QueueEntry.coverUrlOrNull(): String? = when (this) { - is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url - ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url - is QueueEntry.LocalTrack -> null - } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt index 8b8edd2a..f01bcb0f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/cards/PlayableListTile.kt @@ -61,7 +61,8 @@ fun PlayableListTile( placeholderIcon: ImageVector = Iconsax.IconsaxMusic, artwork: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, - isBlacklisted: Boolean = false + isBlacklisted: Boolean = false, + compact: Boolean = false ) { val colors = MillenniumTheme.colors MillenniumListTile( @@ -73,12 +74,12 @@ fun PlayableListTile( 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, + contentPadding = PaddingValues(if (compact) 8.dp else 12.dp), + minimumHeight = if (compact) 52.dp else 76.dp, leading = { MediaArtwork( imageModel, title, - Modifier.size(52.dp).sharedElementOrNone( + Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone( sharedElementKey, LocalSharedTransitionScope.current, LocalAnimatedVisibilityScope.current @@ -114,7 +115,7 @@ fun PlayableListTile( if (onAddToQueue != null && !isPlaying) { MillenniumIconButton( onClick = onAddToQueue, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Secondary ) { MillenniumIcon( @@ -126,7 +127,7 @@ fun PlayableListTile( if (onPlay != null) { MillenniumIconButton( onClick = onPlay, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(if (compact) 32.dp else 40.dp), style = MillenniumIconButtonStyle.Primary ) { MillenniumIcon( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index d7bcb401..e379c8db 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -47,7 +47,10 @@ import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.pluralStringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.spotube.spotube.core.navigation.NavigationCommands +import org.spotube.spotube.core.navigation.Routes import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumIcon @@ -63,15 +66,18 @@ import spotube.composeapp.generated.resources.* fun LibraryLocalTracksScreen( modifier: Modifier = Modifier, viewModel: LibraryLocalTracksViewModel = koinViewModel(), - foldersViewModel: LocalMediaFoldersViewModel = koinViewModel() + foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(), + navigationCommands: NavigationCommands = koinInject() ) { val state by viewModel.state.collectAsStateWithLifecycle() val permission = rememberLocalMediaPermissionState() // Platform permission events only; the ViewModel decides whether/when to index media. LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LocalTracksContent( - state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, - viewModel::playCurrentFolder, permission.requestPermission, modifier, + state, + onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) }, + onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {}, + onRequestPermission = permission.requestPermission, modifier = modifier, onManageFolders = foldersViewModel::open ) LocalMediaFoldersDialogHost(foldersViewModel) @@ -120,7 +126,7 @@ internal fun LocalTracksContent( state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = inset + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(state.visibleFolders, key = { it.path }) { folder -> LocalFolderRow(folder) { onOpenFolder(folder.path) } @@ -130,7 +136,7 @@ internal fun LocalTracksContent( state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = inset + 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(state.visibleTracks, key = { it.path }) { track -> PlayableListTile( @@ -139,6 +145,7 @@ internal fun LocalTracksContent( imageModel = track.artwork, onClick = { onPlayTrack(track.path) }, onPlay = { onPlayTrack(track.path) }, + compact = true, trailingContent = { track.durationLabel?.let { Text( @@ -195,7 +202,7 @@ internal fun LocalTracksBreadcrumb( ) { val backDescription = stringResource(Res.string.library_local_back) Row( - modifier.fillMaxWidth().height(32.dp), + modifier.fillMaxWidth().height(40.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -268,22 +275,22 @@ internal fun LocalTracksBreadcrumb( if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( onClick = onManageFolders, - modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( Iconsax.IconsaxFolderAdd, stringResource(Res.string.library_local_add_folder), - modifier = Modifier.size(14.dp) + modifier = Modifier.size(22.dp) ) } if (state.currentFolderPath != null) MillenniumIconButton( onClick = onPlayFolder, enabled = state.canPlayFolder, - modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet + modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( Iconsax.IconsaxPlay, stringResource(Res.string.library_local_play_folder), - modifier = Modifier.size(14.dp) + modifier = Modifier.size(22.dp) ) } } @@ -297,13 +304,13 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { shape = RoundedCornerShape(8.dp), borderColor = MillenniumTheme.colors.border, borderWidth = 1.dp, - contentPadding = PaddingValues(12.dp), - minimumHeight = 76.dp, + contentPadding = PaddingValues(8.dp), + minimumHeight = 52.dp, leading = { MediaArtwork( folder.artwork, folder.name, - Modifier.size(52.dp), + Modifier.size(36.dp), placeholderIcon = Iconsax.IconsaxFolderOpen ) }, @@ -328,7 +335,7 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) { MillenniumIcon( FeatherIcons.ChevronRight, null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(16.dp) ) } ) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 706052da..b28db0c6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -19,8 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import org.spotube.spotube.core.audioplayer.AudioPlayerQueue -import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.library.LibraryState import org.spotube.spotube.modules.library.LibraryTab import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder @@ -120,7 +118,6 @@ private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean = class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue, private val libraryState: LibraryState ) : ViewModel() { private val requiresPermission = getPlatform().type == PlatformType.Android @@ -155,14 +152,6 @@ class LibraryLocalTracksViewModel( libraryState.setQuery(LibraryTab.LocalTracks, query) } - fun openFolder(folderPath: String) { - _state.update { it.copy(currentFolderPath = folderPath) } - } - - fun navigateUp() { - _state.update { it.copy(currentFolderPath = null) } - } - fun onPermissionChanged(granted: Boolean) { val accessible = !requiresPermission || granted val wasAccessible = _state.value.canAccessMedia @@ -170,10 +159,6 @@ class LibraryLocalTracksViewModel( if (accessible && !wasAccessible) refresh() } - fun playCurrentFolder() { - _state.value.currentFolderPath?.let(::playFolder) - } - fun refresh() { if (!_state.value.canAccessMedia) return viewModelScope.launch { @@ -191,41 +176,6 @@ class LibraryLocalTracksViewModel( } } - fun playFolder(folderPath: String) { - if (!_state.value.canAccessMedia) return - val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return - val tracks = filterTracks(folder.tracks, state.value.query) - if (tracks.isEmpty()) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = 0, - collectionEntry = null - ) - } - } - - fun playTrack(trackPath: String) { - if (!_state.value.canAccessMedia) return - val folder = currentFolder() ?: return - val tracks = filterTracks(folder.tracks, state.value.query) - val selectedIndex = tracks.indexOfFirst { it.path == trackPath } - if (selectedIndex < 0) return - - viewModelScope.launch { - audioPlayerQueue.load( - entries = tracks.map { it.toQueueEntry() }, - autoPlay = true, - startPosition = selectedIndex, - collectionEntry = null - ) - } - } - - private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder - private fun refreshIfEmpty() { if (_state.value.folders.isNotEmpty()) return refresh() @@ -239,22 +189,4 @@ class LibraryLocalTracksViewModel( } } } - - private fun filterTracks(tracks: List, query: String): List { - val normalizedQuery = query.trim() - if (normalizedQuery.isBlank()) return tracks - - return tracks.filter { localTrackMatches(it, normalizedQuery) } - } - - private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack { - return QueueEntry.LocalTrack( - name = name, - artists = artists, - duration = durationMs, - album = album, - coverBytes = coverBytes, - url = path - ) - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt new file mode 100644 index 00000000..d3583913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderScreen.kt @@ -0,0 +1,35 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import org.spotube.spotube.core.ui.component.ApplicationMainBar + +@Composable +fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) { + val state by viewModel.state.collectAsStateWithLifecycle() + Column { + ApplicationMainBar( + backButton = true, + title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) } + ) + LocalTracksContent( + state, + onOpenFolder = {}, + onPlayTrack = viewModel::playTrack, + onNavigateUp = onBack, + onPlayFolder = viewModel::playFolder, + onRequestPermission = {}, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt new file mode 100644 index 00000000..01e4f6d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/local_tracks/LocalFolderViewModel.kt @@ -0,0 +1,66 @@ +/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package org.spotube.spotube.modules.library.local_tracks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator +import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack + +class LocalFolderViewModel( + private val folderPath: String, + coordinator: LocalMediaLibraryCoordinator, + private val audioPlayerQueue: AudioPlayerQueue +) : ViewModel() { + val state: StateFlow = coordinator.cacheState + .map { cache -> + localTracksUiState( + LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath), + "" + ) + } + .stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), + localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "") + ) + + fun playFolder() = play(0) + + fun playTrack(trackPath: String) { + val index = tracks().indexOfFirst { it.path == trackPath } + if (index >= 0) play(index) + } + + private fun tracks(): List = + state.value.currentFolder?.tracks.orEmpty() + + private fun play(startPosition: Int) { + val tracks = tracks() + if (tracks.isEmpty()) return + viewModelScope.launch { + audioPlayerQueue.load( + entries = tracks.map { + QueueEntry.LocalTrack( + name = it.name, + artists = it.artists, + duration = it.durationMs, + album = it.album, + coverBytes = it.coverBytes, + url = it.path + ) + }, + autoPlay = true, + startPosition = startPosition, + collectionEntry = null + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt index ddbdd2b5..a95bb526 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppExpandedPlayer.kt @@ -211,7 +211,7 @@ fun AppExpandedPlayer( val snackbarHostState = remember { MillenniumSnackbarHostState() } val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } val moreOptionsSheetState = rememberModalBottomSheetState( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt index 9f9ebca4..fd64f559 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppFloatingPlayer.kt @@ -131,7 +131,7 @@ internal fun FloatingPlayerContent( onPlayPause: () -> Unit, heartButton: @Composable () -> Unit ) { - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel Box( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt index 25f016cd..3490f2fa 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppLargePlayer.kt @@ -164,7 +164,7 @@ fun AppLargePlayer( var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } - val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } + val coverModel = playerUiState.coverModel PlayerOptionDialogs(viewModel = playerOptionsViewModel) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt index b2b8fd0a..fec5626a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppPlayerState.kt @@ -45,7 +45,6 @@ internal data class PlayerUiState( val artists: String = "No-op artists", val artistList: List = emptyList(), val album: String = "", - val coverUrl: String = "", val position: Duration = 0.milliseconds, val actualDuration: Duration = 0.milliseconds, val metadataDuration: Duration = 0.milliseconds, @@ -55,6 +54,8 @@ internal data class PlayerUiState( val volume: Float = 0f ) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING + + val coverModel: Any? get() = currentQueueEntry?.artworkModel val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration val seekDuration: Duration @@ -72,7 +73,7 @@ internal data class PlayerUiState( queue=${queue.map { it.displayTitle() }}, currentQueueEntry=${currentQueueEntry?.displayTitle()}, title='$title', artists='$artists', - album='$album', coverUrl='$coverUrl', + album='$album', coverModel=${coverModel?.let { it::class.simpleName }}, position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration, playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume ) @@ -105,7 +106,6 @@ internal fun rememberPlayerUiState( artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, artistList = currentQueueEntry?.displayArtistList().orEmpty(), album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), - coverUrl = currentQueueEntry?.coverUrl().orEmpty(), position = position, actualDuration = duration, metadataDuration = metadataDuration, @@ -149,15 +149,6 @@ private fun QueueEntry.displayAlbum(unknownAlbum: String): String { } } -private fun QueueEntry.coverUrl(): String { - return when (this) { - is QueueEntry.StreamingTrack -> (track.album?.thumbnails - ?: track.thumbnails)?.firstOrNull()?.url.orEmpty() - - is QueueEntry.LocalTrack -> "" - } -} - private fun QueueEntry.durationInMilliseconds(): Long { return when (this) { is QueueEntry.StreamingTrack -> track.durationMs diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 648d1526..6b3181ac 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -350,9 +350,9 @@ private fun QueueItemRow( .background(AppTheme.colors.surfaceVariant), contentAlignment = Alignment.Center ) { - if (item.imageUrl != null) { + if (item.artwork != null) { AsyncImage( - model = item.imageUrl, + model = item.artwork, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 1738cdf8..8da73f4c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -39,7 +39,7 @@ data class QueueItemUi( val subtitle: String, val durationLabel: String, val isCurrent: Boolean, - val imageUrl: String?, + val artwork: Any?, val originalIndex: Int, /** Participant who added this item to the jam queue, if any. */ val addedByParticipant: JamParticipant? = null) @@ -77,22 +77,18 @@ class PlayerQueueContentViewModel( val title: String val subtitle: String val durationMs: Long - val imageUrl: String? when (entry) { is QueueEntry.StreamingTrack -> { title = entry.track.title subtitle = entry.track.artists.joinToString(", ") { it.name } durationMs = entry.track.durationMs - imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url - ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url } is QueueEntry.LocalTrack -> { title = entry.name subtitle = entry.artists.joinToString(", ") durationMs = entry.duration - imageUrl = null } } @@ -104,7 +100,7 @@ class PlayerQueueContentViewModel( subtitle = subtitle, durationLabel = durationMs.toDurationLabel(), isCurrent = index == currentIndex, - imageUrl = imageUrl, + artwork = entry.artworkModel, originalIndex = index, addedByParticipant = addedByParticipant) } diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt index f8fdef07..05d2aa04 100644 --- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/MediaLibraryUiTest.kt @@ -158,7 +158,7 @@ class MediaLibraryUiTest { } @Test - fun breadcrumbIsThinAndItsParentLinkNavigatesUp() { + fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() { var height = 0 var navigatedUp = 0 val state = localTracksUiState( @@ -167,7 +167,7 @@ class MediaLibraryUiTest { currentFolderPath = "/music" ), "" ) - ImageComposeScene(360, 40) { + ImageComposeScene(360, 48) { SpotubeTheme(UserSettings(theme = Theme.DARK)) { LocalTracksBreadcrumb( state, @@ -177,7 +177,7 @@ class MediaLibraryUiTest { } }.use { scene -> repeat(6) { scene.render().close() } - assertEquals(32, height) + assertEquals(40, height) val parent = allNodes(scene).first { it.config.getOrNull(SemanticsActions.OnClick) != null && it.config.getOrNull(SemanticsProperties.ContentDescription)