Compare commits

...

8 Commits

Author SHA1 Message Date
Brian López Medina
2f9f793b78
Merge 9785c93b6a into 431fd2353f 2026-11-03 13:21:32 +06:00
Kingkor Roy Tirtho
431fd2353f
Merge pull request #3204 from team-spotube/fix/large-list-memory
fix: memory blow-ups and crashes with large lists and local media
2026-11-03 13:15:19 +06:00
Kingkor Roy Tirtho
0bf0e1816c
Merge pull request #3203 from team-spotube/fix/linux-string-lookup-segfault
fix(desktop): stop non-composable string lookups from querying the system theme
2026-11-02 15:26:26 +06:00
Kingkor Roy Tirtho
f23c66c0c5 build: raise the desktop heap ceiling to 1 GB
384 MB left little headroom for large libraries and long sessions. It is
a ceiling, not a reservation: the serial GC only grows the heap to what
is live.
2026-11-02 14:20:10 +06:00
Kingkor Roy Tirtho
8537718225 fix: stop memory blow-ups and crashes with large lists and local media
TrackList rendered every loaded track inside one LazyColumn item, so all
rows stayed composed and recomposed together; scrolling ~1350 saved
tracks exhausted the desktop heap (#3200). Rows are now separate lazy
items and the card is drawn once behind them. Only the current row
depends on the playing state, the saved tracks screen no longer reloads
from page 1 whenever a page arrives, cached tracks are streamed in 64 KB
chunks instead of read whole, and queue loading/restoring maps entries
in one pass instead of quadratically.

Desktop kept full-size embedded covers for local files and serialized
them as JSON number arrays into the local media index and the queue,
which is persisted on every change (#3193). Covers are now shrunk to
160 px like on Android, the queue no longer persists them (restore fills
them in from the index), and store files already bloated past 8 MB are
dropped instead of loaded.

Lists keyed on plugin ids crashed on blank or repeated ids (#3196); they
now go through uniqueLazyKeys. Queue persistence failures are logged
instead of crashing the app, a likely cause of #3198.

Fixes #3200
Fixes #3193
Fixes #3196
2026-11-02 13:37:05 +06:00
Kingkor Roy Tirtho
1b089a5fec fix(desktop): stop non-composable string lookups from querying the system theme
Compose Resources' getString/getPluralString/getStringArray call
getSystemResourceEnvironment() on every lookup, which on desktop asks
Skiko for the system theme. On Linux that is a blocking D-Bus call to the
desktop portal on the calling thread, and from a background coroutine it
segfaulted the GraalVM AppImage on Fedora GNOME when installing a plugin
from the Welcome screen.

Strings never depend on the theme, so app code now uses wrappers in
core/i18n that reuse the resource environment the UI already computed
(published by AppLocaleProvider), falling back to the library lookup
before anything is composed. A test guards against importing the library
getters again.

Fixes #3183
2026-11-02 10:17:35 +06:00
Brian López Medina
9785c93b6a feat(player): add a full screen now playing view on desktop
Large screens only had the bottom player bar; the expanded player was
reachable only from the compact layout. A new button next to the volume
slider opens a view that covers the window, with the album art in the
middle over a blurred copy of it, the track info, progress, playback
controls, volume, and the queue and lyrics buttons.

The view closes with its button or Esc. On desktop it can also put the
window in OS full screen, and leaves it again when the view closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:57:42 -06:00
Brian López Medina
09edc5a182 refactor(player): extract the large player's seek bar, transport and volume controls
Moves them into shared composables so another player can reuse the same
playback logic. The large player renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:19:03 -06:00
56 changed files with 1806 additions and 405 deletions

View File

@ -54,6 +54,7 @@
## 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)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room)
"-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
)
@ -556,7 +556,7 @@ nucleus.application {
// ProGuard is not used for native images; -Os is the size equivalent
optimization = NativeImageOptimization.SIZE
// Same ceiling as the JVM distribution's -Xmx
maxHeapSize = "384m"
maxHeapSize = "1g"
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
// a second ~30 MB copy as a resource
buildArgs.add("-H:+UnlockExperimentalVMOptions")

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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

@ -893,6 +893,8 @@
<string name="player_collapse_content_description">Collapse player</string>
<string name="player_expand_lyrics_content_description">Expand lyrics</string>
<string name="player_now_playing">Now Playing</string>
<string name="player_fullscreen_open_content_description">Open full screen player</string>
<string name="player_fullscreen_close_content_description">Close full screen player</string>
<string name="player_lyrics">Lyrics</string>
<string name="player_alternative_source">Alternative Source</string>
<string name="player_alternative_source_content_description">Alternative source</string>
@ -1098,6 +1100,8 @@
<string name="app_window_maximize">Maximize</string>
<string name="app_window_restore">Restore</string>
<string name="app_window_close">Close</string>
<string name="app_window_enter_fullscreen">Enter full screen</string>
<string name="app_window_exit_fullscreen">Exit full screen</string>
<!-- Android playback notification -->
<string name="notification_playback_channel_name">Playback</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,6 +81,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
@ -96,6 +98,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.ui.misc.SkeletonTree
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
import org.spotube.spotube.core.ui.misc.TextWithShimmer
import org.spotube.spotube.core.ui.misc.shimmerApply
import org.spotube.spotube.getPlatform
@ -119,6 +122,42 @@ private val CompactTrackListBreakpoint = 600.dp
private val LargeTrackListBreakpoint = 840.dp
private const val ShimmerRowCount = 6
private val TrackCardOuterPadding = 8.dp
private val TrackCardInnerPadding = 12.dp
private const val TrackCardTopKey = "track-card-top"
private const val TrackCardRowKeyPrefix = "track-card-row:"
private const val TrackCardFooterKey = "track-card-footer"
private const val TrackCardBottomKey = "track-card-bottom"
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
/**
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
*/
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
layout { measurable, constraints ->
val info = listState.layoutInfo
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
// Item offsets are relative to the viewport start, which is negative by the top padding.
val toLocal = -info.viewportStartOffset
val viewport = info.viewportEndOffset - info.viewportStartOffset
val overscan = viewport * 3
val first = cardItems.first()
val last = cardItems.last()
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
val bottom = if (last.key == TrackCardBottomKey) {
last.offset + last.size + toLocal - edgeInsetPx
} else viewport + overscan
val height = (bottom - top).coerceAtLeast(0)
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
}
enum class TrackSortOption(val label: StringResource) {
None(Res.string.track_list_sort_original),
Title(Res.string.track_list_sort_title),
@ -198,7 +237,7 @@ fun TrackList(
val shouldLoadMore = remember(density) {
derivedStateOf {
val loadMoreThreshold = with(density) { 200.dp.toPx() }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
// Trigger when the bottom of the track card is close to/inside the viewport,
// which means the user has scrolled through (or is about to scroll through)
// all loaded tracks.
@ -219,115 +258,124 @@ fun TrackList(
val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
// Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
}
// Shimmer, error, empty and next-page rows at the end of the card.
val trackCardFooter: @Composable () -> Unit = {
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
TrackListFeedbackRow(
message = error,
isError = true)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
val trackCardContent: @Composable () -> Unit = {
MillenniumCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
.padding(vertical = TrackCardOuterPadding),
contentPadding = PaddingValues(0.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
MillenniumSeparator()
}
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
TrackListFeedbackRow(
message = error,
isError = true)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
trackRow(displayedIndex, track)
}
trackCardFooter()
}
}
}
@ -454,29 +502,57 @@ fun TrackList(
if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
// Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
// they sit on is drawn once behind the list, spanning the card's laid-out items.
MillenniumCard(
modifier = Modifier
.matchParentSize()
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
contentPadding = PaddingValues(0.dp)) {}
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
// gaps around the card are applied to the items before and after it instead.
LazyColumn(
state = listState,
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp)) {
contentPadding = contentPadding) {
if (headerContent != null) {
item {
headerContent()
Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
}
}
if (!simplified) {
item { filterSortRow() }
item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
}
item(key = TrackCardTopKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
}
// Plugin ids can repeat (the same track twice in a playlist).
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
Column(Modifier.fillMaxWidth()) {
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, visibleTracks[displayedIndex])
}
}
item(key = TrackCardFooterKey) {
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
}
item(key = TrackCardBottomKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
}
item(key = "track-card") { trackCardContent() }
if (footerContent != null) {
item {
footerContent()
Box(Modifier.padding(top = 2.dp)) { footerContent() }
}
}
}
}
if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track ->

View File

@ -0,0 +1,25 @@
/*
* 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.runtime.Composable
// Toggles the OS full screen mode of the app window. Renders nothing where the app doesn't own
// a window (mobile).
@Composable
expect fun WindowFullscreenButton()

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.*
enum class JamSetupMode { Create, Join }

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_local_refresh_failed

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import org.koin.core.component.KoinComponent
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_install

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.savedTracksIdsFlow
repository.libraryChanges
) { _, _ -> }.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.downloads_status_cancelled

View File

@ -0,0 +1,287 @@
/*
* 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.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composeunstyled.Text
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.core.ui.component.WindowFullscreenButton
import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4
import org.spotube.spotube.resources.iconsax.IconsaxMusic
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_fullscreen_close_content_description
import spotube.composeapp.generated.resources.player_lyrics
import spotube.composeapp.generated.resources.queue_title
// The "now playing" view on large screens, opened from the large player. It covers the whole
// window with the album art in the middle over a blurred copy of it, like the expanded player
// does on small screens.
//
// The top bar has no title, so it stays clear of the macOS window buttons, and on the right it
// has the window full screen toggle and the button to close the view (Esc too)
// Below the album art the song title and artists are shown with the like button on the right
// Then the progress bar, and below it the queue and lyrics buttons on the left, the playback
// controls in the center and the volume slider on the right
@Composable
fun AppFullscreenPlayer(
onClose: () -> Unit,
modifier: Modifier = Modifier,
onQueue: () -> Unit = {},
onLyrics: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
)
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val navigationCommands: NavigationCommands = koinInject()
val focusRequester = remember { FocusRequester() }
val background = AppTheme.colors.background
// Takes the keyboard focus so Esc reaches this view
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
Box(
modifier = modifier
.fillMaxSize()
.clipToBounds()
.background(background)
// Keeps clicks from reaching the screen underneath
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {})
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
onClose()
true
} else {
false
}
}
.focusRequester(focusRequester)
.focusable()
) {
AsyncImage(
model = playerUiState.coverModel,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.blur(96.dp, BlurredEdgeTreatment.Rectangle),
contentScale = ContentScale.Crop
)
// Tones the art down towards the theme background, so the text stays readable in light
// and dark themes alike
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(background.copy(alpha = 0.45f), background.copy(alpha = 0.9f))
)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
horizontalAlignment = Alignment.CenterHorizontally
) {
ApplicationMainBar(
actions = {
WindowFullscreenButton()
MillenniumIconButton(
onClick = onClose,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxArrowDown4,
contentDescription = stringResource(Res.string.player_fullscreen_close_content_description)
)
}
},
backButton = false,
transparent = true
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.widthIn(max = 640.dp)
.aspectRatio(1f, matchHeightConstraintsFirst = true)
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(AppTheme.colors.surfaceContainerHighest)
) {
AsyncImage(
model = playerUiState.coverModel,
contentDescription = playerUiState.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Column(
modifier = Modifier
.widthIn(max = 720.dp)
.fillMaxWidth()
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = playerUiState.title,
style = AppTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
PlayerArtistsText(
artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
// The page opens beneath this view, so get out of the way
onClose()
},
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant
)
}
PlayerHeartButton(
audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel
)
}
PlayerSeekBar(
playerUiState = playerUiState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
audioPlayer = audioPlayer
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Row(
modifier = Modifier.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically
) {
MillenniumIconButton(
onClick = onQueue,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title))
}
MillenniumIconButton(
onClick = onLyrics,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics))
}
}
PlayerTransportControls(
playerUiState = playerUiState,
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
playIconSize = 30.dp,
audioPlayer = audioPlayer
)
PlayerVolumeControl(
playerUiState = playerUiState,
modifier = Modifier.align(Alignment.CenterEnd),
sliderModifier = Modifier.width(120.dp),
audioPlayer = audioPlayer
)
}
}
}
}
}

View File

@ -38,13 +38,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -55,17 +49,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.composeunstyled.AnchorSide
import com.composeunstyled.Text
import com.composeunstyled.LocalTextStyle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.LoopState
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.jam.JamRole
import org.spotube.spotube.core.jam.JamRoomService
import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.ui.theming.AppTheme
@ -76,22 +66,10 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore
import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
import org.spotube.spotube.resources.iconsax.IconsaxMusic
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
import org.spotube.spotube.resources.iconsax.IconsaxNext
import org.spotube.spotube.resources.iconsax.IconsaxPause
import org.spotube.spotube.resources.iconsax.IconsaxPlay
import org.spotube.spotube.resources.iconsax.IconsaxPrevious
import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic
import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic
import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne
import org.spotube.spotube.resources.iconsax.IconsaxShuffle
import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross
import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh
import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow
import org.spotube.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -101,29 +79,15 @@ import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumSlider
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_go_to_album
import spotube.composeapp.generated.resources.player_sleep_timer
import spotube.composeapp.generated.resources.player_track_details
import kotlin.time.Duration.Companion.milliseconds
import spotube.composeapp.generated.resources.player_alternative_source_content_description
import spotube.composeapp.generated.resources.player_disable_shuffle_content_description
import spotube.composeapp.generated.resources.player_download
import spotube.composeapp.generated.resources.player_enable_shuffle_content_description
import spotube.composeapp.generated.resources.player_loop_mode_all
import spotube.composeapp.generated.resources.player_loop_mode_none
import spotube.composeapp.generated.resources.player_loop_mode_one
import spotube.composeapp.generated.resources.player_fullscreen_open_content_description
import spotube.composeapp.generated.resources.player_lyrics
import spotube.composeapp.generated.resources.player_more_options_content_description
import spotube.composeapp.generated.resources.player_mute_content_description
import spotube.composeapp.generated.resources.player_next_content_description
import spotube.composeapp.generated.resources.player_pause_content_description
import spotube.composeapp.generated.resources.player_play_or_pause_content_description
import spotube.composeapp.generated.resources.player_previous_content_description
import spotube.composeapp.generated.resources.player_seek_label
import spotube.composeapp.generated.resources.player_unmute_content_description
import spotube.composeapp.generated.resources.player_volume_label
import spotube.composeapp.generated.resources.queue_title
// The main player on large screens shown at the bottom of the screen permanently. It should be
@ -134,6 +98,7 @@ import spotube.composeapp.generated.resources.queue_title
// On the center it contains the playback controls and the progress bar
// On the right it contains the queue btn, download button btn, alternative track source button
// and three dots for options grouped together and below these buttons the volume slider will be shown
// next to the button that opens the full screen player (AppFullscreenPlayer)
@Composable
fun AppLargePlayer(
modifier: Modifier = Modifier,
@ -142,6 +107,7 @@ fun AppLargePlayer(
onAlternativeSource: () -> Unit = {},
onMoreOptions: () -> Unit = {},
onLyrics: () -> Unit = {},
onFullscreen: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(),
downloadsViewModel: DownloadsViewModel = koinViewModel(),
@ -152,77 +118,14 @@ fun AppLargePlayer(
)
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val jamRoomService: JamRoomService = koinInject()
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
val navigationCommands: NavigationCommands = koinInject()
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
val coverModel = playerUiState.coverModel
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) {
seekProgress = playerUiState.progress
}
}
LaunchedEffect(playerUiState.volume) {
if (playerUiState.volume > 0f) {
lastNonZeroVolume = playerUiState.volume
}
}
fun onPlayPause() {
scope.launch {
if (playerUiState.isPlaying) {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
fun onSeekFinished() {
val durationMillis = playerUiState.seekDuration.inWholeMilliseconds
if (durationMillis <= 0L) return
scope.launch {
audioPlayer.seekTo(
(durationMillis * seekProgress.coerceIn(
0f,
1f
)).toLong().milliseconds
)
}
}
// A translucent tint (not near-opaque) so the blurred art behind still reads through, and
// the material's watery caustics sit on top of it.
val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f)
@ -299,100 +202,16 @@ fun AppLargePlayer(
modifier = Modifier.weight(1.1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
MillenniumSlider(
value = seekProgress,
onValueChange = {
seekProgress = it
isSeeking = true
},
onValueChangeFinished = {
isSeeking = false
onSeekFinished()
},
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
label = stringResource(Res.string.player_seek_label),
modifier = Modifier.fillMaxWidth()
)
Row(
PlayerSeekBar(
playerUiState = playerUiState,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatPlayerTime(playerUiState.position),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
Text(
text = formatPlayerTime(playerUiState.displayDuration),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
MillenniumIconButton(
onClick = ::onShuffleToggle,
enabled = !isJamGuest,
style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxShuffle,
contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description),
tint = if (playerUiState.isShuffling) {
AppTheme.colors.primary
} else {
AppTheme.colors.onSurfaceVariant
}
)
}
MillenniumIconButton(
onClick = ::onSkipPrevious,
enabled = !isJamGuest,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description))
}
MillenniumIconButton(
onClick = ::onPlayPause,
style = MillenniumIconButtonStyle.Primary
) {
MillenniumIcon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description),
tint = LocalTextStyle.current.color
)
}
MillenniumIconButton(
onClick = ::onSkipNext,
enabled = !isJamGuest,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description))
}
MillenniumIconButton(
onClick = ::onLoopToggle,
enabled = !isJamGuest,
style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary
) {
MillenniumIcon(
imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
LoopState.ONE -> Iconsax.IconsaxRepeateOne
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
},
contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }),
tint = if (playerUiState.loopState == LoopState.NONE) {
AppTheme.colors.onSurfaceVariant
} else {
AppTheme.colors.primary
}
)
}
}
audioPlayer = audioPlayer
)
PlayerTransportControls(
playerUiState = playerUiState,
modifier = Modifier.fillMaxWidth(),
audioPlayer = audioPlayer
)
}
Column(
@ -481,40 +300,19 @@ fun AppLargePlayer(
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
PlayerVolumeControl(
playerUiState = playerUiState,
sliderModifier = Modifier.fillMaxWidth(0.62f),
audioPlayer = audioPlayer
)
MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = {
scope.launch {
if (playerUiState.volume <= 0f) {
audioPlayer.setVolume(lastNonZeroVolume)
} else {
lastNonZeroVolume = playerUiState.volume
audioPlayer.setVolume(0f)
}
}
}
style = MillenniumIconButtonStyle.Quiet, onClick = onFullscreen
) {
val volumeIcon = when {
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
else -> Iconsax.IconsaxVolumeHigh
}
MillenniumIcon(
imageVector = volumeIcon,
contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description)
Iconsax.IconsaxFullscreen,
contentDescription = stringResource(Res.string.player_fullscreen_open_content_description)
)
}
MillenniumSlider(
value = playerUiState.volume,
onValueChange = {
lastNonZeroVolume = it
scope.launch {
audioPlayer.setVolume(it)
}
},
label = stringResource(Res.string.player_volume_label),
modifier = Modifier.fillMaxWidth(0.62f)
)
}
}
}

View File

@ -17,7 +17,12 @@
package org.spotube.spotube.modules.shell
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -104,6 +109,7 @@ fun AppShell(
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
val isFullscreenPlayerVisible by viewModel.isFullscreenPlayerVisible.collectAsState()
LaunchedEffect(navigatorCommands, navigator) {
launch {
@ -177,7 +183,25 @@ fun AppShell(
modifier = Modifier.fillMaxWidth(),
onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) })
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },
onFullscreen = viewModel::showFullscreenPlayer)
}
// Above the content and the large player, but below the queue and
// alternative source sheets so those can still be opened from it
AnimatedVisibility(
visible = isFullscreenPlayerVisible,
enter = fadeIn() + slideInVertically { it / 8 },
exit = fadeOut() + slideOutVertically { it / 8 }
) {
AppFullscreenPlayer(
modifier = Modifier.fillMaxSize(),
onClose = viewModel::hideFullscreenPlayer,
onQueue = queueViewModel::toggleQueueVisibility,
onLyrics = {
viewModel.hideFullscreenPlayer()
navigatorCommands.navigateTo(Routes.Lyrics)
})
}
if (isQueueVisible) {

View File

@ -54,6 +54,7 @@ class AppShellViewModel : ViewModel() {
val expandedPlayerVisibilityThreshold = 0.001f
private val lyricsOverlayVisible = MutableStateFlow(false)
private val fullscreenPlayerVisible = MutableStateFlow(false)
private val compactChrome = MutableStateFlow(CompactChromeUiState())
val compactChromeUiState = compactChrome.asStateFlow()
@ -62,6 +63,7 @@ class AppShellViewModel : ViewModel() {
}
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
val isFullscreenPlayerVisible: StateFlow<Boolean> = fullscreenPlayerVisible.asStateFlow()
@Composable
fun useSidebar(maxWidth: Dp? = null): Boolean {
@ -113,4 +115,12 @@ class AppShellViewModel : ViewModel() {
fun toggleLyricsOverlay() {
lyricsOverlayVisible.update { !it }
}
fun showFullscreenPlayer() {
fullscreenPlayerVisible.value = true
}
fun hideFullscreenPlayer() {
fullscreenPlayerVisible.value = false
}
}

View File

@ -0,0 +1,312 @@
/*
* 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.shell
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.LocalTextStyle
import com.composeunstyled.Text
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSlider
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.LoopState
import org.spotube.spotube.core.jam.JamRole
import org.spotube.spotube.core.jam.JamRoomService
import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxNext
import org.spotube.spotube.resources.iconsax.IconsaxPause
import org.spotube.spotube.resources.iconsax.IconsaxPlay
import org.spotube.spotube.resources.iconsax.IconsaxPrevious
import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic
import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic
import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne
import org.spotube.spotube.resources.iconsax.IconsaxShuffle
import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross
import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh
import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_disable_shuffle_content_description
import spotube.composeapp.generated.resources.player_enable_shuffle_content_description
import spotube.composeapp.generated.resources.player_loop_mode_all
import spotube.composeapp.generated.resources.player_loop_mode_none
import spotube.composeapp.generated.resources.player_loop_mode_one
import spotube.composeapp.generated.resources.player_mute_content_description
import spotube.composeapp.generated.resources.player_next_content_description
import spotube.composeapp.generated.resources.player_pause_content_description
import spotube.composeapp.generated.resources.player_play_or_pause_content_description
import spotube.composeapp.generated.resources.player_previous_content_description
import spotube.composeapp.generated.resources.player_seek_label
import spotube.composeapp.generated.resources.player_unmute_content_description
import spotube.composeapp.generated.resources.player_volume_label
import kotlin.time.Duration.Companion.milliseconds
// Playback controls shared by the large player and the full screen player, so both drive the
// audio player the same way.
// The seek slider with the elapsed and total time below it
@Composable
internal fun PlayerSeekBar(
playerUiState: PlayerUiState,
modifier: Modifier = Modifier,
audioPlayer: AudioPlayerInterface = koinInject()
) {
val scope = rememberCoroutineScope()
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) {
seekProgress = playerUiState.progress
}
}
fun onSeekFinished() {
val durationMillis = playerUiState.seekDuration.inWholeMilliseconds
if (durationMillis <= 0L) return
scope.launch {
audioPlayer.seekTo(
(durationMillis * seekProgress.coerceIn(
0f,
1f
)).toLong().milliseconds
)
}
}
Column(modifier = modifier) {
MillenniumSlider(
value = seekProgress,
onValueChange = {
seekProgress = it
isSeeking = true
},
onValueChangeFinished = {
isSeeking = false
onSeekFinished()
},
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
label = stringResource(Res.string.player_seek_label),
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatPlayerTime(playerUiState.position),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
Text(
text = formatPlayerTime(playerUiState.displayDuration),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
}
}
// Shuffle, previous, play/pause, next and loop. Jam guests can only play/pause.
@Composable
internal fun PlayerTransportControls(
playerUiState: PlayerUiState,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
playIconSize: Dp = Dp.Unspecified,
audioPlayer: AudioPlayerInterface = koinInject(),
jamRoomService: JamRoomService = koinInject()
) {
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
fun onPlayPause() {
scope.launch {
if (playerUiState.isPlaying) {
audioPlayer.pause()
} else {
audioPlayer.play()
}
}
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement,
modifier = modifier
) {
MillenniumIconButton(
onClick = ::onShuffleToggle,
enabled = !isJamGuest,
style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxShuffle,
contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description),
tint = if (playerUiState.isShuffling) {
AppTheme.colors.primary
} else {
AppTheme.colors.onSurfaceVariant
}
)
}
MillenniumIconButton(
onClick = ::onSkipPrevious,
enabled = !isJamGuest,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description))
}
MillenniumIconButton(
onClick = ::onPlayPause,
style = MillenniumIconButtonStyle.Primary
) {
MillenniumIcon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description),
modifier = if (playIconSize == Dp.Unspecified) Modifier else Modifier.size(playIconSize),
tint = LocalTextStyle.current.color
)
}
MillenniumIconButton(
onClick = ::onSkipNext,
enabled = !isJamGuest,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description))
}
MillenniumIconButton(
onClick = ::onLoopToggle,
enabled = !isJamGuest,
style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary
) {
MillenniumIcon(
imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
LoopState.ONE -> Iconsax.IconsaxRepeateOne
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
},
contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }),
tint = if (playerUiState.loopState == LoopState.NONE) {
AppTheme.colors.onSurfaceVariant
} else {
AppTheme.colors.primary
}
)
}
}
}
// The mute toggle followed by the volume slider. Unmuting restores the last non-zero volume.
@Composable
internal fun PlayerVolumeControl(
playerUiState: PlayerUiState,
modifier: Modifier = Modifier,
sliderModifier: Modifier = Modifier,
audioPlayer: AudioPlayerInterface = koinInject()
) {
val scope = rememberCoroutineScope()
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
LaunchedEffect(playerUiState.volume) {
if (playerUiState.volume > 0f) {
lastNonZeroVolume = playerUiState.volume
}
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = {
scope.launch {
if (playerUiState.volume <= 0f) {
audioPlayer.setVolume(lastNonZeroVolume)
} else {
lastNonZeroVolume = playerUiState.volume
audioPlayer.setVolume(0f)
}
}
}
) {
val volumeIcon = when {
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
else -> Iconsax.IconsaxVolumeHigh
}
MillenniumIcon(
imageVector = volumeIcon,
contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description)
)
}
MillenniumSlider(
value = playerUiState.volume,
onValueChange = {
lastNonZeroVolume = it
scope.launch {
audioPlayer.setVolume(it)
}
},
label = stringResource(Res.string.player_volume_label),
modifier = sliderModifier
)
}
}

View File

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

View File

@ -0,0 +1,90 @@
/*
* 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.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxFullscreen: ImageVector
get() {
if (_IconsaxFullscreen != null) {
return _IconsaxFullscreen!!
}
_IconsaxFullscreen = ImageVector.Builder(
name = "IconsaxFullscreen",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3f, 9f)
verticalLineTo(5f)
curveTo(3f, 3.9f, 3.9f, 3f, 5f, 3f)
horizontalLineTo(9f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(15f, 3f)
horizontalLineTo(19f)
curveTo(20.1f, 3f, 21f, 3.9f, 21f, 5f)
verticalLineTo(9f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(21f, 15f)
verticalLineTo(19f)
curveTo(21f, 20.1f, 20.1f, 21f, 19f, 21f)
horizontalLineTo(15f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(9f, 21f)
horizontalLineTo(5f)
curveTo(3.9f, 21f, 3f, 20.1f, 3f, 19f)
verticalLineTo(15f)
}
}.build()
return _IconsaxFullscreen!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFullscreen: ImageVector? = null

View File

@ -0,0 +1,90 @@
/*
* 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.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxFullscreenExit: ImageVector
get() {
if (_IconsaxFullscreenExit != null) {
return _IconsaxFullscreenExit!!
}
_IconsaxFullscreenExit = ImageVector.Builder(
name = "IconsaxFullscreenExit",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(9f, 3f)
verticalLineTo(7f)
curveTo(9f, 8.1f, 8.1f, 9f, 7f, 9f)
horizontalLineTo(3f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(21f, 9f)
horizontalLineTo(17f)
curveTo(15.9f, 9f, 15f, 8.1f, 15f, 7f)
verticalLineTo(3f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(15f, 21f)
verticalLineTo(17f)
curveTo(15f, 15.9f, 15.9f, 15f, 17f, 15f)
horizontalLineTo(21f)
}
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3f, 15f)
horizontalLineTo(7f)
curveTo(8.1f, 15f, 9f, 15.9f, 9f, 17f)
verticalLineTo(21f)
}
}.build()
return _IconsaxFullscreenExit!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxFullscreenExit: ImageVector? = null

View File

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

View File

@ -0,0 +1,74 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.window.WindowPlacement
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
import org.spotube.spotube.resources.iconsax.IconsaxFullscreenExit
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.app_window_enter_fullscreen
import spotube.composeapp.generated.resources.app_window_exit_fullscreen
@Composable
actual fun WindowFullscreenButton() {
val windowState = LocalWindowState.current
// Where to go back to on exit; only set while this button put the window in full screen
var placementBeforeFullscreen by remember { mutableStateOf<WindowPlacement?>(null) }
val isFullscreen = windowState.placement == WindowPlacement.Fullscreen
// Full screen belongs to the screen that turned it on, so leaving that screen leaves it too
DisposableEffect(windowState) {
onDispose {
val previous = placementBeforeFullscreen
if (previous != null && windowState.placement == WindowPlacement.Fullscreen) {
windowState.placement = previous
}
}
}
MillenniumIconButton(
onClick = {
if (isFullscreen) {
windowState.placement = placementBeforeFullscreen ?: WindowPlacement.Floating
placementBeforeFullscreen = null
} else {
placementBeforeFullscreen = windowState.placement
windowState.placement = WindowPlacement.Fullscreen
}
},
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
imageVector = if (isFullscreen) Iconsax.IconsaxFullscreenExit else Iconsax.IconsaxFullscreen,
contentDescription = stringResource(
if (isFullscreen) Res.string.app_window_exit_fullscreen else Res.string.app_window_enter_fullscreen
)
)
}
}

View File

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

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.jetbrains.compose.resources.getString
import org.spotube.spotube.core.i18n.getString
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res

View File

@ -0,0 +1,46 @@
/*
* 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

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.library.local_tracks.media
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import org.jetbrains.skia.Paint
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class CoverThumbnailTest {
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
// Noise-free but non-uniform so the PNG isn't trivially small.
for (x in 0 until width step 7) {
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
}
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
}
@Test
fun largeCoverIsShrunkToASmallJpeg() {
val original = png(1600, 1200)
val thumbnail = assertNotNull(coverThumbnail(original))
Image.makeFromEncoded(thumbnail).use { image ->
assertEquals(160, minOf(image.width, image.height))
assertEquals(213, image.width)
}
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
}
@Test
fun unreadableCoverIsDropped() {
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
}
}

View File

@ -0,0 +1,25 @@
/*
* 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.runtime.Composable
@Composable
actual fun WindowFullscreenButton() {
// No-op on mobile targets.
}