mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
9 Commits
5a8f54d1bb
...
cc86014718
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc86014718 | ||
|
|
07ad7124a9 | ||
|
|
fa9b42cf10 | ||
|
|
8d4ea82a7e | ||
|
|
eb972c8633 | ||
|
|
aca3aeb592 | ||
|
|
5b36edd259 | ||
|
|
ea01dec6ea | ||
|
|
79266fd483 |
168
.github/workflows/spotube-release-binary.yml
vendored
168
.github/workflows/spotube-release-binary.yml
vendored
@ -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: |
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) +
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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")
|
||||||
|
val initApp = {
|
||||||
initKoin()
|
initKoin()
|
||||||
NewPipeDownloader.init(KoinPathsProvider.paths)
|
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) {
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user