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
- **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.
- **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.

View File

@ -449,7 +449,7 @@ nucleus.application {
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-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:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
)
@ -556,7 +556,7 @@ nucleus.application {
// ProGuard is not used for native images; -Os is the size equivalent
optimization = NativeImageOptimization.SIZE
// Same ceiling as the JVM distribution's -Xmx
maxHeapSize = "1g"
maxHeapSize = "384m"
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
// a second ~30 MB copy as a resource
buildArgs.add("-H:+UnlockExperimentalVMOptions")

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.settings.SettingsRepository
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.library_tab_albums
import spotube.composeapp.generated.resources.library_tab_artists

View File

@ -55,7 +55,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
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.notification_playback_channel_description
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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlin.time.Duration
data class MediaItem(
@ -85,9 +84,7 @@ sealed interface QueueEntry {
val artists: List<String>,
val duration: Long,
val album: String?,
// 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,
val coverBytes: ByteArray?,
override val url: String,
override val addedBy: String = "") : QueueEntry {
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.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
@ -39,7 +37,6 @@ 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
@ -133,15 +130,14 @@ class DeviceAudioPlayerQueue(
startPosition: Int,
collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val resolved = resolveMediaItems(entries)
val mediaItems = resolved.map { it.first }
val mediaItems = entries.mapNotNull { it.toMediaItem() }
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 = entriesByUrl(resolved)
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
updateCollectionContext(collectionEntry)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady()
@ -405,66 +401,19 @@ class DeviceAudioPlayerQueue(
return
}
val restored = resolveMediaItems(withLocalCovers(state.entries))
if (restored.isEmpty()) {
val mediaItems = state.entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" }
return
}
val mediaItems = restored.map { it.first }
entryByMediaUrl.value = entriesByUrl(restored)
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
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

View File

@ -61,13 +61,13 @@ class Database(val paths: Paths) {
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
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 {
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 {
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",

View File

@ -54,7 +54,6 @@ fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> U
layoutDirection?.let { LocalLayoutDirection provides it })
CompositionLocalProvider(*locales.toTypedArray()) {
key(languageTag) {
PublishResourceEnvironment()
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
import okio.ByteString.Companion.encodeUtf8
import org.spotube.spotube.core.i18n.getString
import org.spotube.spotube.core.i18n.getStringArray
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.getStringArray
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_name_adjectives
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.json.Json
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 spotube.composeapp.generated.resources.Res
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.withTimeoutOrNull
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.jam_error_invalid_code
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.launch
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.i18n.getPluralString
import org.spotube.spotube.core.i18n.getString
import org.jetbrains.compose.resources.getPluralString
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_queue_added_track
import spotube.composeapp.generated.resources.jam_queue_added_tracks

View File

@ -196,18 +196,22 @@ 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 = length
override val contentLength: Long = bytes.size.toLong()
override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.ContentRange, contentRange)
append(HttpHeaders.AcceptRanges, "bytes")
}.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
streamFileRange(fileSystem, filePath, start, length, channel)
channel.writeFully(bytes)
}
})
}
return
}
call.respond(object : OutgoingContent.NoContent() {
@ -219,43 +223,20 @@ 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 = fileSize
override val contentLength: Long = bytes.size.toLong()
override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.AcceptRanges, "bytes")
}.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
streamFileRange(fileSystem, filePath, 0, fileSize, channel)
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.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
@ -81,7 +80,6 @@ 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
@ -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.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
@ -122,42 +119,6 @@ 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),
@ -237,7 +198,7 @@ fun TrackList(
val shouldLoadMore = remember(density) {
derivedStateOf {
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,
// which means the user has scrolled through (or is about to scroll through)
// all loaded tracks.
@ -258,7 +219,21 @@ fun TrackList(
val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
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()
}
TrackListRow(
index = displayedIndex + 1,
track = track,
@ -266,8 +241,7 @@ fun TrackList(
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,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
@ -315,8 +289,6 @@ 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) {
@ -357,26 +329,6 @@ 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()
}
}
}
@ -502,54 +454,26 @@ fun TrackList(
if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) {
Box(
LazyColumn(
state = listState,
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
// Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
// they sit on is drawn once behind the list, spanning the card's laid-out items.
MillenniumCard(
modifier = Modifier
.matchParentSize()
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
contentPadding = PaddingValues(0.dp)) {}
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
// gaps around the card are applied to the items before and after it instead.
LazyColumn(
state = listState,
contentPadding = contentPadding) {
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (headerContent != null) {
item {
Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
headerContent()
}
}
if (!simplified) {
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 { filterSortRow() }
}
item(key = "track-card") { trackCardContent() }
if (footerContent != null) {
item {
Box(Modifier.padding(top = 2.dp)) { footerContent() }
}
footerContent()
}
}
}

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.launch
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.common_unknown_error

View File

@ -18,7 +18,6 @@
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
@ -639,7 +638,7 @@ private fun AlbumsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
itemsWithUniqueKeys(albums, id = { it.id }) { album ->
items(albums, key = { it.id }) { album ->
AlbumCard(album = album)
}
}
@ -677,7 +676,7 @@ private fun RelatedArtistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
items(artists, key = { it.id }) { artist ->
ArtistCard(artist = artist)
}
}
@ -715,7 +714,7 @@ private fun FeaturedPlaylistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
items(playlists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist)
}
}

View File

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

View File

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

View File

@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
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.common_unknown_error

View File

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

View File

@ -3,7 +3,6 @@
*/
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
@ -59,7 +58,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
else {
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) } }
}
}
@ -72,7 +71,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
else {
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) } }
}
}

View File

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
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.common_unknown_error

View File

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
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.common_unknown_error

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import org.spotube.spotube.PlatformType
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.library_local_refresh_failed

View File

@ -18,7 +18,6 @@
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
@ -196,7 +195,7 @@ private fun PlaylistPickerContent(
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
items(playlists, key = { it.id }) { playlist ->
PlaylistPickerRow(
playlist = playlist,
onClick = { onPlaylistSelected(playlist.id) })

View File

@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import io.github.vinceglb.filekit.PlatformFile
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.common_unknown_error

View File

@ -18,7 +18,6 @@
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
@ -139,7 +138,7 @@ fun AddTracksToPlaylistDialog(
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
itemsWithUniqueKeys(state.results, id = { it.track.id }) { result ->
items(state.results, key = { it.track.id }) { result ->
TrackSearchResultRow(
track = result.track,
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.launch
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.common_unknown_error

View File

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

View File

@ -50,14 +50,6 @@ 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
@ -79,7 +71,6 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll()
totalCountCache.invalidateAll()
savedTrackIds.value = emptySet()
_libraryChanges.value++
}
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
@ -118,7 +109,6 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll()
totalCountCache.invalidateAll()
savedTrackIds.value += ids.toSet()
_libraryChanges.value++
}
}
@ -132,7 +122,6 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll()
totalCountCache.invalidateAll()
savedTrackIds.value -= ids.toSet()
_libraryChanges.value++
}
}

View File

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

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
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.search_error_no_metadata_plugin

View File

@ -44,7 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
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.search_error_capabilities
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.core.parameter.parametersOf
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 spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.downloads_status_cancelled

View File

@ -18,7 +18,6 @@
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
@ -120,9 +119,9 @@ fun AlternativeTrackContent(
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)) {
itemsWithUniqueKeys(
items(
items = uiState.alternatives,
id = { source -> source.id }) { source ->
key = { source -> source.id }) { source ->
AlternativeSourceRow(
source = source,
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.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
@ -425,10 +423,7 @@ class DeviceAudioPlayerQueueTest {
)
val queue = createQueue()
// 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) }
}
withContext(Dispatchers.Default) { } // yield to let init coroutine run
assertEquals(1, fakePlayer.loadCallCount)
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.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
@ -77,7 +70,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
durationMs = metadata?.durationMs ?: 0L,
album = metadata?.album,
coverBytes = metadata?.coverBytes?.let(::coverThumbnail))
coverBytes = metadata?.coverBytes)
folders.getOrPut(parent) { mutableListOf() }.add(track)
}
}
@ -194,30 +187,3 @@ 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

View File

@ -28,7 +28,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.use
import org.spotube.spotube.modules.settings.SupportedLanguages
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.stringResource
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)))
}
}