From 79266fd483e92fa6f97900bfe3826cc5ca726959 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 16:35:27 +0600 Subject: [PATCH 1/7] fix(windows): window not spawning due to early COM initialization --- .../kotlin/org/spotube/spotube/main.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt index a8d10f0e..17f57aec 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt @@ -132,6 +132,15 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta 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) fun main() { // Sets compose.application.resources.dir in the native image. initKoin() creates the audio @@ -140,8 +149,19 @@ fun main() { GraalVmInitializer.initialize() AppImageEnvironment.apply() FileKit.init(appId = "org.spotube.spotube") - initKoin() - NewPipeDownloader.init(KoinPathsProvider.paths) + val initApp = { + 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) nucleusApplication(backend = NucleusBackend.Tao) { From ea01dec6ea43d6e3980a07218f8bbf198e43d602 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:49:20 +0600 Subject: [PATCH 2/7] fix(settings): palette swatches follow the active light/dark appearance Co-Authored-By: Claude Sonnet 5.5 --- .../modules/settings/sections/AppearanceSection.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt index 9b7ba138..3186eb9e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/AppearanceSection.kt @@ -44,11 +44,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp 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.Theme import org.spotube.spotube.modules.settings.UserSettings @@ -206,7 +206,8 @@ private fun PaletteSwatch( label: String, selected: Boolean, 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( modifier = Modifier .size(40.dp) @@ -222,7 +223,8 @@ private fun PaletteSwatch( @Composable 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( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { @@ -241,7 +243,7 @@ private fun PaletteTrailingSwatch(palette: MillenniumPalette) { @Composable private fun PalettePreview(palette: MillenniumPalette) { - val dark = AppTheme.colors.background.luminance() < 0.5f + val dark = !isLightAppearance() MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { Column( modifier = Modifier From 5b36edd259915ba7ffe1217198c368eb9741cb6b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:49:20 +0600 Subject: [PATCH 3/7] 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 --- .../org/spotube/spotube/core/DebugConfig.kt | 2 + .../spotube/core/server/StreamProxy.kt | 28 ++++++++- .../core/server/StreamingUrlRepository.kt | 25 ++++---- .../modules/settings/SettingsModels.kt | 8 +-- .../core/server/StreamProxyHeadersTest.kt | 57 +++++++++++++++++++ .../StreamingUrlRepositoryBehaviorTest.kt | 10 +++- .../settings/StreamingFormatDefaultsTest.kt | 6 +- .../core/audioplayer/AudioPlayer.jvm.kt | 23 +++++--- gradle/libs.versions.toml | 2 +- 9 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt index b1e5941d..18792453 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -39,4 +39,6 @@ object DebugConfig { * Keep it `false` when committing. */ const val FORCE_SUPPORT_REMINDER: Boolean = false + + const val FORCE_VLC_LOGS: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 900d830a..338803bc 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -425,7 +425,7 @@ internal class StreamProxy( if (requestMethod == HttpMethod.Head) { call.respond(object : OutgoingContent.NoContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength }) return @@ -446,7 +446,7 @@ internal class StreamProxy( call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength override val contentType: ContentType? = downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) @@ -594,4 +594,28 @@ internal class StreamProxy( } }.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() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 19692bf6..11a8ae60 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -80,7 +80,8 @@ class StreamingUrlRepository( private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: TrackSourceRepository, 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() private val streamUrlCacheMutex = Mutex() @@ -145,7 +146,7 @@ class StreamingUrlRepository( } else { stream.streams } - return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform) .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } } @@ -259,7 +260,7 @@ class StreamingUrlRepository( "lowest wins):\n" + streams.joinToString("\n") { stream -> 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)}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + @@ -268,8 +269,8 @@ class StreamingUrlRepository( } when { selected == null -> logger.w { "[Resolve $trackId] no stream selected" } - !selected.isPlayableOn(currentPlatform) -> logger.w { - "[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + + !selected.isPlayableOn(platform) -> logger.w { + "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " + "${selected.describeForLog()}, which the player will likely reject" } 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 IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska") +private val MATROSKA_CONTAINERS = setOf("webm", "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> = 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) - * handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. + * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle + * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. */ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = 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]. */ 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 = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 8d0fa86d..f22688a3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -108,14 +108,14 @@ data class JamBroker( 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). */ internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { - val ios = platform == PlatformType.IOS + val aac = !isContainerPlayableOn("webm", platform) return AudioFormat( - codec = if (ios) "aac" else "opus", - container = if (ios) "mp4" else "webm", + codec = if (aac) "aac" else "opus", + container = if (aac) "mp4" else "webm", qualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt new file mode 100644 index 00000000..441a88ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt @@ -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 . + */ + +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"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index f297b17a..28feb80c 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -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.StreamProtocol 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.modules.settings.UserSettings import co.touchlab.kermit.Logger @@ -99,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest { audioPlayerQueue = queue, matchedTracksRepository = matchedTracksRepository, 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 { 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) assertEquals("https://example.com/opus.webm", first?.url) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt index cc35c849..c25d82a6 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -35,8 +35,10 @@ class StreamingFormatDefaultsTest { @Test fun `other platforms keep opus in webm`() { - val format = defaultStreamingMusicFormat(PlatformType.Android) - assertEquals("opus" to "webm", format.codec to format.container) + listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform -> + val format = defaultStreamingMusicFormat(platform) + assertEquals("opus" to "webm", format.codec to format.container, platform.name) + } } @Test diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt index 37cb7a42..a5f771bc 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch 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.discovery.NativeDiscovery import uk.co.caprica.vlcj.medialist.MediaList @@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val _error = MutableSharedFlow(extraBufferCapacity = 1) actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = + _currentMediaItem.asStateFlow() actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() actual override val durationFlow: StateFlow = _duration.asStateFlow() actual override val positionFlow: StateFlow = _position.asStateFlow() - actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = + _bufferingPosition.asStateFlow() actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() @@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface init { VLCBundleLoaderGenerated.getVerifiedPath() + val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray() mediaPlayerFactory = MediaPlayerFactory( null as NativeDiscovery?, "--no-video", @@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface "--aout=any", "--no-osd", "--no-snapshot-preview", -// "--verbose=2" -// "--quiet" + *vlcLogArgs ) audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { override fun opening(mediaPlayer: MediaPlayer) { @@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) { val durationMs = mediaPlayer.status().length() _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) } @@ -184,7 +188,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface override fun nextItem( mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, - item: uk.co.caprica.vlcj.media.MediaRef) { + item: uk.co.caprica.vlcj.media.MediaRef + ) { lock.withLock { val targetIndex = pendingNextIndex ?: run { val currentMrl = runCatching { @@ -336,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } } - actual override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { lock.withLock { if (disposed) return diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838df455..c167d59c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" -vlcjBundler = "0.2.0" +vlcjBundler = "0.2.1" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0" From aca3aeb592d2873620a29e5d38a2cdc107382407 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 27 Oct 2026 18:53:47 +0600 Subject: [PATCH 4/7] 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 --- composeApp/build.gradle.kts | 1 + .../spotube/core/ui/theming/AppTheme.kt | 3 +-- .../core/ui/theming/SystemDarkTheme.kt | 24 ++++++++++++++++++ .../core/ui/theming/SystemDarkTheme.jvm.kt | 25 +++++++++++++++++++ .../core/ui/theming/SystemDarkTheme.mobile.kt | 24 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt create mode 100644 composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a72f675f..99794847 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -366,6 +366,7 @@ kotlin { implementation(libs.nucleus.decorated.window.tao) implementation(libs.compose.native.tray) implementation(libs.nucleus.media.control) + implementation(libs.nucleus.darkmode.detector) } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt index 6720efb9..b283451e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/AppTheme.kt @@ -17,7 +17,6 @@ package org.spotube.spotube.core.ui.theming -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -205,7 +204,7 @@ fun SpotubeTheme( val isDarkTheme = when (settings.theme) { Theme.LIGHT -> false Theme.DARK -> true - Theme.SYSTEM -> isSystemInDarkTheme() + Theme.SYSTEM -> isSystemDarkTheme() } val palette: MillenniumPalette = settings.palette diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt new file mode 100644 index 00000000..3efa4474 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.kt @@ -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 . + */ + +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 diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt new file mode 100644 index 00000000..eae0a660 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.jvm.kt @@ -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 . + */ + +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() diff --git a/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt new file mode 100644 index 00000000..047248e3 --- /dev/null +++ b/composeApp/src/mobileMain/kotlin/org/spotube/spotube/core/ui/theming/SystemDarkTheme.mobile.kt @@ -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 . + */ + +package org.spotube.spotube.core.ui.theming + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +@Composable +actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c167d59c..ccc34f1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -134,6 +134,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version 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-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-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } From eb972c8633ebcb0d8d6f2a449a301c7031221c57 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 11:25:22 +0600 Subject: [PATCH 5/7] fix(dependencies): update Android target SDK and various library versions --- .github/workflows/spotube-release-binary.yml | 137 ++++++------------- gradle/libs.versions.toml | 28 +--- 2 files changed, 46 insertions(+), 119 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index f12a0d2e..cc69ab89 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -12,6 +12,19 @@ on: - stable - beta - nightly + platform: + description: 'Platform to build (nightly only; stable and beta always build everything)' + required: true + type: choice + default: 'all' + options: + - all + - android + - linux-x86_64 + - linux-aarch64 + - windows + - macos + - ios dry_run: description: 'Dry run (build & upload artifacts only, skip creating release)' required: false @@ -29,67 +42,29 @@ permissions: # Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }} cancel-in-progress: false -env: - COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview - COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d - jobs: - prepare-deps: - name: Prepare Dependencies - runs-on: macos-latest + validate-inputs: + name: Validate Inputs + runs-on: ubuntu-latest steps: - - name: Checkout ComposeNativeWebview - uses: actions/checkout@v7 - with: - repository: ${{ env.COMPOSE_WEBVIEW_REPO }} - ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} - token: ${{ secrets.GITHUB_TOKEN }} - path: compose-webview - - - name: Set up JDK 21 - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: 21 - - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Publish compose-webview to mavenLocal - working-directory: compose-webview + - name: Single platform builds are nightly only + if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }} run: | - chmod +x gradlew - ./gradlew publishToMavenLocal - - - name: Upload maven local - uses: actions/upload-artifact@v7 - with: - name: maven-local - path: ~/.m2/repository/ + echo "::error::Building a single platform is only supported for nightly releases" + exit 1 build-android: name: Android - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -152,7 +127,8 @@ jobs: build-linux: name: Linux Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: @@ -161,12 +137,6 @@ jobs: with: fetch-depth: 0 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Install packaging tools run: | sudo apt-get update @@ -265,7 +235,8 @@ jobs: build-linux-arm64: name: Linux Desktop (aarch64) - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }} timeout-minutes: 120 runs-on: ubuntu-24.04-arm steps: @@ -281,12 +252,6 @@ jobs: with: packages: tools platform-tools ndk;28.2.13676358 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Install packaging tools run: | sudo apt-get update @@ -385,18 +350,13 @@ jobs: build-windows: name: Windows Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }} timeout-minutes: 120 runs-on: windows-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -448,18 +408,13 @@ jobs: build-macos: name: macOS Desktop - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -516,18 +471,13 @@ jobs: build-ios: name: iOS - needs: prepare-deps + needs: validate-inputs + if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -585,8 +535,10 @@ jobs: create-release: name: Create GitHub Release - if: ${{ !inputs.dry_run }} + # Skipped platform jobs count as skipped, not failed, so a single platform build still releases + if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }} needs: + - validate-inputs - build-android - build-linux - build-linux-arm64 @@ -599,13 +551,6 @@ jobs: steps: - uses: actions/checkout@v7 - # The build resolves its plugins from mavenLocal, so Gradle needs the same setup as the build jobs - - name: Download maven local - uses: actions/download-artifact@v8 - with: - name: maven-local - path: ~/.m2/repository/ - - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -639,6 +584,7 @@ jobs: uses: actions/download-artifact@v8 - name: Generate release notes + if: ${{ inputs.platform == 'all' }} run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md @@ -658,9 +604,12 @@ jobs: tag_name: ${{ steps.vars.outputs.tag }} # A new tag (stable) would otherwise point at the default branch instead of the built commit target_commitish: ${{ github.sha }} - fail_on_unmatched_files: true - name: ${{ steps.vars.outputs.release_name }} - body_path: release-notes.md + # A single platform build only has its own artifacts + fail_on_unmatched_files: ${{ inputs.platform == 'all' }} + # A single platform build only replaces that platform's assets (same file names are overwritten), + # so leave the existing release's name and notes alone + name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }} + body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }} draft: ${{ steps.vars.outputs.is_draft }} prerelease: ${{ steps.vars.outputs.is_prerelease }} files: | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccc34f1f..020d99eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,16 +14,12 @@ # along with this program. If not, see . [versions] -agp = "9.1.0" android-compileSdk = "37" android-minSdk = "28" -android-targetSdk = "36" +android-targetSdk = "37" +agp = "9.1.0" androidx-activity = "1.13.0" -androidx-appcompat = "1.7.1" -androidx-core = "1.19.0" -androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" -androidx-testExt = "1.3.0" appdirs = "1.5.0" cache4k = "0.14.0" carApp = "1.7.0" @@ -34,12 +30,10 @@ composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" haze = "2.0.0-alpha03" jna = "5.18.1" -junit = "4.13.2" kermit = "2.1.0" kmpZip = "1.0.0" kotlin = "2.4.21" kotlinx-coroutines = "1.11.0" -kotlinx-io = "0.9.1" kotlinx-serialization-json = "1.11.0" millennium = "0.1.1" mqttClient = "2.1.1" @@ -55,27 +49,22 @@ vlcjNative = "4.12.0" ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" -runner = "1.7.0" -core = "1.7.0" filekit = "0.14.2" -compose-webview = "0.1.0-SNAPSHOT" +compose-webview = "1.0.3" composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" -compose-multiplatform-adaptive = "1.3.0-alpha05" compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" -ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" vlcjBundler = "0.2.1" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" -kotlinx-atomicfu = "0.33.0" mokkery = "3.5.1" jmustache = "1.16" @@ -98,12 +87,6 @@ kmp-zip = { module = "no.synth:kmp-zip", version.ref = "kmpZip" } kmp-zip-kotlinx = { module = "no.synth:kmp-zip-kotlinx", version.ref = "kmpZip" } kmp-zip-okio = { module = "no.synth:kmp-zip-okio", version.ref = "kmpZip" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { module = "junit:junit", version.ref = "junit" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } -androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } -androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } -androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } @@ -153,8 +136,6 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } -androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } -androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } @@ -170,7 +151,6 @@ feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.re kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } -kotlinx-io-okio = { module = "org.jetbrains.kotlinx:kotlinx-io-okio", version.ref = "kotlinx-io" } cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } @@ -189,10 +169,8 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } -mavenPublish = { id = "maven-publish" } spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" } vlcjBundler = { id = "org.spotube.vlcj-bundler", version.ref = "vlcjBundler" } -kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } From 8d4ea82a7eab6eef9a31358e6ab783845efb9319 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 15:01:43 +0600 Subject: [PATCH 6/7] fix(build): add headless mode for Linux and update CI to prepare dependencies --- .github/workflows/spotube-release-binary.yml | 100 +++++++++++++++++-- composeApp/build.gradle.kts | 6 ++ gradle/libs.versions.toml | 2 +- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index cc69ab89..d666c54d 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -45,6 +45,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }} cancel-in-progress: false +env: + COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview + # fix/linux-wayland-input + COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a + jobs: validate-inputs: name: Validate Inputs @@ -56,15 +61,61 @@ jobs: echo "::error::Building a single platform is only supported for nightly releases" exit 1 + prepare-deps: + name: Prepare Dependencies + needs: validate-inputs + runs-on: macos-latest + steps: + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v7 + with: + repository: ${{ env.COMPOSE_WEBVIEW_REPO }} + ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} + token: ${{ secrets.GITHUB_TOKEN }} + path: compose-webview + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Publish compose-webview to mavenLocal + working-directory: compose-webview + run: | + chmod +x gradlew + ./gradlew publishToMavenLocal + + - name: Upload maven local + uses: actions/upload-artifact@v7 + with: + name: maven-local + path: ~/.m2/repository/ + build-android: name: Android - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }} timeout-minutes: 120 runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -127,7 +178,7 @@ jobs: build-linux: name: Linux Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }} timeout-minutes: 120 runs-on: ubuntu-latest @@ -142,6 +193,12 @@ jobs: sudo apt-get update sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -235,7 +292,7 @@ jobs: build-linux-arm64: name: Linux Desktop (aarch64) - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }} timeout-minutes: 120 runs-on: ubuntu-24.04-arm @@ -257,6 +314,12 @@ jobs: sudo apt-get update sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -350,13 +413,19 @@ jobs: build-windows: name: Windows Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }} timeout-minutes: 120 runs-on: windows-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -408,13 +477,19 @@ jobs: build-macos: name: macOS Desktop - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -471,13 +546,19 @@ jobs: build-ios: name: iOS - needs: validate-inputs + needs: [validate-inputs, prepare-deps] if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }} timeout-minutes: 120 runs-on: macos-latest steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -539,6 +620,7 @@ jobs: if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }} needs: - validate-inputs + - prepare-deps - build-android - build-linux - build-linux-arm64 @@ -551,6 +633,12 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Download maven local + uses: actions/download-artifact@v8 + with: + name: maven-local + path: ~/.m2/repository/ + - name: Set up JDK 21 uses: actions/setup-java@v6 with: diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 99794847..f60ba709 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -646,6 +646,12 @@ tasks.withType().configureEach { jvmArgs( "--enable-native-access=ALL-UNNAMED" ) + // Linux: the hot-reload agent starts AWT before main(), and JBR's X11 toolkit pins GTK to + // XWayland, where the embedded WebKit WebView renders black. Tao owns the windows, so the + // reloadable JVM stays headless (DevTools runs in its own JVM and is unaffected). + if (System.getProperty("os.name").lowercase().contains("linux")) { + jvmArgs("-Djava.awt.headless=true") + } systemProperty( "compose.application.resources.dir", project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 020d99eb..d8ec04eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ ziplineVersion = "1.28.0" ktor = "3.5.1" dnssdkt = "1.1.0" filekit = "0.14.2" -compose-webview = "1.0.3" +compose-webview = "0.1.0-SNAPSHOT" composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" From fa9b42cf10d329981ba5ca89762e73a9398db8ef Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 29 Oct 2026 16:03:55 +0600 Subject: [PATCH 7/7] cd(build): add support for building WebView native libraries on Linux and Windows --- .github/workflows/spotube-release-binary.yml | 87 +++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index d666c54d..418db54e 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -61,9 +61,75 @@ jobs: echo "::error::Building a single platform is only supported for nightly releases" exit 1 + # ComposeNativeWebview's JNI backends are gitignored and only built for the host OS, so a + # publish from macOS alone ships a jar without the Linux / Windows WebView libraries (the + # WebView then renders nothing). Build them on their own OS and hand them to prepare-deps. + build-webview-natives: + name: WebView Natives (${{ matrix.name }}) + needs: validate-inputs + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - name: linux-x64 + os: ubuntu-latest + - name: linux-aarch64 + os: ubuntu-24.04-arm + - name: windows-x64 + os: windows-latest + steps: + - name: Checkout ComposeNativeWebview + uses: actions/checkout@v7 + with: + repository: ${{ env.COMPOSE_WEBVIEW_REPO }} + ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }} + token: ${{ secrets.GITHUB_TOKEN }} + path: compose-webview + + - name: Set up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Build Linux WebView native library + if: runner.os == 'Linux' + working-directory: compose-webview + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libgtk-3-dev libcairo2-dev \ + libsoup-3.0-dev libwebkit2gtk-4.1-dev + bash webview-compose/src/jvmMain/native/linux/build.sh + + - name: Build Windows WebView native library + if: runner.os == 'Windows' + working-directory: compose-webview + shell: cmd + run: webview-compose\src\jvmMain\native\windows\build.bat + + - name: Verify WebView native library + working-directory: compose-webview + shell: bash + run: | + case "${{ matrix.name }}" in + linux-*) files="${{ matrix.name }}/libcompose_webview_linux.so" ;; + windows-x64) files="win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll" ;; + esac + for f in $files; do + test -f "webview-compose/src/jvmMain/resources/nucleus/native/$f" || { echo "::error::Missing $f"; exit 1; } + done + + - name: Upload WebView native library + uses: actions/upload-artifact@v7 + with: + name: webview-natives-${{ matrix.name }} + path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native/ + retention-days: 1 + prepare-deps: name: Prepare Dependencies - needs: validate-inputs + needs: [validate-inputs, build-webview-natives] runs-on: macos-latest steps: - name: Checkout ComposeNativeWebview @@ -74,6 +140,15 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} path: compose-webview + # Downloaded after checkout, so they are newer than the native sources and + # the build keeps them instead of trying to rebuild on the wrong OS. + - name: Download WebView native libraries + uses: actions/download-artifact@v8 + with: + pattern: webview-natives-* + merge-multiple: true + path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native + - name: Set up JDK 21 uses: actions/setup-java@v6 with: @@ -95,6 +170,16 @@ jobs: chmod +x gradlew ./gradlew publishToMavenLocal + - name: Verify published JVM jar carries every WebView backend + run: | + jar=$(ls ~/.m2/repository/dev/nucleusframework/composewebview-jvm/*/composewebview-jvm-*.jar | grep -v -e sources -e javadoc | head -1) + contents=$(unzip -l "$jar") + for f in linux-x64/libcompose_webview_linux.so linux-aarch64/libcompose_webview_linux.so \ + win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll \ + darwin-aarch64/libcompose_webview_macos.dylib darwin-x64/libcompose_webview_macos.dylib; do + grep -q "nucleus/native/$f" <<< "$contents" || { echo "::error::$jar is missing $f"; exit 1; } + done + - name: Upload maven local uses: actions/upload-artifact@v7 with: