Compare commits

...

6 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
784d9260d1
Merge pull request #3132 from wuqqers/fix/desugar-jdk-libs-nio
fix(android): use desugar_jdk_libs_nio so NewPipeExtractor works on minSdk 28
2026-11-03 13:43:44 +06:00
Kingkor Roy Tirtho
9a47a1af5f
Merge branch 'dev' into fix/desugar-jdk-libs-nio 2026-11-03 13:42:50 +06:00
Kingkor Roy Tirtho
431fd2353f
Merge pull request #3204 from team-spotube/fix/large-list-memory
fix: memory blow-ups and crashes with large lists and local media
2026-11-03 13:15:19 +06:00
Kingkor Roy Tirtho
f23c66c0c5 build: raise the desktop heap ceiling to 1 GB
384 MB left little headroom for large libraries and long sessions. It is
a ceiling, not a reservation: the serial GC only grows the heap to what
is live.
2026-11-02 14:20:10 +06:00
Kingkor Roy Tirtho
8537718225 fix: stop memory blow-ups and crashes with large lists and local media
TrackList rendered every loaded track inside one LazyColumn item, so all
rows stayed composed and recomposed together; scrolling ~1350 saved
tracks exhausted the desktop heap (#3200). Rows are now separate lazy
items and the card is drawn once behind them. Only the current row
depends on the playing state, the saved tracks screen no longer reloads
from page 1 whenever a page arrives, cached tracks are streamed in 64 KB
chunks instead of read whole, and queue loading/restoring maps entries
in one pass instead of quadratically.

Desktop kept full-size embedded covers for local files and serialized
them as JSON number arrays into the local media index and the queue,
which is persisted on every change (#3193). Covers are now shrunk to
160 px like on Android, the queue no longer persists them (restore fills
them in from the index), and store files already bloated past 8 MB are
dropped instead of loaded.

Lists keyed on plugin ids crashed on blank or repeated ids (#3196); they
now go through uniqueLazyKeys. Queue persistence failures are logged
instead of crashing the app, a likely cause of #3198.

Fixes #3200
Fixes #3193
Fixes #3196
2026-11-02 13:37:05 +06:00
Omer Kaya Ekici
ed3743bbea fix(android): use desugar_jdk_libs_nio so URLEncoder.encode(String, Charset) is backported on API < 33
The default desugar_jdk_libs artifact does not include the java.nio/charset backing APIs, so on API < 33 (minSdk 28) NewPipeExtractor's URLEncoder.encode(String, Charset) call throws NoSuchMethodError and the app crashes on launch. The _nio variant backports those APIs (2.1.5 is available for it and matches the AGP 8.13.2 requirement).

Refs #3130
2026-09-20 10:43:52 +03:00
21 changed files with 701 additions and 158 deletions

View File

@ -449,7 +449,7 @@ nucleus.application {
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM --- // --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) "-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
) )
@ -556,7 +556,7 @@ nucleus.application {
// ProGuard is not used for native images; -Os is the size equivalent // ProGuard is not used for native images; -Os is the size equivalent
optimization = NativeImageOptimization.SIZE optimization = NativeImageOptimization.SIZE
// Same ceiling as the JVM distribution's -Xmx // Same ceiling as the JVM distribution's -Xmx
maxHeapSize = "384m" maxHeapSize = "1g"
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
// a second ~30 MB copy as a resource // a second ~30 MB copy as a resource
buildArgs.add("-H:+UnlockExperimentalVMOptions") buildArgs.add("-H:+UnlockExperimentalVMOptions")

View File

@ -21,6 +21,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlin.time.Duration import kotlin.time.Duration
data class MediaItem( data class MediaItem(
@ -84,7 +85,9 @@ sealed interface QueueEntry {
val artists: List<String>, val artists: List<String>,
val duration: Long, val duration: Long,
val album: String?, val album: String?,
val coverBytes: ByteArray?, // Not persisted: the queue is saved on every change and covers made it hundreds of MB
// of JSON (#3193). Restore fills them back in from the local media index.
@Transient val coverBytes: ByteArray? = null,
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 val artworkUrl: String? get() = null

View File

@ -20,8 +20,10 @@ package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -130,14 +133,15 @@ class DeviceAudioPlayerQueue(
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?) { collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val resolved = resolveMediaItems(entries)
val mediaItems = resolved.map { it.first }
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
logger.w { "load: no valid media items after filtering blanks" } logger.w { "load: no valid media items after filtering blanks" }
} }
val normalizedStartPosition = val normalizedStartPosition =
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } entryByMediaUrl.value = entriesByUrl(resolved)
updateCollectionContext(collectionEntry) updateCollectionContext(collectionEntry)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady() persistQueueStateIfReady()
@ -401,19 +405,66 @@ class DeviceAudioPlayerQueue(
return return
} }
val mediaItems = state.entries.mapNotNull { it.toMediaItem() } val restored = resolveMediaItems(withLocalCovers(state.entries))
if (mediaItems.isEmpty()) { if (restored.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" } logger.w { "restorePersistedState: all entries had blank urls" }
return return
} }
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } val mediaItems = restored.map { it.first }
entryByMediaUrl.value = entriesByUrl(restored)
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
logger.i { "Persisted state restored successfully" } logger.i { "Persisted state restored successfully" }
} }
/**
* Converts each entry once. Looking every item's entry up again by converting the whole list
* per item was quadratic: ~900k suspending conversions to start a 1353-track playlist.
*/
private suspend fun resolveMediaItems(entries: List<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
/** First entry wins for a repeated url, as before. */
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
}
/** Local covers aren't persisted with the queue; look them up in the local media index. */
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
if (entries.none { it is QueueEntry.LocalTrack }) return entries
val covers = runCatching {
get<LocalMediaCacheRepository>().cacheFlow.first().folders
.flatMap { it.tracks }
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
.toMap()
}.getOrElse {
logger.w(it) { "Couldn't read local media index for queue covers" }
emptyMap()
}
if (covers.isEmpty()) return entries
return entries.map { entry ->
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
} else entry
}
}
/**
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
*/
private suspend fun persistQueueStateIfReady() { private suspend fun persistQueueStateIfReady() {
try {
persistQueueState()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e(e) { "Failed to persist queue state" }
}
}
private suspend fun persistQueueState() {
if (isRestoringPersistedState) { if (isRestoringPersistedState) {
logger.v { "Skipping persist: still restoring persisted state" } logger.v { "Skipping persist: still restoring persisted state" }
return return

View File

@ -61,13 +61,13 @@ class Database(val paths: Paths) {
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy { val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) }
) )
} }
val localMediaDataStore: DataStore<Preferences> by lazy { val localMediaDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) }
) )
} }
@ -83,7 +83,23 @@ class Database(val paths: Paths) {
) )
} }
/**
* DataStore reads a whole preferences file into the heap. Earlier builds stored full-size
* embedded covers as JSON number arrays in the queue and local media stores, growing them to
* hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so
* drop one that is far past any sane size instead of loading it.
*/
private fun dropIfOversized(path: okio.Path): okio.Path {
runCatching {
val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path
if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path)
}
return path
}
private companion object { private companion object {
const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024
/** Preference files written by previous launches; their presence means an upgrade. */ /** Preference files written by previous launches; their presence means an upgrade. */
val EXISTING_INSTALL_PROBE_FILES = listOf( val EXISTING_INSTALL_PROBE_FILES = listOf(
"spotube.preferences_pb", "spotube.preferences_pb",

View File

@ -196,22 +196,18 @@ class CacheManager(
val (start, end) = range val (start, end) = range
val length = end - start + 1 val length = end - start + 1
val contentRange = "bytes $start-$end/$fileSize" val contentRange = "bytes $start-$end/$fileSize"
fileSystem.source(filePath).buffer().use { source ->
source.skip(start)
val bytes = source.readByteArray(length)
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.PartialContent override val status: HttpStatusCode = HttpStatusCode.PartialContent
override val contentLength: Long = bytes.size.toLong() override val contentLength: Long = length
override val contentType: ContentType = entry.toContentType() override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply { override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.ContentRange, contentRange) append(HttpHeaders.ContentRange, contentRange)
append(HttpHeaders.AcceptRanges, "bytes") append(HttpHeaders.AcceptRanges, "bytes")
}.build() }.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(bytes) streamFileRange(fileSystem, filePath, start, length, channel)
} }
}) })
}
return return
} }
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
@ -223,20 +219,43 @@ class CacheManager(
return return
} }
fileSystem.source(filePath).buffer().use { source ->
val bytes = source.readByteArray()
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.OK override val status: HttpStatusCode = HttpStatusCode.OK
override val contentLength: Long = bytes.size.toLong() override val contentLength: Long = fileSize
override val contentType: ContentType = entry.toContentType() override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply { override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.AcceptRanges, "bytes") append(HttpHeaders.AcceptRanges, "bytes")
}.build() }.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(bytes) streamFileRange(fileSystem, filePath, 0, fileSize, channel)
} }
}) })
} }
}
private const val STREAM_CHUNK_BYTES = 64 * 1024
/**
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
* player re-requests them on every seek, so they must never be read into the heap whole.
*/
internal suspend fun streamFileRange(
fileSystem: FileSystem,
filePath: Path,
start: Long,
length: Long,
channel: io.ktor.utils.io.ByteWriteChannel) {
fileSystem.source(filePath).buffer().use { source ->
source.skip(start)
val chunk = ByteArray(STREAM_CHUNK_BYTES)
var remaining = length
while (remaining > 0) {
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
if (read == -1) break
channel.writeFully(chunk, 0, read)
remaining -= read
}
} }
} }

View File

@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,6 +81,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@ -96,6 +98,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.ui.misc.SkeletonTree import org.spotube.spotube.core.ui.misc.SkeletonTree
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
import org.spotube.spotube.core.ui.misc.TextWithShimmer import org.spotube.spotube.core.ui.misc.TextWithShimmer
import org.spotube.spotube.core.ui.misc.shimmerApply import org.spotube.spotube.core.ui.misc.shimmerApply
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
@ -119,6 +122,42 @@ private val CompactTrackListBreakpoint = 600.dp
private val LargeTrackListBreakpoint = 840.dp private val LargeTrackListBreakpoint = 840.dp
private const val ShimmerRowCount = 6 private const val ShimmerRowCount = 6
private val TrackCardOuterPadding = 8.dp
private val TrackCardInnerPadding = 12.dp
private const val TrackCardTopKey = "track-card-top"
private const val TrackCardRowKeyPrefix = "track-card-row:"
private const val TrackCardFooterKey = "track-card-footer"
private const val TrackCardBottomKey = "track-card-bottom"
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
/**
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
*/
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
layout { measurable, constraints ->
val info = listState.layoutInfo
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
// Item offsets are relative to the viewport start, which is negative by the top padding.
val toLocal = -info.viewportStartOffset
val viewport = info.viewportEndOffset - info.viewportStartOffset
val overscan = viewport * 3
val first = cardItems.first()
val last = cardItems.last()
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
val bottom = if (last.key == TrackCardBottomKey) {
last.offset + last.size + toLocal - edgeInsetPx
} else viewport + overscan
val height = (bottom - top).coerceAtLeast(0)
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
}
enum class TrackSortOption(val label: StringResource) { enum class TrackSortOption(val label: StringResource) {
None(Res.string.track_list_sort_original), None(Res.string.track_list_sort_original),
Title(Res.string.track_list_sort_title), Title(Res.string.track_list_sort_title),
@ -198,7 +237,7 @@ fun TrackList(
val shouldLoadMore = remember(density) { val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
val loadMoreThreshold = with(density) { 200.dp.toPx() } val loadMoreThreshold = with(density) { 200.dp.toPx() }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
// Trigger when the bottom of the track card is close to/inside the viewport, // Trigger when the bottom of the track card is close to/inside the viewport,
// which means the user has scrolled through (or is about to scroll through) // which means the user has scrolled through (or is about to scroll through)
// all loaded tracks. // all loaded tracks.
@ -219,21 +258,7 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = { val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
MillenniumCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(0.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
MillenniumSeparator()
}
TrackListRow( TrackListRow(
index = displayedIndex + 1, index = displayedIndex + 1,
track = track, track = track,
@ -241,7 +266,8 @@ fun TrackList(
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions, useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId, isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying, // Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
isSelectionMode = isSelectionMode, isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id), isSelected = selectedTrackIds.contains(track.id),
onTrackClick = { onTrackClick = {
@ -289,6 +315,8 @@ fun TrackList(
onArtistsOverflowClick = { onArtistsOverflowClick(track) }) onArtistsOverflowClick = { onArtistsOverflowClick(track) })
} }
// Shimmer, error, empty and next-page rows at the end of the card.
val trackCardFooter: @Composable () -> Unit = {
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex -> repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
@ -329,6 +357,26 @@ fun TrackList(
useDropdownForOptions = useDropdownForOptions) useDropdownForOptions = useDropdownForOptions)
} }
} }
val trackCardContent: @Composable () -> Unit = {
MillenniumCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TrackCardOuterPadding),
contentPadding = PaddingValues(0.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, track)
}
trackCardFooter()
}
} }
} }
@ -454,26 +502,54 @@ fun TrackList(
if (scrollable) { if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
LazyColumn( Box(
state = listState,
modifier = Modifier modifier = Modifier
.widthIn(max = 1280.dp) .widthIn(max = 1280.dp)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
contentPadding = contentPadding, // Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
verticalArrangement = Arrangement.spacedBy(2.dp)) { // they sit on is drawn once behind the list, spanning the card's laid-out items.
MillenniumCard(
modifier = Modifier
.matchParentSize()
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
contentPadding = PaddingValues(0.dp)) {}
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
// gaps around the card are applied to the items before and after it instead.
LazyColumn(
state = listState,
contentPadding = contentPadding) {
if (headerContent != null) { if (headerContent != null) {
item { item {
headerContent() Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
} }
} }
if (!simplified) { if (!simplified) {
item { filterSortRow() } item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
}
item(key = TrackCardTopKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
}
// Plugin ids can repeat (the same track twice in a playlist).
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
Column(Modifier.fillMaxWidth()) {
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, visibleTracks[displayedIndex])
}
}
item(key = TrackCardFooterKey) {
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
}
item(key = TrackCardBottomKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
} }
item(key = "track-card") { trackCardContent() }
if (footerContent != null) { if (footerContent != null) {
item { item {
footerContent() Box(Modifier.padding(top = 2.dp)) { footerContent() }
}
} }
} }
} }

View File

@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.misc
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
/*
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
*/
private const val DUPLICATE_SEPARATOR = '\u0000'
/**
* One key per item: an item keeps its id the first time it appears, so scroll position and
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
* a real id.
*/
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
val occurrences = HashMap<String, Int>(items.size)
return items.map { item ->
val base = id(item)
val occurrence = occurrences[base] ?: 0
occurrences[base] = occurrence + 1
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
}
}
fun <T> LazyListScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}
fun <T> LazyGridScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyGridItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.artist package org.spotube.spotube.modules.artist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -638,7 +639,7 @@ private fun AlbumsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(albums, key = { it.id }) { album -> itemsWithUniqueKeys(albums, id = { it.id }) { album ->
AlbumCard(album = album) AlbumCard(album = album)
} }
} }
@ -676,7 +677,7 @@ private fun RelatedArtistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(artists, key = { it.id }) { artist -> itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
ArtistCard(artist = artist) ArtistCard(artist = artist)
} }
} }
@ -714,7 +715,7 @@ private fun FeaturedPlaylistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(playlists, key = { it.id }) { playlist -> itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
PlaylistCard(playlist = playlist) PlaylistCard(playlist = playlist)
} }
} }

View File

@ -3,6 +3,7 @@
*/ */
package org.spotube.spotube.modules.library package org.spotube.spotube.modules.library
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -58,7 +59,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
else { else {
if (header != null) item(key = "library_header") { header(true) } if (header != null) item(key = "library_header") { header(true) }
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) }
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
} }
} }
@ -71,7 +72,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
else { else {
if (header != null) item(key = "library_header") { header(false) } if (header != null) item(key = "library_header") { header(false) }
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) }
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
} }
} }

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.library.playlist package org.spotube.spotube.modules.library.playlist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
@ -195,7 +196,7 @@ private fun PlaylistPickerContent(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(playlists, key = { it.id }) { playlist -> itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
PlaylistPickerRow( PlaylistPickerRow(
playlist = playlist, playlist = playlist,
onClick = { onPlaylistSelected(playlist.id) }) onClick = { onPlaylistSelected(playlist.id) })

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.playlist package org.spotube.spotube.modules.playlist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
@ -138,7 +139,7 @@ fun AddTracksToPlaylistDialog(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(state.results, key = { it.track.id }) { result -> itemsWithUniqueKeys(state.results, id = { it.track.id }) { result ->
TrackSearchResultRow( TrackSearchResultRow(
track = result.track, track = result.track,
isAdding = result.isAdding, isAdding = result.isAdding,

View File

@ -50,6 +50,14 @@ class SavedTracksRepository(
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow() val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
/**
* Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow]
* also grows while pages and saved-states are fetched, so reloading the list on it restarted
* paging on every page (#3200).
*/
private val _libraryChanges = MutableStateFlow(0)
val libraryChanges: StateFlow<Int> = _libraryChanges.asStateFlow()
init { init {
scope.launch { scope.launch {
pluginManager.selectedMetadataPlugin pluginManager.selectedMetadataPlugin
@ -71,6 +79,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value = emptySet() savedTrackIds.value = emptySet()
_libraryChanges.value++
} }
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
@ -109,6 +118,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value += ids.toSet() savedTrackIds.value += ids.toSet()
_libraryChanges.value++
} }
} }
@ -122,6 +132,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value -= ids.toSet() savedTrackIds.value -= ids.toSet()
_libraryChanges.value++
} }
} }

View File

@ -152,7 +152,7 @@ class SavedTracksViewModel(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged(), .distinctUntilChanged(),
repository.savedTracksIdsFlow repository.libraryChanges
) { _, _ -> }.collect { ) { _, _ -> }.collect {
loadInitialData() loadInitialData()
loadCurrentUser() loadCurrentUser()

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.shell.alternative_track package org.spotube.spotube.modules.shell.alternative_track
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
@ -119,9 +120,9 @@ fun AlternativeTrackContent(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(6.dp)) {
items( itemsWithUniqueKeys(
items = uiState.alternatives, items = uiState.alternatives,
key = { source -> source.id }) { source -> id = { source -> source.id }) { source ->
AlternativeSourceRow( AlternativeSourceRow(
source = source, source = source,
isActive = source.id == uiState.activeSourceId, isActive = source.id == uiState.activeSourceId,

View File

@ -8,8 +8,10 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.dsl.module import org.koin.dsl.module
@ -423,7 +425,10 @@ class DeviceAudioPlayerQueueTest {
) )
val queue = createQueue() val queue = createQueue()
withContext(Dispatchers.Default) { } // yield to let init coroutine run // Restore runs on Dispatchers.IO; wait for it on real time rather than assuming one yield is enough.
withContext(Dispatchers.Default) {
withTimeout(5_000) { while (fakePlayer.loadCallCount == 0) delay(10) }
}
assertEquals(1, fakePlayer.loadCallCount) assertEquals(1, fakePlayer.loadCallCount)
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry()) assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())

View File

@ -19,6 +19,13 @@ package org.spotube.spotube.modules.library.local_tracks.media
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.FilterMipmap
import org.jetbrains.skia.FilterMode
import org.jetbrains.skia.Image
import org.jetbrains.skia.MipmapMode
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@ -70,7 +77,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
durationMs = metadata?.durationMs ?: 0L, durationMs = metadata?.durationMs ?: 0L,
album = metadata?.album, album = metadata?.album,
coverBytes = metadata?.coverBytes) coverBytes = metadata?.coverBytes?.let(::coverThumbnail))
folders.getOrPut(parent) { mutableListOf() }.add(track) folders.getOrPut(parent) { mutableListOf() }.add(track)
} }
} }
@ -187,3 +194,30 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
"wma") "wma")
} }
} }
/**
* Embedded covers can be several MB each and the index is serialised to JSON, so keep a small
* JPEG like Android does (#3193). Returns null for unreadable art.
*/
internal fun coverThumbnail(bytes: ByteArray, size: Int = COVER_THUMBNAIL_SIZE): ByteArray? = runCatching {
Image.makeFromEncoded(bytes).use { source ->
val scale = size.toFloat() / minOf(source.width, source.height)
if (scale >= 1f && bytes.size <= MAX_SMALL_COVER_BYTES) return@runCatching bytes
val width = (source.width * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
val height = (source.height * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
Surface.makeRasterN32Premul(width, height).use { surface ->
surface.canvas.drawImageRect(
source,
Rect.makeWH(source.width.toFloat(), source.height.toFloat()),
Rect.makeWH(width.toFloat(), height.toFloat()),
FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE),
null,
true)
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.JPEG, 85)?.bytes }
}
}
}.getOrNull()
private const val COVER_THUMBNAIL_SIZE = 160
private const val MAX_SMALL_COVER_BYTES = 64 * 1024

