From 85377182251418a25f0a70e2ea94afddf1ee632e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 2 Nov 2026 13:37:05 +0600 Subject: [PATCH 1/2] 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 --- .../core/audioplayer/AudioPlayerModels.kt | 5 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 61 +++- .../org/spotube/spotube/core/db/Database.kt | 20 +- .../spotube/core/server/CacheManager.kt | 77 +++-- .../spotube/core/ui/component/TrackList.kt | 288 +++++++++++------- .../spotube/core/ui/misc/UniqueLazyKeys.kt | 67 ++++ .../spotube/modules/artist/ArtistScreen.kt | 7 +- .../modules/library/LibraryMediaCollection.kt | 5 +- .../library/playlist/AddToPlaylistPicker.kt | 3 +- .../playlist/AddTracksToPlaylistDialog.kt | 3 +- .../saved_tracks/SavedTracksRepository.kt | 11 + .../saved_tracks/SavedTracksViewModel.kt | 2 +- .../AlternativeTrackContent.kt | 5 +- .../audioplayer/DeviceAudioPlayerQueueTest.kt | 7 +- .../media/JvmLocalMediaDiscoveryService.kt | 36 ++- .../core/server/StreamFileRangeTest.kt | 59 ++++ .../component/TrackListVirtualizationTest.kt | 101 ++++++ .../core/ui/misc/UniqueLazyKeysTest.kt | 40 +++ .../local_tracks/media/CoverThumbnailTest.kt | 56 ++++ 19 files changed, 698 insertions(+), 155 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt index 8840f8b3..18306883 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerModels.kt @@ -21,6 +21,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlin.time.Duration data class MediaItem( @@ -84,7 +85,9 @@ sealed interface QueueEntry { val artists: List, val duration: Long, 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 addedBy: String = "") : QueueEntry { override val artworkUrl: String? get() = null diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index ee7f5542..7798ab77 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -20,8 +20,10 @@ package org.spotube.spotube.core.audioplayer import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.core.di.injectLogger 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.settings.SettingsProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.koin.core.component.get import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @@ -130,14 +133,15 @@ class DeviceAudioPlayerQueue( startPosition: Int, collectionEntry: QueueCollectionEntry?) { 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()) { logger.w { "load: no valid media items after filtering blanks" } } val normalizedStartPosition = if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) 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) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) persistQueueStateIfReady() @@ -401,19 +405,66 @@ class DeviceAudioPlayerQueue( return } - val mediaItems = state.entries.mapNotNull { it.toMediaItem() } - if (mediaItems.isEmpty()) { + val restored = resolveMediaItems(withLocalCovers(state.entries)) + if (restored.isEmpty()) { logger.w { "restorePersistedState: all entries had blank urls" } 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) logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) 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): List> = + entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } } + + /** First entry wins for a repeated url, as before. */ + private fun entriesByUrl(resolved: List>): Map = 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): List { + if (entries.none { it is QueueEntry.LocalTrack }) return entries + val covers = runCatching { + get().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() { + try { + persistQueueState() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Failed to persist queue state" } + } + } + + private suspend fun persistQueueState() { if (isRestoringPersistedState) { logger.v { "Skipping persist: still restoring persisted state" } return diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt index 272aa8fe..a83c5a0c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/db/Database.kt @@ -61,13 +61,13 @@ class Database(val paths: Paths) { val audioPlayerQueueDataStore: DataStore by lazy { 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 by lazy { 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 { + const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024 + /** Preference files written by previous launches; their presence means an upgrade. */ val EXISTING_INSTALL_PROBE_FILES = listOf( "spotube.preferences_pb", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt index 28093b8a..7bab2235 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/CacheManager.kt @@ -196,22 +196,18 @@ class CacheManager( val (start, end) = range val length = end - start + 1 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() { - override val status: HttpStatusCode = HttpStatusCode.PartialContent - override val contentLength: Long = bytes.size.toLong() - override val contentType: ContentType = entry.toContentType() - override val headers: Headers = HeadersBuilder().apply { - append(HttpHeaders.ContentRange, contentRange) - append(HttpHeaders.AcceptRanges, "bytes") - }.build() - override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { - channel.writeFully(bytes) - } - }) - } + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.PartialContent + override val contentLength: Long = length + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.ContentRange, contentRange) + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + streamFileRange(fileSystem, filePath, start, length, channel) + } + }) return } call.respond(object : OutgoingContent.NoContent() { @@ -223,19 +219,42 @@ class CacheManager( return } - fileSystem.source(filePath).buffer().use { source -> - val bytes = source.readByteArray() - call.respond(object : OutgoingContent.WriteChannelContent() { - override val status: HttpStatusCode = HttpStatusCode.OK - override val contentLength: Long = bytes.size.toLong() - override val contentType: ContentType = entry.toContentType() - override val headers: Headers = HeadersBuilder().apply { - append(HttpHeaders.AcceptRanges, "bytes") - }.build() - override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { - channel.writeFully(bytes) - } - }) + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentLength: Long = fileSize + override val contentType: ContentType = entry.toContentType() + override val headers: Headers = HeadersBuilder().apply { + append(HttpHeaders.AcceptRanges, "bytes") + }.build() + override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { + 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 } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt index e6e69dfc..24969413 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/component/TrackList.kt @@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState 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.luminance import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign 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.track.MetadataTrack 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.shimmerApply import org.spotube.spotube.getPlatform @@ -119,6 +122,42 @@ private val CompactTrackListBreakpoint = 600.dp private val LargeTrackListBreakpoint = 840.dp 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) { None(Res.string.track_list_sort_original), Title(Res.string.track_list_sort_title), @@ -198,7 +237,7 @@ fun TrackList( val shouldLoadMore = remember(density) { derivedStateOf { 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, // which means the user has scrolled through (or is about to scroll through) // all loaded tracks. @@ -219,115 +258,124 @@ fun TrackList( val showAlbum = !isCompact val useDropdownForOptions = isDesktop && !isCompact + val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track -> + TrackListRow( + index = displayedIndex + 1, + track = track, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = track.id == currentTrackId, + // Only the current row depends on it, so play/pause/buffering flips don't recompose every row. + isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId, + isSelectionMode = isSelectionMode, + isSelected = selectedTrackIds.contains(track.id), + onTrackClick = { + if (isSelectionMode) { + selectedTrackIds = + if (selectedTrackIds.contains(track.id)) { + selectedTrackIds - track.id + } else { + selectedTrackIds + track.id + } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) + } else { + onTrackClick(track) + } + }, + onLongClick = { + if (!useDropdownForOptions && !isSelectionMode) { + isSelectionMode = true + selectedTrackIds = setOf(track.id) + } else if (!useDropdownForOptions) { + selectedTrackForOptions = track + } + }, + onSelectionToggle = { checked -> + isSelectionMode = true + selectedTrackIds = if (checked) { + selectedTrackIds + track.id + } else { + selectedTrackIds - track.id + } + }, + onTrackOptionsAction = { action -> + onTrackOptionsAction( + track, + action + ) + }, + trackOptionsState = trackOptionsState(track), + isInJam = isInJam, + isJamGuest = isJamGuest, + onShowOptionsClick = { selectedTrackForOptions = track }, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = { onArtistsOverflowClick(track) }) + } + + // Shimmer, error, empty and next-page rows at the end of the card. + val trackCardFooter: @Composable () -> Unit = { + if (isLoading && tracks.isEmpty()) { + repeat(ShimmerRowCount) { shimmerIndex -> + if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = shimmerIndex + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + + if (error != null) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + TrackListFeedbackRow( + message = error, + isError = true) + } + + if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { + TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks)) + } + + if (isLoadingNextPage) { + if (visibleTracks.isNotEmpty()) { + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) + } + ShimmerTrackListRow( + index = visibleTracks.size + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions) + } + } + val trackCardContent: @Composable () -> Unit = { MillenniumCard( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = TrackCardOuterPadding), contentPadding = PaddingValues(0.dp)) { Column( modifier = Modifier .fillMaxWidth() - .padding(top = 12.dp, bottom = 12.dp) + .padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding) ) { visibleTracks.forEachIndexed { displayedIndex, track -> if (displayedIndex > 0) { MillenniumSeparator() } - TrackListRow( - index = displayedIndex + 1, - track = track, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - isCurrentTrack = track.id == currentTrackId, - isCurrentTrackPlaying = isCurrentTrackPlaying, - isSelectionMode = isSelectionMode, - isSelected = selectedTrackIds.contains(track.id), - onTrackClick = { - if (isSelectionMode) { - selectedTrackIds = - if (selectedTrackIds.contains(track.id)) { - selectedTrackIds - track.id - } else { - selectedTrackIds + track.id - } - } else if (isJamGuest) { - onTrackOptionsAction(track, TrackOptionsAction.AddToJam) - } else { - onTrackClick(track) - } - }, - onLongClick = { - if (!useDropdownForOptions && !isSelectionMode) { - isSelectionMode = true - selectedTrackIds = setOf(track.id) - } else if (!useDropdownForOptions) { - selectedTrackForOptions = track - } - }, - onSelectionToggle = { checked -> - isSelectionMode = true - selectedTrackIds = if (checked) { - selectedTrackIds + track.id - } else { - selectedTrackIds - track.id - } - }, - onTrackOptionsAction = { action -> - onTrackOptionsAction( - track, - action - ) - }, - trackOptionsState = trackOptionsState(track), - isInJam = isInJam, - isJamGuest = isJamGuest, - onShowOptionsClick = { selectedTrackForOptions = track }, - onArtistClick = onArtistClick, - onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }) - } - - if (isLoading && tracks.isEmpty()) { - repeat(ShimmerRowCount) { shimmerIndex -> - if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { - MillenniumSeparator( - modifier = Modifier.padding(horizontal = 8.dp)) - } - ShimmerTrackListRow( - index = shimmerIndex + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions) - } - } - - if (error != null) { - if (visibleTracks.isNotEmpty()) { - MillenniumSeparator( - modifier = Modifier.padding(horizontal = 8.dp)) - } - TrackListFeedbackRow( - message = error, - isError = true) - } - - if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { - TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks)) - } - - if (isLoadingNextPage) { - if (visibleTracks.isNotEmpty()) { - MillenniumSeparator( - modifier = Modifier.padding(horizontal = 8.dp)) - } - ShimmerTrackListRow( - index = visibleTracks.size + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions) + trackRow(displayedIndex, track) } + trackCardFooter() } } } @@ -454,29 +502,57 @@ fun TrackList( if (scrollable) { Box(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter) + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) { + // Rows are separate lazy items (only on-screen ones are composed, #3200), so the card + // 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, - modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter) - .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp)) { + contentPadding = contentPadding) { if (headerContent != null) { item { - headerContent() + Box(Modifier.padding(bottom = 2.dp)) { headerContent() } } } 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) { item { - footerContent() + Box(Modifier.padding(top = 2.dp)) { footerContent() } } } } + } if (!useDropdownForOptions) { selectedTrackForOptions?.let { track -> diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt new file mode 100644 index 00000000..85a6d57d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeys.kt @@ -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 . + */ + +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 uniqueLazyKeys(items: List, id: (T) -> String): List { + val occurrences = HashMap(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 LazyListScope.itemsWithUniqueKeys( + items: List, + 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 LazyGridScope.itemsWithUniqueKeys( + items: List, + 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]) } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt index 68d5560b..5b2459e1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/artist/ArtistScreen.kt @@ -18,6 +18,7 @@ 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.MillenniumButton import org.spotube.millennium.composables.MillenniumButtonStyle @@ -638,7 +639,7 @@ private fun AlbumsSection( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { - items(albums, key = { it.id }) { album -> + itemsWithUniqueKeys(albums, id = { it.id }) { album -> AlbumCard(album = album) } } @@ -676,7 +677,7 @@ private fun RelatedArtistsSection( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { - items(artists, key = { it.id }) { artist -> + itemsWithUniqueKeys(artists, id = { it.id }) { artist -> ArtistCard(artist = artist) } } @@ -714,7 +715,7 @@ private fun FeaturedPlaylistsSection( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { - items(playlists, key = { it.id }) { playlist -> + itemsWithUniqueKeys(playlists, id = { it.id }) { playlist -> PlaylistCard(playlist = playlist) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt index 467881d8..f2f6056d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/LibraryMediaCollection.kt @@ -3,6 +3,7 @@ */ 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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -58,7 +59,7 @@ internal fun LibraryMediaCollection( if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } else { if (header != null) item(key = "library_header") { header(true) } - items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } + itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } } } @@ -71,7 +72,7 @@ internal fun LibraryMediaCollection( if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } else { if (header != null) item(key = "library_header") { header(false) } - items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } + itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt index 09e3a729..48326a1e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -18,6 +18,7 @@ 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.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumButton @@ -195,7 +196,7 @@ private fun PlaylistPickerContent( LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(playlists, key = { it.id }) { playlist -> + itemsWithUniqueKeys(playlists, id = { it.id }) { playlist -> PlaylistPickerRow( playlist = playlist, onClick = { onPlaylistSelected(playlist.id) }) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index 2f785cd8..1f8ca56a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -18,6 +18,7 @@ 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.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumIcon @@ -138,7 +139,7 @@ fun AddTracksToPlaylistDialog( .fillMaxWidth() .weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(state.results, key = { it.track.id }) { result -> + itemsWithUniqueKeys(state.results, id = { it.track.id }) { result -> TrackSearchResultRow( track = result.track, isAdding = result.isAdding, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt index 9b931d99..a2689682 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksRepository.kt @@ -50,6 +50,14 @@ class SavedTracksRepository( val savedTracksIdsFlow : StateFlow> = 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 = _libraryChanges.asStateFlow() + init { scope.launch { pluginManager.selectedMetadataPlugin @@ -71,6 +79,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value = emptySet() + _libraryChanges.value++ } suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = @@ -109,6 +118,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value += ids.toSet() + _libraryChanges.value++ } } @@ -122,6 +132,7 @@ class SavedTracksRepository( savedTracksCache.invalidateAll() totalCountCache.invalidateAll() savedTrackIds.value -= ids.toSet() + _libraryChanges.value++ } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 6ee5e398..9ec0b723 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -152,7 +152,7 @@ class SavedTracksViewModel( .filterNotNull() .flatMapLatest { it.loggedInFlow } .distinctUntilChanged(), - repository.savedTracksIdsFlow + repository.libraryChanges ) { _, _ -> }.collect { loadInitialData() loadCurrentUser() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 8e97ec04..68c2ac95 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -18,6 +18,7 @@ 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.MillenniumIcon import org.spotube.millennium.composables.MillenniumListTile @@ -119,9 +120,9 @@ fun AlternativeTrackContent( LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) { - items( + itemsWithUniqueKeys( items = uiState.alternatives, - key = { source -> source.id }) { source -> + id = { source -> source.id }) { source -> AlternativeSourceRow( source = source, isActive = source.id == uiState.activeSourceId, diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 5dde1c65..d1075488 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -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.track.MetadataTrack import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module @@ -423,7 +425,10 @@ class DeviceAudioPlayerQueueTest { ) 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(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry()) diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt index d7dd7ab0..dab76069 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -19,6 +19,13 @@ package org.spotube.spotube.modules.library.local_tracks.media import kotlinx.coroutines.Dispatchers 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.Files import java.nio.file.Path @@ -70,7 +77,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), durationMs = metadata?.durationMs ?: 0L, album = metadata?.album, - coverBytes = metadata?.coverBytes) + coverBytes = metadata?.coverBytes?.let(::coverThumbnail)) folders.getOrPut(parent) { mutableListOf() }.add(track) } } @@ -187,3 +194,30 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { "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 + diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt new file mode 100644 index 00000000..2ed56a00 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/server/StreamFileRangeTest.kt @@ -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 . + */ + +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())) +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt new file mode 100644 index 00000000..5e37f66f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/component/TrackListVirtualizationTest.kt @@ -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 . + */ + +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 { + fun flatten(node: SemanticsNode): List = listOf(node) + node.children.flatMap(::flatten) + return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) } + } + + private fun songTitles(scene: ImageComposeScene): Set = 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) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt new file mode 100644 index 00000000..be156dde --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/ui/misc/UniqueLazyKeysTest.kt @@ -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 . + */ + +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) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt new file mode 100644 index 00000000..da9365f2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/library/local_tracks/media/CoverThumbnailTest.kt @@ -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 . + */ + +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))) + } +} From f23c66c0c56a60f7cde5af4d52fe3ecb0e3cb841 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 2 Nov 2026 14:20:10 +0600 Subject: [PATCH 2/2] 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. --- composeApp/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 38967b31..4058d430 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -449,7 +449,7 @@ nucleus.application { "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work // --- ADD THESE FOR SPEED & LOW RAM --- "-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:+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 optimization = NativeImageOptimization.SIZE // 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 // a second ~30 MB copy as a resource buildArgs.add("-H:+UnlockExperimentalVMOptions")