feat(local-tracks): implement LocalFolder screen and view model for folder navigation

This commit is contained in:
Kingkor Roy Tirtho 2026-10-18 18:04:53 +06:00
parent c767b8dcd6
commit 75ced1285e
20 changed files with 332 additions and 211 deletions

View File

@ -202,7 +202,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
.setArtist(artist) .setArtist(artist)
.setAlbumTitle(album) .setAlbumTitle(album)
.apply { .apply {
if (coverURL.isNotBlank()) { if (coverBytes != null) {
setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (coverURL.isNotBlank()) {
setArtworkUri(android.net.Uri.parse(coverURL)) setArtworkUri(android.net.Uri.parse(coverURL))
} }
} }

View File

@ -25,7 +25,18 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.graphics.BitmapFactory
import android.graphics.Bitmap
import android.provider.MediaStore 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.spotube.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import java.io.File import java.io.File
@ -35,14 +46,39 @@ class AndroidLocalMediaDiscoveryService(
private val context: Context) : LocalMediaDiscoveryService, KoinComponent { private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
val logger by injectLogger<AndroidLocalMediaDiscoveryService>() val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> { private class TagInfo(
val title: String?,
val artists: List<String>,
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<String>,
val mediaStoreAlbum: String?,
val mediaStoreDurationMs: Long)
// Parsing tags + cover with lofty is the expensive part, so keep results across rescans.
private val tagCache = ConcurrentHashMap<String, TagInfo>()
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> = withContext(Dispatchers.IO) {
discoverFoldersBlocking(roots)
}
private suspend fun discoverFoldersBlocking(roots: List<String>): List<LocalMediaFolder> {
logger.i { "discoverFolders: start roots=${roots.size}" } logger.i { "discoverFolders: start roots=${roots.size}" }
if (!hasReadPermission()) { if (!hasReadPermission()) {
logger.w { "discoverFolders: missing media permission, returning empty result" } logger.w { "discoverFolders: missing media permission, returning empty result" }
return emptyList() return emptyList()
} }
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>() val pending = mutableListOf<PendingTrack>()
val knownFilePaths = hashSetOf<String>()
val resolver = context.contentResolver val resolver = context.contentResolver
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
var totalRows = 0 var totalRows = 0
@ -58,7 +94,9 @@ class AndroidLocalMediaDiscoveryService(
MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION, 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) arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
} else { } else {
emptyArray() emptyArray()
@ -77,6 +115,8 @@ class AndroidLocalMediaDiscoveryService(
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE) val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH) 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 { logger.d {
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)" "discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
@ -118,20 +158,23 @@ class AndroidLocalMediaDiscoveryService(
.takeUnless { it.equals("<unknown>", true) } .takeUnless { it.equals("<unknown>", true) }
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() } val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L) 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 { val name = rawTitle.ifBlank {
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
} }
val track = LocalMediaTrack( pending += PendingTrack(
path = uri, uri = uri,
name = name.ifBlank { "Unknown track" }, filePath = filePath,
artists = listOfNotNull(artist), modifiedAt = modifiedAt,
durationMs = durationMs, folderPath = folderPath,
album = album, fallbackName = name.ifBlank { "Unknown track" },
coverBytes = null) mediaStoreArtists = listOfNotNull(artist),
mediaStoreAlbum = album,
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) mediaStoreDurationMs = durationMs)
acceptedRows += 1 acceptedRows += 1
} }
} ?: run { } ?: run {
@ -142,20 +185,13 @@ class AndroidLocalMediaDiscoveryService(
logger.w { "discoverFolders: MediaStore query returned null cursor" } logger.w { "discoverFolders: MediaStore query returned null cursor" }
} }
val mediaStoreFolders = tracksByFolder.entries // Files MediaStore already indexed must not be listed a second time by the filesystem scan.
.map { (folderPath, tracks) -> discoverFileSystemFallback(roots, knownFilePaths, pending)
LocalMediaFolder(
path = folderPath,
name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
}
.sortedBy { it.name.lowercase(Locale.getDefault()) }
val fallbackFolders = discoverFileSystemFallback(roots) val merged = buildFolders(pending)
val merged = mergeFolders(mediaStoreFolders, fallbackFolders)
logger.i { 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()) { if (merged.isNotEmpty()) {
@ -221,56 +257,109 @@ class AndroidLocalMediaDiscoveryService(
return extension in SUPPORTED_EXTENSIONS return extension in SUPPORTED_EXTENSIONS
} }
private fun discoverFileSystemFallback(roots: List<String>): List<LocalMediaFolder> { private fun discoverFileSystemFallback(
roots: List<String>,
knownFilePaths: Set<String>,
out: MutableList<PendingTrack>) {
val candidateRoots = resolveCandidateRoots(roots) val candidateRoots = resolveCandidateRoots(roots)
if (candidateRoots.isEmpty()) { if (candidateRoots.isEmpty()) {
logger.d { "discoverFileSystemFallback: no candidate roots" } logger.d { "discoverFileSystemFallback: no candidate roots" }
return emptyList() return
} }
val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/"
logger.d { val seen = hashSetOf<String>()
"discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}" var added = 0
}
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
var scannedFiles = 0
var acceptedFiles = 0
candidateRoots.forEach { root -> candidateRoots.forEach { root ->
scanDirectory(root) { file -> scanDirectory(root) { file ->
scannedFiles += 1 if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory
val extension = file.extension.lowercase(Locale.getDefault()) val absolute = file.absolutePath
if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory
acceptedFiles += 1
val folderPath = file.parentFile?.absolutePath ?: "Unknown" // Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge.
val name = file.nameWithoutExtension.ifBlank { file.name } val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/"
val track = LocalMediaTrack( val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent
path = file.absolutePath, out += PendingTrack(
name = name, uri = absolute,
artists = emptyList(), filePath = absolute,
durationMs = 0L, modifiedAt = file.lastModified() / 1000,
album = null, folderPath = folderPath,
coverBytes = null) fallbackName = file.nameWithoutExtension.ifBlank { file.name },
mediaStoreArtists = emptyList(),
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) mediaStoreAlbum = null,
mediaStoreDurationMs = 0L)
added += 1
} }
} }
logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" }
}
val folders = tracksByFolder.entries private suspend fun buildFolders(pending: List<PendingTrack>): List<LocalMediaFolder> {
.map { (folderPath, tracks) -> 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( LocalMediaFolder(
path = folderPath, path = path,
name = normalizeFolderName(folderPath), name = normalizeFolderName(path),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) })
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
logger.i {
"discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}"
} }
return folders 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
}
// 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<String>): List<File> { private fun resolveCandidateRoots(roots: List<String>): List<File> {
@ -309,35 +398,8 @@ class AndroidLocalMediaDiscoveryService(
} }
} }
private fun mergeFolders(
mediaStoreFolders: List<LocalMediaFolder>,
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
if (fallbackFolders.isEmpty()) return mediaStoreFolders
if (mediaStoreFolders.isEmpty()) return fallbackFolders
val merged = linkedMapOf<String, MutableList<LocalMediaTrack>>()
val seenTrackPaths = mutableSetOf<String>()
(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 { companion object {
private const val THUMBNAIL_SIZE = 160
private val SUPPORTED_EXTENSIONS = setOf( private val SUPPORTED_EXTENSIONS = setOf(
"mp3", "mp3",
"m4a", "m4a",

View File

@ -30,7 +30,8 @@ data class MediaItem(
val duration: Duration, val duration: Duration,
val coverURL: String, val coverURL: String,
val url: String, val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
val coverBytes: ByteArray? = null)
enum class LoopState { enum class LoopState {
NONE, ONE, ALL; NONE, ONE, ALL;
@ -53,13 +54,28 @@ sealed interface QueueEntry {
val url: String val url: String
val addedBy: 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 @Serializable
@SerialName("streaming") @SerialName("streaming")
data class StreamingTrack( data class StreamingTrack(
val track: MetadataTrack, val track: MetadataTrack,
override val url: String, override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, 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 @Serializable
@SerialName("local") @SerialName("local")
@ -71,6 +87,8 @@ sealed interface QueueEntry {
val coverBytes: ByteArray?, val coverBytes: ByteArray?,
override val url: String, override val url: String,
override val addedBy: String = "") : QueueEntry { override val addedBy: String = "") : QueueEntry {
override val artworkUrl: String? get() = null
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || this::class != other::class) return false if (other == null || this::class != other::class) return false

View File

@ -463,7 +463,7 @@ class DeviceAudioPlayerQueue(
artist = track.artists.joinToString(", ") { artist -> artist.name }, artist = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title.orEmpty(), album = track.album?.title.orEmpty(),
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), coverURL = artworkUrl.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol) protocol = protocol)
} }
@ -476,7 +476,8 @@ class DeviceAudioPlayerQueue(
album = album.orEmpty(), album = album.orEmpty(),
duration = duration.milliseconds, duration = duration.milliseconds,
coverURL = "", coverURL = "",
url = url) url = url,
coverBytes = coverBytes?.takeIf { it.isNotEmpty() })
} }
} }
} }

View File

@ -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.album.LibraryAlbumsViewModel
import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel 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.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.LocalMediaCacheRepository
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
@ -133,6 +134,7 @@ val sharedModules = module {
viewModelOf(::LibraryAlbumsViewModel) viewModelOf(::LibraryAlbumsViewModel)
viewModelOf(::LibraryArtistsViewModel) viewModelOf(::LibraryArtistsViewModel)
viewModelOf(::LibraryLocalTracksViewModel) viewModelOf(::LibraryLocalTracksViewModel)
viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) }
// Plugin system // Plugin system
singleOf(::PluginManager) { singleOf(::PluginManager) {

View File

@ -26,6 +26,8 @@ import org.spotube.spotube.modules.devices.RemoteControlScreen
import org.spotube.spotube.modules.jam.JamScreen import org.spotube.spotube.modules.jam.JamScreen
import org.spotube.spotube.modules.home.HomeScreen import org.spotube.spotube.modules.home.HomeScreen
import org.spotube.spotube.modules.library.LibraryScreen 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.lyrics.LyricsScreen
import org.spotube.spotube.modules.playlist.PlaylistScreen import org.spotube.spotube.modules.playlist.PlaylistScreen
import org.spotube.spotube.modules.plugin.PluginScreen import org.spotube.spotube.modules.plugin.PluginScreen
@ -96,6 +98,9 @@ sealed interface Routes : NavKey {
@Serializable @Serializable
data object Welcome : Routes data object Welcome : Routes
@Serializable
data class LocalFolder(val folderPath: String) : Routes
} }
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@ -115,6 +120,14 @@ val navigationModule = module {
// Not guarded as a whole: Local tracks and Downloads work without a plugin. // Not guarded as a whole: Local tracks and Downloads work without a plugin.
LibraryScreen() LibraryScreen()
} }
navigation<Routes.LocalFolder> {
LocalFolderScreen(
viewModel = koinViewModel(
key = it.folderPath,
parameters = { parametersOf(it.folderPath) }
),
onBack = { get<NavigationCommands>().pop() })
}
navigation<Routes.Settings> { navigation<Routes.Settings> {
SettingsScreen(settingsViewModel = koinViewModel()) SettingsScreen(settingsViewModel = koinViewModel())
} }