View File

@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.server
import io.ktor.utils.io.ByteChannel
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.io.readByteArray
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import java.nio.file.Files
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertContentEquals
class StreamFileRangeTest {
// Several chunks plus a partial one.
private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234)
private fun copy(start: Long, length: Long): ByteArray = runBlocking {
val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() }
Files.write(file, data)
val channel = ByteChannel()
launch {
streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel)
channel.flushAndClose()
}
channel.readRemaining().readByteArray()
}
@Test
fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong()))
@Test
fun rangeAcrossChunks() {
val start = 70_000L
val length = 120_000L
assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length))
}
@Test
fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong()))
}

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.component
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.use
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.ui.theming.SpotubeTheme
import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** #3200: big track lists must only compose the rows on screen. */
@OptIn(ExperimentalComposeUiApi::class)
class TrackListVirtualizationTest {
private fun track(index: Int) = MetadataTrack(
// Every 100th id repeats: playlists can hold the same track twice.
id = if (index % 100 == 0) "dup" else "t$index",
title = "Song $index",
durationMs = 200_000L,
trackNumber = 1,
discNumber = 1,
artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)),
album = null,
thumbnails = emptyList(),
explicit = false,
popularity = 0,
isrcCode = null,
externalUri = null)
private fun allNodes(scene: ImageComposeScene): List<SemanticsNode> {
fun flatten(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap(::flatten)
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
}
private fun songTitles(scene: ImageComposeScene): Set<String> = allNodes(scene)
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
.map { it.text }
.filter { it.startsWith("Song ") }
.toSet()
@Test
fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() {
val tracks = (0 until 2000).map(::track)
val listState = LazyListState()
var scope: CoroutineScope? = null
var nextPageRequests = 0
ImageComposeScene(1000, 800) {
scope = rememberCoroutineScope()
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
TrackList(
tracks = tracks,
hasMore = true,
state = listState,
onLoadNextPage = { nextPageRequests++ })
}
}.use { scene ->
repeat(3) { scene.render().close() }
val visible = songTitles(scene)
assertTrue("Song 1" in visible, "first rows render: $visible")
assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}")
assertEquals(0, nextPageRequests)
scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) }
repeat(3) { scene.render().close() }
val atEnd = songTitles(scene)
assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}")
assertTrue("Song 1" !in atEnd)
assertEquals(1, nextPageRequests)
}
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.misc
import kotlin.test.Test
import kotlin.test.assertEquals
class UniqueLazyKeysTest {
@Test
fun blankAndRepeatedIdsGetDistinctKeys() {
val ids = listOf("a", "", "b", "a", "", "a")
val keys = uniqueLazyKeys(ids) { it }
assertEquals(ids.size, keys.toSet().size)
// First occurrences keep their id so scroll position stays stable.
assertEquals("a", keys[0])
assertEquals("b", keys[2])
}
@Test
fun suffixesCannotCollideWithRealIds() {
val ids = listOf("a", "a", "a#1", "a1", "a_1")
assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size)
}
}

View File

@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.library.local_tracks.media
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import org.jetbrains.skia.Paint
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class CoverThumbnailTest {
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
// Noise-free but non-uniform so the PNG isn't trivially small.
for (x in 0 until width step 7) {
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
}
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
}
@Test
fun largeCoverIsShrunkToASmallJpeg() {
val original = png(1600, 1200)
val thumbnail = assertNotNull(coverThumbnail(original))
Image.makeFromEncoded(thumbnail).use { image ->
assertEquals(160, minOf(image.width, image.height))
assertEquals(213, image.width)
}
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
}
@Test
fun unreadableCoverIsDropped() {
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
}
}

View File

@ -73,7 +73,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }