Compare commits

..

8 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
07ad7124a9
Merge pull request #3173 from team-spotube/dev
Dev
2026-10-29 17:36:52 +06:00
Kingkor Roy Tirtho
fa9b42cf10 cd(build): add support for building WebView native libraries on Linux and Windows 2026-10-29 16:03:55 +06:00
Kingkor Roy Tirtho
8d4ea82a7e fix(build): add headless mode for Linux and update CI to prepare dependencies 2026-10-29 15:01:43 +06:00
Kingkor Roy Tirtho
eb972c8633 fix(dependencies): update Android target SDK and various library versions 2026-10-29 11:25:22 +06:00
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
17 changed files with 391 additions and 82 deletions

View File

@ -12,6 +12,19 @@ on:
- stable - stable
- beta - beta
- nightly - 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: dry_run:
description: 'Dry run (build & upload artifacts only, skip creating release)' description: 'Dry run (build & upload artifacts only, skip creating release)'
required: false required: false
@ -29,16 +42,94 @@ permissions:
# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side # Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }}
cancel-in-progress: false cancel-in-progress: false
env: env:
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d # fix/linux-wayland-input
COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a
jobs: jobs:
validate-inputs:
name: Validate Inputs
runs-on: ubuntu-latest
steps:
- name: Single platform builds are nightly only
if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }}
run: |
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: prepare-deps:
name: Prepare Dependencies name: Prepare Dependencies
needs: [validate-inputs, build-webview-natives]
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout ComposeNativeWebview - name: Checkout ComposeNativeWebview
@ -49,6 +140,15 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview 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 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
with: with:
@ -70,6 +170,16 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew publishToMavenLocal ./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 - name: Upload maven local
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@ -78,7 +188,8 @@ jobs:
build-android: build-android:
name: Android name: Android
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -152,7 +263,8 @@ jobs:
build-linux: build-linux:
name: Linux Desktop name: Linux Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -161,17 +273,17 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: maven-local name: maven-local
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
with: with:
@ -265,7 +377,8 @@ jobs:
build-linux-arm64: build-linux-arm64:
name: Linux Desktop (aarch64) name: Linux Desktop (aarch64)
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
steps: steps:
@ -281,17 +394,17 @@ jobs:
with: with:
packages: tools platform-tools ndk;28.2.13676358 packages: tools platform-tools ndk;28.2.13676358
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: maven-local name: maven-local
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
with: with:
@ -385,7 +498,8 @@ jobs:
build-windows: build-windows:
name: Windows Desktop name: Windows Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: windows-latest runs-on: windows-latest
steps: steps:
@ -448,7 +562,8 @@ jobs:
build-macos: build-macos:
name: macOS Desktop name: macOS Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: macos-latest runs-on: macos-latest
steps: steps:
@ -516,7 +631,8 @@ jobs:
build-ios: build-ios:
name: iOS name: iOS
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: macos-latest runs-on: macos-latest
steps: steps:
@ -585,8 +701,11 @@ jobs:
create-release: create-release:
name: Create GitHub 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: needs:
- validate-inputs
- prepare-deps
- build-android - build-android
- build-linux - build-linux
- build-linux-arm64 - build-linux-arm64
@ -599,7 +718,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - 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 - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
@ -639,6 +757,7 @@ jobs:
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
- name: Generate release notes - name: Generate release notes
if: ${{ inputs.platform == 'all' }}
run: | run: |
echo "## Downloads" > release-notes.md echo "## Downloads" > release-notes.md
echo "" >> release-notes.md echo "" >> release-notes.md
@ -658,9 +777,12 @@ jobs:
tag_name: ${{ steps.vars.outputs.tag }} tag_name: ${{ steps.vars.outputs.tag }}
# A new tag (stable) would otherwise point at the default branch instead of the built commit # A new tag (stable) would otherwise point at the default branch instead of the built commit
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
fail_on_unmatched_files: true # A single platform build only has its own artifacts
name: ${{ steps.vars.outputs.release_name }} fail_on_unmatched_files: ${{ inputs.platform == 'all' }}
body_path: release-notes.md # 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 }} draft: ${{ steps.vars.outputs.is_draft }}
prerelease: ${{ steps.vars.outputs.is_prerelease }} prerelease: ${{ steps.vars.outputs.is_prerelease }}
files: | files: |

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)
} }
} }
} }
@ -645,6 +646,12 @@ tasks.withType<ComposeHotRun>().configureEach {
jvmArgs( jvmArgs(
"--enable-native-access=ALL-UNNAMED" "--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( systemProperty(
"compose.application.resources.dir", "compose.application.resources.dir",
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()

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

@ -14,16 +14,12 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
[versions] [versions]
agp = "9.1.0"
android-compileSdk = "37" android-compileSdk = "37"
android-minSdk = "28" android-minSdk = "28"
android-targetSdk = "36" android-targetSdk = "37"
agp = "9.1.0"
androidx-activity = "1.13.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-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
appdirs = "1.5.0" appdirs = "1.5.0"
cache4k = "0.14.0" cache4k = "0.14.0"
carApp = "1.7.0" carApp = "1.7.0"
@ -34,12 +30,10 @@ composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
haze = "2.0.0-alpha03" haze = "2.0.0-alpha03"
jna = "5.18.1" jna = "5.18.1"
junit = "4.13.2"
kermit = "2.1.0" kermit = "2.1.0"
kmpZip = "1.0.0" kmpZip = "1.0.0"
kotlin = "2.4.21" kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
millennium = "0.1.1" millennium = "0.1.1"
mqttClient = "2.1.1" mqttClient = "2.1.1"
@ -55,27 +49,22 @@ vlcjNative = "4.12.0"
ziplineVersion = "1.28.0" ziplineVersion = "1.28.0"
ktor = "3.5.1" ktor = "3.5.1"
dnssdkt = "1.1.0" dnssdkt = "1.1.0"
runner = "1.7.0"
core = "1.7.0"
filekit = "0.14.2" filekit = "0.14.2"
compose-webview = "0.1.0-SNAPSHOT" compose-webview = "0.1.0-SNAPSHOT"
composeNativeTray = "2.1.6" composeNativeTray = "2.1.6"
koin = "4.2.2" koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05"
compose-multiplatform-lifecycle = "2.10.0" compose-multiplatform-lifecycle = "2.10.0"
feather-icons = "1.1.1" feather-icons = "1.1.1"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
media3 = "1.10.1" media3 = "1.10.1"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
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"
mokkery = "3.5.1" mokkery = "3.5.1"
jmustache = "1.16" 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-kotlinx = { module = "no.synth:kmp-zip-kotlinx", version.ref = "kmpZip" }
kmp-zip-okio = { module = "no.synth:kmp-zip-okio", 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-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" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } 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" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@ -134,6 +117,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" }
@ -152,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-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" } 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" } 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-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 = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
@ -169,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" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", 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-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" }
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", 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" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
@ -188,10 +169,8 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" }
spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" } spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" }
vlcjBundler = { id = "org.spotube.vlcj-bundler", version.ref = "vlcjBundler" } 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" } uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" }