Compare commits

..

1 Commits

Author SHA1 Message Date
Brian López Medina
57fdde5f85
Merge 9785c93b6a into 0541fd9d23 2026-11-02 09:33:48 +06:00
45 changed files with 181 additions and 843 deletions

View File

@ -54,7 +54,6 @@
## UI component patterns ## UI component patterns
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen. - **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
- Outside composables (ViewModels, services, snackbars), import `getString` / `getPluralString` / `getStringArray` from `org.spotube.spotube.core.i18n`, never from `org.jetbrains.compose.resources`. The library versions ask Skiko for the system theme on every call, which on Linux is a blocking D-Bus call that segfaulted the desktop app from background threads (#3183). `NonComposableStringLookupTest` enforces this.
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. - **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.

View File

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

View File

@ -41,7 +41,7 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository
import org.spotube.spotube.modules.search.SearchRepository import org.spotube.spotube.modules.search.SearchRepository
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_tab_albums import spotube.composeapp.generated.resources.library_tab_albums
import spotube.composeapp.generated.resources.library_tab_artists import spotube.composeapp.generated.resources.library_tab_artists

View File

@ -55,7 +55,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.notification_playback_channel_description import spotube.composeapp.generated.resources.notification_playback_channel_description
import spotube.composeapp.generated.resources.notification_playback_channel_name import spotube.composeapp.generated.resources.notification_playback_channel_name

View File

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

View File

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

View File

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

View File

@ -54,7 +54,6 @@ fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> U
layoutDirection?.let { LocalLayoutDirection provides it }) layoutDirection?.let { LocalLayoutDirection provides it })
CompositionLocalProvider(*locales.toTypedArray()) { CompositionLocalProvider(*locales.toTypedArray()) {
key(languageTag) { key(languageTag) {
PublishResourceEnvironment()
content() content()
} }
} }

View File

@ -1,71 +0,0 @@
/*
* 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.i18n
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.ResourceEnvironment
import org.jetbrains.compose.resources.StringArrayResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getSystemResourceEnvironment
import org.jetbrains.compose.resources.rememberResourceEnvironment
import kotlin.concurrent.Volatile
import org.jetbrains.compose.resources.getPluralString as composeGetPluralString
import org.jetbrains.compose.resources.getString as composeGetString
import org.jetbrains.compose.resources.getStringArray as composeGetStringArray
/*
* Non-composable string lookups (ViewModels, services, snackbars). Use these instead of
* org.jetbrains.compose.resources.getString/getPluralString/getStringArray.
*
* The library versions call getSystemResourceEnvironment() on every lookup. On desktop that
* also asks Skiko for the system theme, and on Linux Skiko answers with a blocking D-Bus call to
* the desktop portal on whatever thread is asking. Run from a background coroutine, that call
* segfaulted the GraalVM build on GNOME (#3183). Strings never depend on the theme, so these
* reuse the environment the UI already computed (on the UI thread, for stringResource) and only
* fall back to the library's lookup before anything has been composed.
*/
@Volatile
private var composedEnvironment: ResourceEnvironment? = null
/** Publishes the UI's resource environment for [getString] and friends. Called by [AppLocaleProvider]. */
@Composable
internal fun PublishResourceEnvironment() {
val environment = rememberResourceEnvironment()
SideEffect { composedEnvironment = environment }
}
private fun resourceEnvironment(): ResourceEnvironment =
composedEnvironment ?: getSystemResourceEnvironment()
suspend fun getString(resource: StringResource): String =
composeGetString(resourceEnvironment(), resource)
suspend fun getString(resource: StringResource, vararg formatArgs: Any): String =
composeGetString(resourceEnvironment(), resource, *formatArgs)
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
composeGetPluralString(resourceEnvironment(), resource, quantity)
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
composeGetPluralString(resourceEnvironment(), resource, quantity, *formatArgs)
suspend fun getStringArray(resource: StringArrayResource): List<String> =
composeGetStringArray(resourceEnvironment(), resource)

View File

