mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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
This commit is contained in:
parent
0541fd9d23
commit
8537718225
@ -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<String>,
|
||||
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
|
||||
|
||||
@ -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<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
|
||||
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
|
||||
|
||||
/** First entry wins for a repeated url, as before. */
|
||||
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
|
||||
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
|
||||
}
|
||||
|
||||
/** Local covers aren't persisted with the queue; look them up in the local media index. */
|
||||
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
|
||||
if (entries.none { it is QueueEntry.LocalTrack }) return entries
|
||||
val covers = runCatching {
|
||||
get<LocalMediaCacheRepository>().cacheFlow.first().folders
|
||||
.flatMap { it.tracks }
|
||||
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
|
||||
.toMap()
|
||||
}.getOrElse {
|
||||
logger.w(it) { "Couldn't read local media index for queue covers" }
|
||||
emptyMap()
|
||||
}
|
||||
if (covers.isEmpty()) return entries
|
||||
return entries.map { entry ->
|
||||
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
|
||||
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
|
||||
} else entry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
|
||||
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
|
||||
*/
|
||||
private suspend fun persistQueueStateIfReady() {
|
||||
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
|
||||
|
||||
@ -61,13 +61,13 @@ class Database(val paths: Paths) {
|
||||
|
||||
val audioPlayerQueueDataStore: DataStore<Preferences> 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<Preferences> 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",
|
||||
|
||||
@ -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 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) {
|
||||
channel.writeFully(bytes)
|
||||
streamFileRange(fileSystem, filePath, start, length, channel)
|
||||
}
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
call.respond(object : OutgoingContent.NoContent() {
|
||||
@ -223,20 +219,43 @@ 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 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) {
|
||||
channel.writeFully(bytes)
|
||||
streamFileRange(fileSystem, filePath, 0, fileSize, channel)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private const val STREAM_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
|
||||
* player re-requests them on every seek, so they must never be read into the heap whole.
|
||||
*/
|
||||
internal suspend fun streamFileRange(
|
||||
fileSystem: FileSystem,
|
||||
filePath: Path,
|
||||
start: Long,
|
||||
length: Long,
|
||||
channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
fileSystem.source(filePath).buffer().use { source ->
|
||||
source.skip(start)
|
||||
val chunk = ByteArray(STREAM_CHUNK_BYTES)
|
||||
var remaining = length
|
||||
while (remaining > 0) {
|
||||
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
|
||||
if (read == -1) break
|
||||
channel.writeFully(chunk, 0, read)
|
||||
remaining -= read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,21 +258,7 @@ fun TrackList(
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 12.dp)
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
MillenniumSeparator()
|
||||
}
|
||||
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
track = track,
|
||||
@ -241,7 +266,8 @@ fun TrackList(
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
isCurrentTrack = track.id == currentTrackId,
|
||||
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
||||
// Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
|
||||
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
|
||||
isSelectionMode = isSelectionMode,
|
||||
isSelected = selectedTrackIds.contains(track.id),
|
||||
onTrackClick = {
|
||||
@ -289,6 +315,8 @@ fun TrackList(
|
||||
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) {
|
||||
@ -329,6 +357,26 @@ fun TrackList(
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = TrackCardOuterPadding),
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
MillenniumSeparator()
|
||||
}
|
||||
trackRow(displayedIndex, track)
|
||||
}
|
||||
trackCardFooter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -454,26 +502,54 @@ fun TrackList(
|
||||
|
||||
if (scrollable) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
Box(
|
||||
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)) {
|
||||
.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,
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.ui.misc
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/*
|
||||
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
|
||||
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
|
||||
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
|
||||
*/
|
||||
|
||||
private const val DUPLICATE_SEPARATOR = '\u0000'
|
||||
|
||||
/**
|
||||
* One key per item: an item keeps its id the first time it appears, so scroll position and
|
||||
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
|
||||
* a real id.
|
||||
*/
|
||||
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
|
||||
val occurrences = HashMap<String, Int>(items.size)
|
||||
return items.map { item ->
|
||||
val base = id(item)
|
||||
val occurrence = occurrences[base] ?: 0
|
||||
occurrences[base] = occurrence + 1
|
||||
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> LazyListScope.itemsWithUniqueKeys(
|
||||
items: List<T>,
|
||||
id: (T) -> String,
|
||||
keyPrefix: String = "",
|
||||
itemContent: @Composable LazyItemScope.(T) -> Unit
|
||||
) {
|
||||
val keys = uniqueLazyKeys(items, id)
|
||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
||||
}
|
||||
|
||||
fun <T> LazyGridScope.itemsWithUniqueKeys(
|
||||
items: List<T>,
|
||||
id: (T) -> String,
|
||||
keyPrefix: String = "",
|
||||
itemContent: @Composable LazyGridItemScope.(T) -> Unit
|
||||
) {
|
||||
val keys = uniqueLazyKeys(items, id)
|
||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <T> 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 <T> 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) } }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) })
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,6 +50,14 @@ class SavedTracksRepository(
|
||||
|
||||
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
|
||||
|
||||
/**
|
||||
* Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow]
|
||||
* also grows while pages and saved-states are fetched, so reloading the list on it restarted
|
||||
* paging on every page (#3200).
|
||||
*/
|
||||
private val _libraryChanges = MutableStateFlow(0)
|
||||
val libraryChanges: StateFlow<Int> = _libraryChanges.asStateFlow()
|
||||
|
||||
init {
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -152,7 +152,7 @@ class SavedTracksViewModel(
|
||||
.filterNotNull()
|
||||
.flatMapLatest { it.loggedInFlow }
|
||||
.distinctUntilChanged(),
|
||||
repository.savedTracksIdsFlow
|
||||
repository.libraryChanges
|
||||
) { _, _ -> }.collect {
|
||||
loadInitialData()
|
||||
loadCurrentUser()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.server
|
||||
|
||||
import io.ktor.utils.io.ByteChannel
|
||||
import io.ktor.utils.io.readRemaining
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.io.readByteArray
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.nio.file.Files
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
|
||||
class StreamFileRangeTest {
|
||||
// Several chunks plus a partial one.
|
||||
private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234)
|
||||
|
||||
private fun copy(start: Long, length: Long): ByteArray = runBlocking {
|
||||
val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() }
|
||||
Files.write(file, data)
|
||||
val channel = ByteChannel()
|
||||
launch {
|
||||
streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel)
|
||||
channel.flushAndClose()
|
||||
}
|
||||
channel.readRemaining().readByteArray()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong()))
|
||||
|
||||
@Test
|
||||
fun rangeAcrossChunks() {
|
||||
val start = 70_000L
|
||||
val length = 120_000L
|
||||
assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong()))
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.use
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.ui.theming.SpotubeTheme
|
||||
import org.spotube.spotube.modules.settings.Theme
|
||||
import org.spotube.spotube.modules.settings.UserSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** #3200: big track lists must only compose the rows on screen. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
class TrackListVirtualizationTest {
|
||||
private fun track(index: Int) = MetadataTrack(
|
||||
// Every 100th id repeats: playlists can hold the same track twice.
|
||||
id = if (index % 100 == 0) "dup" else "t$index",
|
||||
title = "Song $index",
|
||||
durationMs = 200_000L,
|
||||
trackNumber = 1,
|
||||
discNumber = 1,
|
||||
artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)),
|
||||
album = null,
|
||||
thumbnails = emptyList(),
|
||||
explicit = false,
|
||||
popularity = 0,
|
||||
isrcCode = null,
|
||||
externalUri = null)
|
||||
|
||||
private fun allNodes(scene: ImageComposeScene): List<SemanticsNode> {
|
||||
fun flatten(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap(::flatten)
|
||||
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
|
||||
}
|
||||
|
||||
private fun songTitles(scene: ImageComposeScene): Set<String> = allNodes(scene)
|
||||
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
|
||||
.map { it.text }
|
||||
.filter { it.startsWith("Song ") }
|
||||
.toSet()
|
||||
|
||||
@Test
|
||||
fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() {
|
||||
val tracks = (0 until 2000).map(::track)
|
||||
val listState = LazyListState()
|
||||
var scope: CoroutineScope? = null
|
||||
var nextPageRequests = 0
|
||||
|
||||
ImageComposeScene(1000, 800) {
|
||||
scope = rememberCoroutineScope()
|
||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||
TrackList(
|
||||
tracks = tracks,
|
||||
hasMore = true,
|
||||
state = listState,
|
||||
onLoadNextPage = { nextPageRequests++ })
|
||||
}
|
||||
}.use { scene ->
|
||||
repeat(3) { scene.render().close() }
|
||||
|
||||
val visible = songTitles(scene)
|
||||
assertTrue("Song 1" in visible, "first rows render: $visible")
|
||||
assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}")
|
||||
assertEquals(0, nextPageRequests)
|
||||
|
||||
scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) }
|
||||
repeat(3) { scene.render().close() }
|
||||
|
||||
val atEnd = songTitles(scene)
|
||||
assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}")
|
||||
assertTrue("Song 1" !in atEnd)
|
||||
assertEquals(1, nextPageRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.ui.misc
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class UniqueLazyKeysTest {
|
||||
@Test
|
||||
fun blankAndRepeatedIdsGetDistinctKeys() {
|
||||
val ids = listOf("a", "", "b", "a", "", "a")
|
||||
val keys = uniqueLazyKeys(ids) { it }
|
||||
|
||||
assertEquals(ids.size, keys.toSet().size)
|
||||
// First occurrences keep their id so scroll position stays stable.
|
||||
assertEquals("a", keys[0])
|
||||
assertEquals("b", keys[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun suffixesCannotCollideWithRealIds() {
|
||||
val ids = listOf("a", "a", "a#1", "a1", "a_1")
|
||||
assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.library.local_tracks.media
|
||||
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.Paint
|
||||
import org.jetbrains.skia.Rect
|
||||
import org.jetbrains.skia.Surface
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CoverThumbnailTest {
|
||||
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
|
||||
// Noise-free but non-uniform so the PNG isn't trivially small.
|
||||
for (x in 0 until width step 7) {
|
||||
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
|
||||
}
|
||||
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeCoverIsShrunkToASmallJpeg() {
|
||||
val original = png(1600, 1200)
|
||||
val thumbnail = assertNotNull(coverThumbnail(original))
|
||||
|
||||
Image.makeFromEncoded(thumbnail).use { image ->
|
||||
assertEquals(160, minOf(image.width, image.height))
|
||||
assertEquals(213, image.width)
|
||||
}
|
||||
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableCoverIsDropped() {
|
||||
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user