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