View File

@ -64,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Devices::class, Routes.Devices.serializer()) subclass(Routes.Devices::class, Routes.Devices.serializer())
subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer())
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
} }
} }
} }

View File

@ -354,7 +354,7 @@ class RemoteControlHandler(
currentTrackTitle = current?.titleOrNull(), currentTrackTitle = current?.titleOrNull(),
currentTrackArtists = current?.artistsOrNull(), currentTrackArtists = current?.artistsOrNull(),
currentTrackAlbum = current?.albumOrNull(), currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull()) currentTrackCoverUrl = current?.artworkUrl)
val text = json.encodeToString(RemoteControlEvent.serializer(), state) val text = json.encodeToString(RemoteControlEvent.serializer(), state)
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
@ -395,7 +395,7 @@ class RemoteControlHandler(
title = track.title, title = track.title,
artists = track.artists.joinToString(", ") { artist -> artist.name }, artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title, album = track.album?.title,
coverUrl = coverUrlOrNull(), coverUrl = artworkUrl,
durationMs = track.durationMs) durationMs = track.durationMs)
is QueueEntry.LocalTrack -> RemoteQueueEntry( is QueueEntry.LocalTrack -> RemoteQueueEntry(
@ -427,12 +427,6 @@ class RemoteControlHandler(
is QueueEntry.StreamingTrack -> track.album?.title is QueueEntry.StreamingTrack -> track.album?.title
is QueueEntry.LocalTrack -> album 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 @Serializable

View File

@ -61,7 +61,8 @@ fun PlayableListTile(
placeholderIcon: ImageVector = Iconsax.IconsaxMusic, placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
artwork: (@Composable () -> Unit)? = null, artwork: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null,
isBlacklisted: Boolean = false isBlacklisted: Boolean = false,
compact: Boolean = false
) { ) {
val colors = MillenniumTheme.colors val colors = MillenniumTheme.colors
MillenniumListTile( MillenniumListTile(
@ -73,12 +74,12 @@ fun PlayableListTile(
borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border, borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border,
titleForeground = if (isBlacklisted) colors.danger else null, titleForeground = if (isBlacklisted) colors.danger else null,
borderWidth = 1.dp, borderWidth = 1.dp,
contentPadding = PaddingValues(12.dp), contentPadding = PaddingValues(if (compact) 8.dp else 12.dp),
minimumHeight = 76.dp, minimumHeight = if (compact) 52.dp else 76.dp,
leading = { leading = {
MediaArtwork( MediaArtwork(
imageModel, title, imageModel, title,
Modifier.size(52.dp).sharedElementOrNone( Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone(
sharedElementKey, sharedElementKey,
LocalSharedTransitionScope.current, LocalSharedTransitionScope.current,
LocalAnimatedVisibilityScope.current LocalAnimatedVisibilityScope.current
@ -114,7 +115,7 @@ fun PlayableListTile(
if (onAddToQueue != null && !isPlaying) { if (onAddToQueue != null && !isPlaying) {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
MillenniumIcon( MillenniumIcon(
@ -126,7 +127,7 @@ fun PlayableListTile(
if (onPlay != null) { if (onPlay != null) {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
modifier = Modifier.size(40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {
MillenniumIcon( MillenniumIcon(

View File

@ -47,7 +47,10 @@ import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen
import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.spotube.spotube.resources.iconsax.IconsaxPlay
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.pluralStringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel 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.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
@ -63,15 +66,18 @@ import spotube.composeapp.generated.resources.*
fun LibraryLocalTracksScreen( fun LibraryLocalTracksScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel(), viewModel: LibraryLocalTracksViewModel = koinViewModel(),
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel() foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(),
navigationCommands: NavigationCommands = koinInject()
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permission = rememberLocalMediaPermissionState() val permission = rememberLocalMediaPermissionState()
// Platform permission events only; the ViewModel decides whether/when to index media. // Platform permission events only; the ViewModel decides whether/when to index media.
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent( LocalTracksContent(
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, state,
viewModel::playCurrentFolder, permission.requestPermission, modifier, onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) },
onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {},
onRequestPermission = permission.requestPermission, modifier = modifier,
onManageFolders = foldersViewModel::open onManageFolders = foldersViewModel::open
) )
LocalMediaFoldersDialogHost(foldersViewModel) LocalMediaFoldersDialogHost(foldersViewModel)
@ -120,7 +126,7 @@ internal fun LocalTracksContent(
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn( state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
Modifier.weight(1f).fillMaxWidth(), Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = inset + 16.dp), contentPadding = PaddingValues(bottom = inset + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(state.visibleFolders, key = { it.path }) { folder -> items(state.visibleFolders, key = { it.path }) { folder ->
LocalFolderRow(folder) { onOpenFolder(folder.path) } LocalFolderRow(folder) { onOpenFolder(folder.path) }
@ -130,7 +136,7 @@ internal fun LocalTracksContent(
state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn( state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
Modifier.weight(1f).fillMaxWidth(), Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = inset + 16.dp), contentPadding = PaddingValues(bottom = inset + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(state.visibleTracks, key = { it.path }) { track -> items(state.visibleTracks, key = { it.path }) { track ->
PlayableListTile( PlayableListTile(
@ -139,6 +145,7 @@ internal fun LocalTracksContent(
imageModel = track.artwork, imageModel = track.artwork,
onClick = { onPlayTrack(track.path) }, onClick = { onPlayTrack(track.path) },
onPlay = { onPlayTrack(track.path) }, onPlay = { onPlayTrack(track.path) },
compact = true,
trailingContent = { trailingContent = {
track.durationLabel?.let { track.durationLabel?.let {
Text( Text(
@ -195,7 +202,7 @@ internal fun LocalTracksBreadcrumb(
) { ) {
val backDescription = stringResource(Res.string.library_local_back) val backDescription = stringResource(Res.string.library_local_back)
Row( Row(
modifier.fillMaxWidth().height(32.dp), modifier.fillMaxWidth().height(40.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@ -268,22 +275,22 @@ internal fun LocalTracksBreadcrumb(
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp) if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp)
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton( if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
onClick = onManageFolders, onClick = onManageFolders,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxFolderAdd, Iconsax.IconsaxFolderAdd,
stringResource(Res.string.library_local_add_folder), stringResource(Res.string.library_local_add_folder),
modifier = Modifier.size(14.dp) modifier = Modifier.size(22.dp)
) )
} }
if (state.currentFolderPath != null) MillenniumIconButton( if (state.currentFolderPath != null) MillenniumIconButton(
onClick = onPlayFolder, enabled = state.canPlayFolder, onClick = onPlayFolder, enabled = state.canPlayFolder,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxPlay, Iconsax.IconsaxPlay,
stringResource(Res.string.library_local_play_folder), 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), shape = RoundedCornerShape(8.dp),
borderColor = MillenniumTheme.colors.border, borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp, borderWidth = 1.dp,
contentPadding = PaddingValues(12.dp), contentPadding = PaddingValues(8.dp),
minimumHeight = 76.dp, minimumHeight = 52.dp,
leading = { leading = {
MediaArtwork( MediaArtwork(
folder.artwork, folder.artwork,
folder.name, folder.name,
Modifier.size(52.dp), Modifier.size(36.dp),
placeholderIcon = Iconsax.IconsaxFolderOpen placeholderIcon = Iconsax.IconsaxFolderOpen
) )
}, },
@ -328,7 +335,7 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) {
MillenniumIcon( MillenniumIcon(
FeatherIcons.ChevronRight, FeatherIcons.ChevronRight,
null, null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(16.dp)
) )
} }
) )

View File

@ -19,8 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.LibraryState
import org.spotube.spotube.modules.library.LibraryTab import org.spotube.spotube.modules.library.LibraryTab
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder
@ -120,7 +118,6 @@ private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean =
class LibraryLocalTracksViewModel( class LibraryLocalTracksViewModel(
private val coordinator: LocalMediaLibraryCoordinator, private val coordinator: LocalMediaLibraryCoordinator,
private val audioPlayerQueue: AudioPlayerQueue,
private val libraryState: LibraryState private val libraryState: LibraryState
) : ViewModel() { ) : ViewModel() {
private val requiresPermission = getPlatform().type == PlatformType.Android private val requiresPermission = getPlatform().type == PlatformType.Android
@ -155,14 +152,6 @@ class LibraryLocalTracksViewModel(
libraryState.setQuery(LibraryTab.LocalTracks, query) 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) { fun onPermissionChanged(granted: Boolean) {
val accessible = !requiresPermission || granted val accessible = !requiresPermission || granted
val wasAccessible = _state.value.canAccessMedia val wasAccessible = _state.value.canAccessMedia
@ -170,10 +159,6 @@ class LibraryLocalTracksViewModel(
if (accessible && !wasAccessible) refresh() if (accessible && !wasAccessible) refresh()
} }
fun playCurrentFolder() {
_state.value.currentFolderPath?.let(::playFolder)
}
fun refresh() { fun refresh() {
if (!_state.value.canAccessMedia) return if (!_state.value.canAccessMedia) return
viewModelScope.launch { 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() { private fun refreshIfEmpty() {
if (_state.value.folders.isNotEmpty()) return if (_state.value.folders.isNotEmpty()) return
refresh() refresh()
@ -239,22 +189,4 @@ class LibraryLocalTracksViewModel(
} }
} }
} }
private fun filterTracks(tracks: List<LocalMediaTrack>, query: String): List<LocalMediaTrack> {
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
)
}
} }

View File

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

View File

@ -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<LibraryLocalTracksState> = 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<LocalMediaTrack> =
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
)
}
}
}

View File

@ -211,7 +211,7 @@ fun AppExpandedPlayer(
val snackbarHostState = remember { MillenniumSnackbarHostState() } val snackbarHostState = remember { MillenniumSnackbarHostState() }
val albumArtModifier = val albumArtModifier =
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } val coverModel = playerUiState.coverModel
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
val moreOptionsSheetState = rememberModalBottomSheetState( val moreOptionsSheetState = rememberModalBottomSheetState(

View File

@ -131,7 +131,7 @@ internal fun FloatingPlayerContent(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
heartButton: @Composable () -> Unit heartButton: @Composable () -> Unit
) { ) {
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } val coverModel = playerUiState.coverModel
Box( Box(
modifier = modifier modifier = modifier

View File

@ -164,7 +164,7 @@ fun AppLargePlayer(
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) } 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) PlayerOptionDialogs(viewModel = playerOptionsViewModel)

View File

@ -45,7 +45,6 @@ internal data class PlayerUiState(
val artists: String = "No-op artists", val artists: String = "No-op artists",
val artistList: List<PlayerArtist> = emptyList(), val artistList: List<PlayerArtist> = emptyList(),
val album: String = "", val album: String = "",
val coverUrl: String = "",
val position: Duration = 0.milliseconds, val position: Duration = 0.milliseconds,
val actualDuration: Duration = 0.milliseconds, val actualDuration: Duration = 0.milliseconds,
val metadataDuration: Duration = 0.milliseconds, val metadataDuration: Duration = 0.milliseconds,
@ -55,6 +54,8 @@ internal data class PlayerUiState(
val volume: Float = 0f val volume: Float = 0f
) { ) {
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
val coverModel: Any? get() = currentQueueEntry?.artworkModel
val displayDuration: Duration val displayDuration: Duration
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
val seekDuration: Duration val seekDuration: Duration
@ -72,7 +73,7 @@ internal data class PlayerUiState(
queue=${queue.map { it.displayTitle() }}, queue=${queue.map { it.displayTitle() }},
currentQueueEntry=${currentQueueEntry?.displayTitle()}, currentQueueEntry=${currentQueueEntry?.displayTitle()},
title='$title', artists='$artists', title='$title', artists='$artists',
album='$album', coverUrl='$coverUrl', album='$album', coverModel=${coverModel?.let { it::class.simpleName }},
position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration, position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration,
playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume
) )
@ -105,7 +106,6 @@ internal fun rememberPlayerUiState(
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists }, artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists },
artistList = currentQueueEntry?.displayArtistList().orEmpty(), artistList = currentQueueEntry?.displayArtistList().orEmpty(),
album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(), album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(),
coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
position = position, position = position,
actualDuration = duration, actualDuration = duration,
metadataDuration = metadataDuration, 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 { private fun QueueEntry.durationInMilliseconds(): Long {
return when (this) { return when (this) {
is QueueEntry.StreamingTrack -> track.durationMs is QueueEntry.StreamingTrack -> track.durationMs

View File

@ -350,9 +350,9 @@ private fun QueueItemRow(
.background(AppTheme.colors.surfaceVariant), .background(AppTheme.colors.surfaceVariant),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (item.imageUrl != null) { if (item.artwork != null) {
AsyncImage( AsyncImage(
model = item.imageUrl, model = item.artwork,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@ -39,7 +39,7 @@ data class QueueItemUi(
val subtitle: String, val subtitle: String,
val durationLabel: String, val durationLabel: String,
val isCurrent: Boolean, val isCurrent: Boolean,
val imageUrl: String?, val artwork: Any?,
val originalIndex: Int, val originalIndex: Int,
/** Participant who added this item to the jam queue, if any. */ /** Participant who added this item to the jam queue, if any. */
val addedByParticipant: JamParticipant? = null) val addedByParticipant: JamParticipant? = null)
@ -77,22 +77,18 @@ class PlayerQueueContentViewModel(
val title: String val title: String
val subtitle: String val subtitle: String
val durationMs: Long val durationMs: Long
val imageUrl: String?
when (entry) { when (entry) {
is QueueEntry.StreamingTrack -> { is QueueEntry.StreamingTrack -> {
title = entry.track.title title = entry.track.title
subtitle = entry.track.artists.joinToString(", ") { it.name } subtitle = entry.track.artists.joinToString(", ") { it.name }
durationMs = entry.track.durationMs 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 -> { is QueueEntry.LocalTrack -> {
title = entry.name title = entry.name
subtitle = entry.artists.joinToString(", ") subtitle = entry.artists.joinToString(", ")
durationMs = entry.duration durationMs = entry.duration
imageUrl = null
} }
} }
@ -104,7 +100,7 @@ class PlayerQueueContentViewModel(
subtitle = subtitle, subtitle = subtitle,
durationLabel = durationMs.toDurationLabel(), durationLabel = durationMs.toDurationLabel(),
isCurrent = index == currentIndex, isCurrent = index == currentIndex,
imageUrl = imageUrl, artwork = entry.artworkModel,
originalIndex = index, originalIndex = index,
addedByParticipant = addedByParticipant) addedByParticipant = addedByParticipant)
} }

View File

@ -158,7 +158,7 @@ class MediaLibraryUiTest {
} }
@Test @Test
fun breadcrumbIsThinAndItsParentLinkNavigatesUp() { fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() {
var height = 0 var height = 0
var navigatedUp = 0 var navigatedUp = 0
val state = localTracksUiState( val state = localTracksUiState(
@ -167,7 +167,7 @@ class MediaLibraryUiTest {
currentFolderPath = "/music" currentFolderPath = "/music"
), "" ), ""
) )
ImageComposeScene(360, 40) { ImageComposeScene(360, 48) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) { SpotubeTheme(UserSettings(theme = Theme.DARK)) {
LocalTracksBreadcrumb( LocalTracksBreadcrumb(
state, state,
@ -177,7 +177,7 @@ class MediaLibraryUiTest {
} }
}.use { scene -> }.use { scene ->
repeat(6) { scene.render().close() } repeat(6) { scene.render().close() }
assertEquals(32, height) assertEquals(40, height)
val parent = allNodes(scene).first { val parent = allNodes(scene).first {
it.config.getOrNull(SemanticsActions.OnClick) != null && it.config.getOrNull(SemanticsActions.OnClick) != null &&
it.config.getOrNull(SemanticsProperties.ContentDescription) it.config.getOrNull(SemanticsProperties.ContentDescription)