Compare commits

...

4 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
aca3aeb592 fix(theme): follow the OS dark mode on desktop
Compose's isSystemInDarkTheme never updates under the Tao window, so Theme.SYSTEM
stayed light. Use Nucleus' darkmode-detector on JVM and keep Compose's check on mobile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:53:47 +06:00
Kingkor Roy Tirtho
5b36edd259 fix(windows): make VLC playback work through the stream proxy
The proxy copied every upstream header, duplicating Content-Length, which VLC
rejects. Forward only the range/validator headers. Bump vlcj-bundler to 0.2.1,
which bundles the Matroska demuxer needed for WebM. Make StreamingUrlRepository's
platform injectable and add VLC log switch via DebugConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
ea01dec6ea fix(settings): palette swatches follow the active light/dark appearance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
79266fd483 fix(windows): window not spawning due to early COM initialization 2026-10-27 16:35:27 +06:00
16 changed files with 238 additions and 35 deletions

View File

@ -366,6 +366,7 @@ kotlin {
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control) implementation(libs.nucleus.media.control)
implementation(libs.nucleus.darkmode.detector)
} }
} }
} }

View File

@ -39,4 +39,6 @@ object DebugConfig {
* Keep it `false` when committing. * Keep it `false` when committing.
*/ */
const val FORCE_SUPPORT_REMINDER: Boolean = false const val FORCE_SUPPORT_REMINDER: Boolean = false
const val FORCE_VLC_LOGS: Boolean = false
} }

View File

@ -425,7 +425,7 @@ internal class StreamProxy(
if (requestMethod == HttpMethod.Head) { if (requestMethod == HttpMethod.Head) {
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
}) })
return return
@ -446,7 +446,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
override val contentType: ContentType? = override val contentType: ContentType? =
downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse)
@ -594,4 +594,28 @@ internal class StreamProxy(
} }
}.build() }.build()
} }
}
private val PLAYER_PASSTHROUGH_HEADERS = listOf(
HttpHeaders.ContentRange,
HttpHeaders.AcceptRanges,
HttpHeaders.ETag,
HttpHeaders.LastModified,
)
/**
* The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate
* Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop
* headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after
* the response headers. Content-Range and Accept-Ranges must stay: without them the player
* treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed.
*/
internal fun Headers.passthroughForPlayer(): Headers {
val upstream = this
return HeadersBuilder().apply {
PLAYER_PASSTHROUGH_HEADERS.forEach { name ->
upstream.getAll(name)?.forEach { value -> append(name, value) }
}
}.build()
} }

View File

@ -80,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -145,7 +146,7 @@ class StreamingUrlRepository(
} else { } else {
stream.streams stream.streams
} }
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
} }
@ -259,7 +260,7 @@ class StreamingUrlRepository(
"lowest wins):\n" + "lowest wins):\n" +
streams.joinToString("\n") { stream -> streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " " val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " + "${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
@ -268,8 +269,8 @@ class StreamingUrlRepository(
} }
when { when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" } selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(currentPlatform) -> logger.w { !selected.isPlayableOn(platform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject" "${selected.describeForLog()}, which the player will likely reject"
} }
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
@ -424,12 +425,16 @@ internal fun selectPreferredAudioStream(
} }
) )
// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load. private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska")
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
// AVPlayer has no WebM/Ogg/Matroska demuxer.
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
/** /**
* Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android) * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
* handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/ */
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) && isContainerPlayableOn(container, platform) &&
@ -437,7 +442,7 @@ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ /** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +

View File

@ -17,7 +17,6 @@
package org.spotube.spotube.core.ui.theming package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -205,7 +204,7 @@ fun SpotubeTheme(
val isDarkTheme = when (settings.theme) { val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false Theme.LIGHT -> false
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemDarkTheme()
} }
val palette: MillenniumPalette = settings.palette val palette: MillenniumPalette = settings.palette

View File

@ -0,0 +1,24 @@
/*
* 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.theming
import androidx.compose.runtime.Composable
/** Whether the OS is in dark mode, observed live; desktop can't rely on Compose's own check under Tao. */
@Composable
expect fun isSystemDarkTheme(): Boolean