@ -18,8 +18,8 @@
package org.spotube.spotube.core.jam package org.spotube.spotube.core.jam
import okio.ByteString.Companion.encodeUtf8 import okio.ByteString.Companion.encodeUtf8
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getStringArray import org.jetbrains.compose.resources.getStringArray
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_name_adjectives import spotube.composeapp.generated.resources.jam_name_adjectives
import spotube.composeapp.generated.resources.jam_name_animals import spotube.composeapp.generated.resources.jam_name_animals

View File

@ -58,7 +58,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonClassDiscriminator import kotlinx.serialization.json.JsonClassDiscriminator
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.network.HttpClientFactory
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_banned import spotube.composeapp.generated.resources.jam_error_banned

View File

@ -59,7 +59,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.abs import kotlin.math.abs
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_invalid_code import spotube.composeapp.generated.resources.jam_error_invalid_code
import spotube.composeapp.generated.resources.jam_error_no_server import spotube.composeapp.generated.resources.jam_error_no_server

View File

@ -40,8 +40,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getPluralString import org.jetbrains.compose.resources.getPluralString
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_queue_added_track import spotube.composeapp.generated.resources.jam_queue_added_track
import spotube.composeapp.generated.resources.jam_queue_added_tracks import spotube.composeapp.generated.resources.jam_queue_added_tracks

View File

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

View File

@ -46,7 +46,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -81,7 +80,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@ -98,7 +96,6 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.ui.misc.SkeletonTree import org.spotube.spotube.core.ui.misc.SkeletonTree
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
import org.spotube.spotube.core.ui.misc.TextWithShimmer import org.spotube.spotube.core.ui.misc.TextWithShimmer
import org.spotube.spotube.core.ui.misc.shimmerApply import org.spotube.spotube.core.ui.misc.shimmerApply
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
@ -122,42 +119,6 @@ private val CompactTrackListBreakpoint = 600.dp
private val LargeTrackListBreakpoint = 840.dp private val LargeTrackListBreakpoint = 840.dp
private const val ShimmerRowCount = 6 private const val ShimmerRowCount = 6
private val TrackCardOuterPadding = 8.dp
private val TrackCardInnerPadding = 12.dp
private const val TrackCardTopKey = "track-card-top"
private const val TrackCardRowKeyPrefix = "track-card-row:"
private const val TrackCardFooterKey = "track-card-footer"
private const val TrackCardBottomKey = "track-card-bottom"
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
/**
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
*/
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
layout { measurable, constraints ->
val info = listState.layoutInfo
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
// Item offsets are relative to the viewport start, which is negative by the top padding.
val toLocal = -info.viewportStartOffset
val viewport = info.viewportEndOffset - info.viewportStartOffset
val overscan = viewport * 3
val first = cardItems.first()
val last = cardItems.last()
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
val bottom = if (last.key == TrackCardBottomKey) {
last.offset + last.size + toLocal - edgeInsetPx
} else viewport + overscan
val height = (bottom - top).coerceAtLeast(0)
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
}
enum class TrackSortOption(val label: StringResource) { enum class TrackSortOption(val label: StringResource) {
None(Res.string.track_list_sort_original), None(Res.string.track_list_sort_original),
Title(Res.string.track_list_sort_title), Title(Res.string.track_list_sort_title),
@ -237,7 +198,7 @@ fun TrackList(
val shouldLoadMore = remember(density) { val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
val loadMoreThreshold = with(density) { 200.dp.toPx() } val loadMoreThreshold = with(density) { 200.dp.toPx() }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey } val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" }
// Trigger when the bottom of the track card is close to/inside the viewport, // Trigger when the bottom of the track card is close to/inside the viewport,
// which means the user has scrolled through (or is about to scroll through) // which means the user has scrolled through (or is about to scroll through)
// all loaded tracks. // all loaded tracks.
@ -258,124 +219,115 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !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 = { val trackCardContent: @Composable () -> Unit = {
MillenniumCard( MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = TrackCardOuterPadding), .padding(vertical = 8.dp),
contentPadding = PaddingValues(0.dp)) { contentPadding = PaddingValues(0.dp)) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding) .padding(top = 12.dp, bottom = 12.dp)
) { ) {
visibleTracks.forEachIndexed { displayedIndex, track -> visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) { if (displayedIndex > 0) {
MillenniumSeparator() MillenniumSeparator()
} }
trackRow(displayedIndex, track) 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)
} }
trackCardFooter()
} }
} }
} }
@ -502,57 +454,29 @@ fun TrackList(
if (scrollable) { if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) { 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( LazyColumn(
state = listState, state = listState,
contentPadding = contentPadding) { 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)) {
if (headerContent != null) { if (headerContent != null) {
item { item {
Box(Modifier.padding(bottom = 2.dp)) { headerContent() } headerContent()
} }
} }
if (!simplified) { if (!simplified) {
item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } } item { filterSortRow() }
}
item(key = TrackCardTopKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
}
// Plugin ids can repeat (the same track twice in a playlist).
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
Column(Modifier.fillMaxWidth()) {
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, visibleTracks[displayedIndex])
}
}
item(key = TrackCardFooterKey) {
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
}
item(key = TrackCardBottomKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
} }
item(key = "track-card") { trackCardContent() }
if (footerContent != null) { if (footerContent != null) {
item { item {
Box(Modifier.padding(top = 2.dp)) { footerContent() } footerContent()
} }
} }
} }
}
if (!useDropdownForOptions) { if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track -> selectedTrackForOptions?.let { track ->

View File

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

View File

@ -51,7 +51,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

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

View File

@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
sealed interface ArtistScreenState { sealed interface ArtistScreenState {

View File

@ -62,7 +62,7 @@ import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import kotlin.time.Clock import kotlin.time.Clock
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
data class DownloadItem( data class DownloadItem(

View File

@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
enum class JamSetupMode { Create, Join } enum class JamSetupMode { Create, Join }

View File

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

View File

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_local_refresh_failed import spotube.composeapp.generated.resources.library_local_refresh_failed

View File

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

View File

@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.PlatformFile
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

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

View File

@ -56,7 +56,7 @@ import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

@ -61,7 +61,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_install import spotube.composeapp.generated.resources.plugin_action_install

View File

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

View File

@ -59,7 +59,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks" const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks"
@ -152,7 +152,7 @@ class SavedTracksViewModel(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged(), .distinctUntilChanged(),
repository.libraryChanges repository.savedTracksIdsFlow
) { _, _ -> }.collect { ) { _, _ -> }.collect {
loadInitialData() loadInitialData()
loadCurrentUser() loadCurrentUser()

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.search_error_no_metadata_plugin import spotube.composeapp.generated.resources.search_error_no_metadata_plugin

View File

@ -44,7 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.search_error_capabilities import spotube.composeapp.generated.resources.search_error_capabilities
import spotube.composeapp.generated.resources.search_error_search import spotube.composeapp.generated.resources.search_error_search

View File

@ -123,7 +123,7 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.downloads_status_cancelled import spotube.composeapp.generated.resources.downloads_status_cancelled

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.use import androidx.compose.ui.use
import org.spotube.spotube.modules.settings.SupportedLanguages import org.spotube.spotube.modules.settings.SupportedLanguages
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.spotube.spotube.core.i18n.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res

View File

@ -1,46 +0,0 @@
/*
* 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.i18n
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* The library's non-composable getters query the system theme over D-Bus on Linux on every
* call, which crashed the desktop app (#3183). App code must use the ones in Strings.kt.
*/
class NonComposableStringLookupTest {
@Test
fun appCodeUsesTheAppStringLookups() {
val forbidden = Regex("""^import org\.jetbrains\.compose\.resources\.get(String|PluralString|StringArray)\s*$""")
val offenders = File("src").walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex()
.filter { (_, line) -> forbidden.matches(line) }
.map { (index, _) -> "${file.path}:${index + 1}" }
}
.toList()
assertTrue(
offenders.isEmpty(),
"Import getString/getPluralString/getStringArray from org.spotube.spotube.core.i18n instead:\n" +
offenders.joinToString("\n"))
}
}

View File

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

View File

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

View File

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

View File

@ -1,56 +0,0 @@
/*
* 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)))
}
}