View File

@ -108,14 +108,14 @@ data class JamBroker(
val connectionTimeoutSeconds: Int = 10) val connectionTimeoutSeconds: Int = 10)
/** /**
* opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC. * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
*/ */
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
val ios = platform == PlatformType.IOS val aac = !isContainerPlayableOn("webm", platform)
return AudioFormat( return AudioFormat(
codec = if (ios) "aac" else "opus", codec = if (aac) "aac" else "opus",
container = if (ios) "mp4" else "webm", container = if (aac) "mp4" else "webm",
qualities = listOf( qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),

View File

@ -44,11 +44,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.core.ui.theming.isLightAppearance
import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.Theme import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings import org.spotube.spotube.modules.settings.UserSettings
@ -206,7 +206,8 @@ private fun PaletteSwatch(
label: String, label: String,
selected: Boolean, selected: Boolean,
onClick: () -> Unit) { onClick: () -> Unit) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
@ -222,7 +223,8 @@ private fun PaletteSwatch(
@Composable @Composable
private fun PaletteTrailingSwatch(palette: MillenniumPalette) { private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
@ -241,7 +243,7 @@ private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
@Composable @Composable
private fun PalettePreview(palette: MillenniumPalette) { private fun PalettePreview(palette: MillenniumPalette) {
val dark = AppTheme.colors.background.luminance() < 0.5f val dark = !isLightAppearance()
MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) {
Column( Column(
modifier = Modifier modifier = Modifier

View File

@ -0,0 +1,57 @@
/*
* 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.http.HeadersBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class StreamProxyHeadersTest {
// Lowercase names and values as googlevideo sends them
private val upstream = HeadersBuilder().apply {
append("accept-ranges", "bytes")
append("alt-svc", "h3=\":443\"")
append("cache-control", "private, max-age=21298")
append("connection", "keep-alive")
append("content-length", "3523553")
append("content-range", "bytes 0-3523552/3523553")
append("content-type", "audio/webm")
append("last-modified", "Tue, 18 Feb 2025 22:42:11 GMT")
}.build()
@Test
fun `keeps the headers a player needs to seek`() {
val forwarded = upstream.passthroughForPlayer()
assertEquals("bytes 0-3523552/3523553", forwarded["Content-Range"])
assertEquals("bytes", forwarded["Accept-Ranges"])
assertEquals("Tue, 18 Feb 2025 22:42:11 GMT", forwarded["Last-Modified"])
}
@Test
fun `drops headers that are duplicated or hop-by-hop`() {
val forwarded = upstream.passthroughForPlayer()
// Length and type are set on the OutgoingContent; forwarding them too emits them twice
assertNull(forwarded["Content-Length"])
assertNull(forwarded["Content-Type"])
assertNull(forwarded["Connection"])
assertNull(forwarded["Alt-Svc"])
assertNull(forwarded["Cache-Control"])
}
}

View File

@ -5,6 +5,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
@ -99,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest {
audioPlayerQueue = queue, audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository, matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings, settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs) streamUrlCacheTtlMs = cacheTtlMs,
// Not the host's platform: Windows and iOS don't prefer opus/webm
platform = PlatformType.Linux)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI) val service = FakeAudioPluginService(pluginId, audioAPI)
@ -236,6 +239,11 @@ class StreamingUrlRepositoryBehaviorTest {
) )
} }
// The default format depends on the host platform (aac/mp4 on Windows and iOS)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "opus", container = "webm"))
}
val first = h.repository.resolveStreamInfo(t) val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url) assertEquals("https://example.com/opus.webm", first?.url)

View File

@ -35,8 +35,10 @@ class StreamingFormatDefaultsTest {
@Test @Test
fun `other platforms keep opus in webm`() { fun `other platforms keep opus in webm`() {
val format = defaultStreamingMusicFormat(PlatformType.Android) listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
assertEquals("opus" to "webm", format.codec to format.container) val format = defaultStreamingMusicFormat(platform)
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
}
} }
@Test @Test

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.DebugConfig
import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.medialist.MediaList import uk.co.caprica.vlcj.medialist.MediaList
@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1) private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow() actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow() actual override val currentMediaItemFlow: StateFlow<MediaItem?> =
_currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow() actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow() actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow() actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow() actual override val bufferingPositionFlow: StateFlow<Duration> =
_bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow() actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow() actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow() actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
init { init {
VLCBundleLoaderGenerated.getVerifiedPath() VLCBundleLoaderGenerated.getVerifiedPath()
val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray()
mediaPlayerFactory = MediaPlayerFactory( mediaPlayerFactory = MediaPlayerFactory(
null as NativeDiscovery?, null as NativeDiscovery?,
"--no-video", "--no-video",
@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
"--aout=any", "--aout=any",
"--no-osd", "--no-osd",
"--no-snapshot-preview", "--no-snapshot-preview",
// "--verbose=2" *vlcLogArgs
// "--quiet"
) )
audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) {
override fun opening(mediaPlayer: MediaPlayer) { override fun opening(mediaPlayer: MediaPlayer) {
@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
) { ) {
val durationMs = mediaPlayer.status().length() val durationMs = mediaPlayer.status().length()
_bufferingPosition.tryEmit( _bufferingPosition.tryEmit(
if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO) if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO
)
_playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY) _playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY)
} }
@ -184,7 +188,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
override fun nextItem( override fun nextItem(
mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer,
item: uk.co.caprica.vlcj.media.MediaRef) { item: uk.co.caprica.vlcj.media.MediaRef
) {
lock.withLock { lock.withLock {
val targetIndex = pendingNextIndex ?: run { val targetIndex = pendingNextIndex ?: run {
val currentMrl = runCatching { val currentMrl = runCatching {
@ -336,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
} }
actual override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) { actual override suspend fun load(
playlist: List<MediaItem>,
autoPlay: Boolean,
startPosition: Int
) {
lock.withLock { lock.withLock {
if (disposed) return if (disposed) return

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.theming
import androidx.compose.runtime.Composable
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
// The Tao window never feeds Compose's LocalSystemTheme, so ask the OS directly
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkMode()

View File

@ -132,6 +132,15 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta
return PersistedWindowState.of(size, saved.placement) return PersistedWindowState.of(size, saved.placement)
} }
/** Runs [block] on a fresh thread and waits for it, rethrowing whatever it threw. */
private fun runOnWorkerThread(name: String, block: () -> Unit) {
var failure: Throwable? = null
val thread = Thread({ runCatching(block).onFailure { failure = it } }, name)
thread.start()
thread.join()
failure?.let { throw it }
}
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class) @OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
fun main() { fun main() {
// Sets compose.application.resources.dir in the native image. initKoin() creates the audio // Sets compose.application.resources.dir in the native image. initKoin() creates the audio
@ -140,8 +149,19 @@ fun main() {
GraalVmInitializer.initialize() GraalVmInitializer.initialize()
AppImageEnvironment.apply() AppImageEnvironment.apply()
FileKit.init(appId = "org.spotube.spotube") FileKit.init(appId = "org.spotube.spotube")
initKoin() val initApp = {
NewPipeDownloader.init(KoinPathsProvider.paths) initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
}
// libvlc and the system media controls initialize COM as multithreaded on the calling thread. In the
// native image the main thread also creates the Tao window, whose OleInitialize needs a single-threaded
// apartment and panics with RPC_E_CHANGED_MODE, so on Windows Koin's eager singletons are created
// elsewhere. COM doesn't exist on macOS/Linux, where this stays on the main thread as before.
if (getPlatform().type == PlatformType.Windows) {
runOnWorkerThread("spotube-init", initApp)
} else {
initApp()
}
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
nucleusApplication(backend = NucleusBackend.Tao) { nucleusApplication(backend = NucleusBackend.Tao) {

View File

@ -0,0 +1,24 @@
/*
* 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.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme()

View File

@ -72,7 +72,7 @@ ksp = "2.3.12"
spotube-gradle = "0.1.2" spotube-gradle = "0.1.2"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.2.0" vlcjBundler = "0.2.1"
vanniktechMavenPublish = "0.37.0" vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
@ -134,6 +134,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" }
nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" } nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" }
nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleusNucleusApplication" }
nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" } nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }