mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Merge 81a6ad7f8c into 07ad7124a9
This commit is contained in:
commit
2832d7829d
118
.github/workflows/build-android-apk.yml
vendored
Normal file
118
.github/workflows/build-android-apk.yml
vendored
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag (e.g. v6.0.0-custom or nightly)'
|
||||||
|
required: false
|
||||||
|
default: 'nightly'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
name: Build Spotube Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- name: Checkout spotube
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Checkout ComposeNativeWebview
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: KRTirtho/ComposeNativeWebview
|
||||||
|
ref: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
|
||||||
|
path: compose-webview
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev
|
||||||
|
|
||||||
|
- name: Publish compose-webview to mavenLocal
|
||||||
|
working-directory: compose-webview
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
mkdir -p src/jvmMain/resources/nucleus/native/linux-x64
|
||||||
|
touch src/jvmMain/resources/nucleus/native/linux-x64/libcompose_webview_linux.so
|
||||||
|
./gradlew publishToMavenLocal -x :webview-compose:buildNativeLinux -x :webview-compose:verifyPublicationCoordinates --no-daemon
|
||||||
|
|
||||||
|
- name: Configure Android signing
|
||||||
|
working-directory: composeApp
|
||||||
|
env:
|
||||||
|
KEYSTORE: ${{ secrets.KEYSTORE }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||||
|
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
chmod +x ../gradlew
|
||||||
|
if [ -n "$KEYSTORE" ]; then
|
||||||
|
echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks"
|
||||||
|
echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties
|
||||||
|
echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties
|
||||||
|
echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties
|
||||||
|
echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties
|
||||||
|
else
|
||||||
|
echo "No custom keystore secret found, generating build keystore..."
|
||||||
|
keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US"
|
||||||
|
echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties
|
||||||
|
echo "release.signing.storePassword=spotubepass" >> local.properties
|
||||||
|
echo "release.signing.keyAlias=spotube" >> local.properties
|
||||||
|
echo "release.signing.keyPassword=spotubepass" >> local.properties
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Assemble Android Release APK
|
||||||
|
run: |
|
||||||
|
./gradlew :composeApp:assembleNightlyRelease -Pnightly=true -PbuildNumber=${{ github.run_number }} --stacktrace
|
||||||
|
|
||||||
|
- name: Locate and Rename APK
|
||||||
|
run: |
|
||||||
|
APK_FILE=$(find composeApp/build/outputs/apk -name "*.apk" -type f | grep -i "release" | head -1)
|
||||||
|
if [ -z "$APK_FILE" ]; then
|
||||||
|
APK_FILE=$(find composeApp/build/outputs/apk -name "*.apk" -type f | head -1)
|
||||||
|
fi
|
||||||
|
echo "Found APK at: $APK_FILE"
|
||||||
|
cp "$APK_FILE" Spotube-android-all-arch.apk
|
||||||
|
ls -lh Spotube-android-all-arch.apk
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: Spotube-android-all-arch
|
||||||
|
path: Spotube-android-all-arch.apk
|
||||||
|
|
||||||
|
- name: Create or Update GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ inputs.tag || 'nightly' }}
|
||||||
|
name: "Spotube Android Build (${{ inputs.tag || 'nightly' }})"
|
||||||
|
body: |
|
||||||
|
### Spotube Custom Android Build
|
||||||
|
Compiled automatically with custom playback & proxy fixes:
|
||||||
|
- **Google Video 403 Forbidden Fix:** Upstream headers with `Referer: https://www.youtube.com/`, `Origin`, and browser `User-Agent`.
|
||||||
|
- **YouTube Video ID Extraction:** Fixed parsing from `externalUri` instead of passing Spotify ID.
|
||||||
|
- **Relaxed Matching:** Top relevance fallback to prevent `TrackNotFoundError`.
|
||||||
|
- **Native YouTube Endless Playback:** Radio mix via `list=RD{videoId}`.
|
||||||
|
files: Spotube-android-all-arch.apk
|
||||||
|
prerelease: true
|
||||||
|
draft: false
|
||||||
67
.github/workflows/publish-release-artifacts.yml
vendored
Normal file
67
.github/workflows/publish-release-artifacts.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: Publish Existing Artifacts to Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
run_id:
|
||||||
|
description: 'Workflow Run ID with built artifacts'
|
||||||
|
required: true
|
||||||
|
default: '40649093060'
|
||||||
|
type: string
|
||||||
|
release_tag:
|
||||||
|
description: 'Release tag (e.g. nightly or v6.0.0)'
|
||||||
|
required: true
|
||||||
|
default: 'nightly'
|
||||||
|
type: string
|
||||||
|
release_name:
|
||||||
|
description: 'Release name'
|
||||||
|
required: false
|
||||||
|
default: 'Spotube Multiplatform Build (nightly)'
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Mark as pre-release'
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-artifacts:
|
||||||
|
name: Publish Artifacts to GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts from run
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
run-id: ${{ inputs.run_id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
path: downloaded-artifacts
|
||||||
|
|
||||||
|
- name: Inspect and organize artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find downloaded-artifacts -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) -exec cp {} release-files/ \;
|
||||||
|
ls -lh release-files/
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
echo "## Downloads" > release-notes.md
|
||||||
|
echo "" >> release-notes.md
|
||||||
|
for f in release-files/*; do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
echo "- \`$(basename "$f")\`" >> release-notes.md
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ inputs.release_tag }}
|
||||||
|
name: ${{ inputs.release_name }}
|
||||||
|
body_path: release-notes.md
|
||||||
|
prerelease: ${{ inputs.prerelease }}
|
||||||
|
draft: false
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
files: release-files/*
|
||||||
58
.github/workflows/spotube-release-binary.yml
vendored
58
.github/workflows/spotube-release-binary.yml
vendored
@ -13,7 +13,7 @@ on:
|
|||||||
- beta
|
- beta
|
||||||
- nightly
|
- nightly
|
||||||
platform:
|
platform:
|
||||||
description: 'Platform to build (nightly only; stable and beta always build everything)'
|
description: 'Platform to build'
|
||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
default: 'all'
|
default: 'all'
|
||||||
@ -36,9 +36,8 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
# Read-only by default; only create-release gets write access
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
# 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:
|
||||||
@ -216,25 +215,28 @@ jobs:
|
|||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Decode keystore
|
|
||||||
env:
|
|
||||||
KEYSTORE: ${{ secrets.KEYSTORE }}
|
|
||||||
run: |
|
|
||||||
echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks"
|
|
||||||
|
|
||||||
- name: Configure Android signing
|
- name: Configure Android signing
|
||||||
working-directory: composeApp
|
working-directory: composeApp
|
||||||
env:
|
env:
|
||||||
|
KEYSTORE: ${{ secrets.KEYSTORE }}
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
|
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||||
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
cat >> local.properties <<EOF
|
if [ -n "$KEYSTORE" ]; then
|
||||||
release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks
|
echo "$KEYSTORE" | base64 --decode > "${{ github.workspace }}/composeApp/upload-keystore.jks"
|
||||||
release.signing.storePassword=${KEYSTORE_PASSWORD}
|
echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties
|
||||||
release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}
|
echo "release.signing.storePassword=${KEYSTORE_PASSWORD}" >> local.properties
|
||||||
release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}
|
echo "release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}" >> local.properties
|
||||||
EOF
|
echo "release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}" >> local.properties
|
||||||
|
else
|
||||||
|
echo "No custom keystore secret found, generating build keystore..."
|
||||||
|
keytool -genkey -v -keystore "${{ github.workspace }}/composeApp/upload-keystore.jks" -alias spotube -keyalg RSA -keysize 2048 -validity 10000 -storepass spotubepass -keypass spotubepass -dname "CN=Spotube, OU=Mobile, O=Spotube, C=US"
|
||||||
|
echo "release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks" >> local.properties
|
||||||
|
echo "release.signing.storePassword=spotubepass" >> local.properties
|
||||||
|
echo "release.signing.keyAlias=spotube" >> local.properties
|
||||||
|
echo "release.signing.keyPassword=spotubepass" >> local.properties
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Assemble Release
|
- name: Assemble Release
|
||||||
run: |
|
run: |
|
||||||
@ -754,10 +756,23 @@ jobs:
|
|||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
|
|
||||||
|
- name: Verify build artifacts exist
|
||||||
|
id: check_artifacts
|
||||||
|
run: |
|
||||||
|
COUNT=$(find . -maxdepth 2 -type f \( -name "*.apk" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.tar.xz" -o -name "*.exe" -o -name "*.dmg" -o -name "*.ipa" \) 2>/dev/null | wc -l)
|
||||||
|
echo "Found $COUNT build artifact(s)."
|
||||||
|
if [ "$COUNT" -eq 0 ]; then
|
||||||
|
echo "has_artifacts=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No build artifacts were found. Skipping release publish step."
|
||||||
|
else
|
||||||
|
echo "has_artifacts=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
if: ${{ inputs.platform == 'all' }}
|
if: steps.check_artifacts.outputs.has_artifacts == 'true' && inputs.platform == 'all'
|
||||||
run: |
|
run: |
|
||||||
echo "## Downloads" > release-notes.md
|
echo "## Downloads" > release-notes.md
|
||||||
echo "" >> release-notes.md
|
echo "" >> release-notes.md
|
||||||
@ -765,23 +780,24 @@ jobs:
|
|||||||
if [ -d "$dir" ]; then
|
if [ -d "$dir" ]; then
|
||||||
echo "### $dir" >> release-notes.md
|
echo "### $dir" >> release-notes.md
|
||||||
for f in "$dir"/*; do
|
for f in "$dir"/*; do
|
||||||
echo "- \`$(basename "$f")\`" >> release-notes.md
|
[ -f "$f" ] && echo "- \`$(basename "$f")\`" >> release-notes.md
|
||||||
done
|
done
|
||||||
echo "" >> release-notes.md
|
echo "" >> release-notes.md
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create or Update Release
|
||||||
uses: softprops/action-gh-release@v3
|
if: steps.check_artifacts.outputs.has_artifacts == 'true'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
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 }}
|
||||||
# A single platform build only has its own artifacts
|
# A single platform build only has its own artifacts
|
||||||
fail_on_unmatched_files: ${{ inputs.platform == 'all' }}
|
fail_on_unmatched_files: false
|
||||||
# A single platform build only replaces that platform's assets (same file names are overwritten),
|
# 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
|
# so leave the existing release's name and notes alone
|
||||||
name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }}
|
name: ${{ (inputs.platform == 'all' || steps.vars.outputs.release_name != '') && steps.vars.outputs.release_name || '' }}
|
||||||
body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }}
|
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 }}
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **audioplayer/ios**: Implement `IosMediaSession` with `AVAudioSession`, `MPNowPlayingInfoCenter`, and `MPRemoteCommandCenter` for lock-screen / Control Center media controls and background audio playback (PR #3170).
|
||||||
|
- **audioplayer/ios**: Enable direct streaming resolution for iOS playback in `DeviceAudioPlayerQueue.toMediaItem` (PR #3170).
|
||||||
|
- **audioplayer/DeviceAudioPlayerQueue**: Implement complete 4-tier endless playback recommendation cascade: metadata recommendations, native YouTube radio mix, album tracks, and artist top tracks (PR #3092, PR #3134).
|
||||||
|
- **plugin/PluginManager**: Auto-select vacant ability slots on plugin installation and recover fallback providers on plugin removal (PR #3147).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- **build/android**: Switch `desugar_jdk_libs` to `com.android.tools:desugar_jdk_libs_nio` to resolve Android 9-12 `NoSuchMethodError: java.net.URLEncoder.encode` (PR #3132).
|
||||||
|
- **server/StreamProxy**: Harden YouTube/GoogleVideo proxy streaming against HTTP 403 Forbidden by inspecting client parameter `c` (`ANDROID`, `IOS`, `VISIONOS`), injecting matching mobile User-Agents, and stripping both `Origin` and `Referer`/`Referrer` headers on mobile streams (PR #3150).
|
||||||
|
- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents, stripping `Origin` and `Referer`/`Referrer` headers on mobile streams, and failing early on non-success HTTP probe status to prevent corrupt HTML error pages from saving into the media library (PR #3150).
|
||||||
|
- **audioplayer/DeviceAudioPlayerQueue**: Pair `QueueEntry` with resolved `MediaItem` in queue mutations to prevent quadratic network re-resolutions and null pointer crashes; read queue state via `getQueue()` to ensure endless playback succeeds when backgrounded; deduplicate radio mix seed tracks to properly cascade to album and artist top tracks (PR #3092, PR #3134).
|
||||||
|
- **audioplayer/ios**: Update lock screen and Control Center playback rates immediately in `pause()` and `syncTimeControlStatus()` to fix stuck playing indicators; register `togglePlayPauseCommand` handler for Bluetooth headphone and AirPods single-tap toggle controls (PR #3170).
|
||||||
|
- **plugin/PluginManager**: Increment generation counter in `removePlugin` to trigger clean reactive flow restarts and reliable fallback activation upon plugin uninstallation (PR #3147).
|
||||||
|
- **server/StreamingUrlRepository**: Evict stale cached track sources from `MatchedTracksRepository` when cached source resolution fails before falling back to fresh track lookups (PR #3092, PR #3134).
|
||||||
|
- **newpipe/RealNewPipeAudioAPI**: Fix YouTube video ID extraction when `track.externalUri` contains YouTube URLs (`v=`, `youtu.be`, `/embed/`, `/shorts/`, with trailing slash/query/fragment support) instead of passing raw Spotify track IDs to video info lookups.
|
||||||
|
- **newpipe/RealNewPipeAudioAPI**: Improve track matching and scoring for Topic channels, VEVO uploads, and official variants with relaxed duration bins, and guarantee top relevance search result is prioritized at index 0 when confidence scoring yields no candidates $\ge 0.3$.
|
||||||
|
- **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms with safe thumbnail fallbacks.
|
||||||
|
|
||||||
## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26)
|
## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
12
README.md
12
README.md
@ -43,6 +43,18 @@ Download Spotube from the Official Website: https://spotube.cc
|
|||||||
|
|
||||||
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly).
|
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly).
|
||||||
|
|
||||||
|
### 🧩 Plugins
|
||||||
|
|
||||||
|
Spotube supports metadata and source plugins compiled as `.smplug` files.
|
||||||
|
|
||||||
|
You can install plugins directly in Spotube:
|
||||||
|
1. Navigate to **Settings** > **Plugins** > **Install a Plugin**.
|
||||||
|
2. Under **Download from URL**, paste the direct plugin URL, for example:
|
||||||
|
- **Spotify Plugin (Latest)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/latest/download/plugin.smplug`
|
||||||
|
- **Spotify Plugin (v1.0.0)**: `https://github.com/pgwiz/spotube-plugin-spotify/releases/download/1.0.0/plugin.smplug`
|
||||||
|
- **Legacy Plugin Reference**: `https://github.com/sonic-liberation/spotube-plugin-spotify/releases/download/0.2.2/plugin.smplug` *(Format: `https://github.com/$LEGACY_REPO/releases/download/$LEGACY_TAG/plugin.smplug`)*
|
||||||
|
3. Click **Download**, review permissions, and confirm.
|
||||||
|
|
||||||
## 🕳️ Building from source
|
## 🕳️ Building from source
|
||||||
|
|
||||||
<a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
|
<a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
|
||||||
|
|||||||
@ -155,6 +155,47 @@ actual class NewPipeService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun getRadioMix(videoId: String): List<VideoSearchResult> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val urls = listOf(
|
||||||
|
"https://www.youtube.com/watch?v=$videoId&list=RD$videoId",
|
||||||
|
"https://www.youtube.com/playlist?list=RD$videoId"
|
||||||
|
)
|
||||||
|
for (url in urls) {
|
||||||
|
val items = runCatching {
|
||||||
|
val extractor = YouTube.getPlaylistExtractor(url)
|
||||||
|
extractor.fetchPage()
|
||||||
|
val page = extractor.initialPage
|
||||||
|
page.items.mapNotNull { item ->
|
||||||
|
if (item is StreamInfoItem) {
|
||||||
|
val vId = runCatching { Url(item.url).parameters["v"] }.getOrNull()
|
||||||
|
?: item.url.substringAfter("watch?v=", "").substringBefore("&")
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?: item.url.substringAfterLast("/")
|
||||||
|
VideoSearchResult(
|
||||||
|
id = vId,
|
||||||
|
title = item.name,
|
||||||
|
url = item.url,
|
||||||
|
uploader = item.uploaderName,
|
||||||
|
durationMs = secondsToMs(item.duration),
|
||||||
|
thumbnailUrl = item.thumbnails.firstOrNull()?.url.orEmpty()
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
logger.w(it) { "Failed to extract YouTube radio mix from $url" }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (!items.isNullOrEmpty()) {
|
||||||
|
logger.i { "Extracted ${items.size} tracks from YouTube radio mix for video $videoId" }
|
||||||
|
return@withContext items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.w { "Unable to fetch radio mix for video $videoId" }
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
actual val context: Any = context
|
actual val context: Any = context
|
||||||
|
|
||||||
private val appContext: Context = (context as Context).applicationContext
|
private val appContext: Context = (context as Context).applicationContext
|
||||||
|
private val kermitLogger = Logger.withTag("AudioPlayer")
|
||||||
|
|
||||||
private fun ensureServiceStarted() {
|
private fun ensureServiceStarted() {
|
||||||
// A plain start, never startForegroundService: that obliges the service to call
|
// A plain start, never startForegroundService: that obliges the service to call
|
||||||
@ -127,6 +129,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
kermitLogger.e(error) { "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}" }
|
||||||
|
Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error)
|
||||||
_error.tryEmit(error)
|
_error.tryEmit(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlayl
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.server.LocalServer
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import org.spotube.spotube.core.playback.CollectionPlaybackHelper
|
import org.spotube.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import org.spotube.spotube.modules.album.AlbumRepository
|
import org.spotube.spotube.modules.album.AlbumRepository
|
||||||
@ -662,9 +664,12 @@ class MediaBrowseHelper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildStreamingUrl(trackId: String): String? {
|
private suspend fun buildStreamingUrl(trackId: String): String? {
|
||||||
val settings = settingsRepository.userSettings.first()
|
val baseUrl = localServer?.awaitBaseUrl() ?: run {
|
||||||
val port = settings.playbackProxyServerPort
|
val settings = settingsRepository.userSettings.first()
|
||||||
if (port == 0) return null
|
val port = settings.playbackProxyServerPort
|
||||||
return "http://127.0.0.1:$port/stream/$trackId"
|
if (port == 0) return null
|
||||||
|
"http://127.0.0.1:$port"
|
||||||
|
}
|
||||||
|
return "${baseUrl.trimEnd('/')}/stream/$trackId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,6 +112,17 @@
|
|||||||
<string name="settings_section_updates">Updates</string>
|
<string name="settings_section_updates">Updates</string>
|
||||||
<string name="settings_section_support">Support</string>
|
<string name="settings_section_support">Support</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
|
<string name="settings_section_logs">Logs and Diagnostics</string>
|
||||||
|
<string name="settings_logs_title">Logs and Diagnostics</string>
|
||||||
|
<string name="settings_logs_subtitle">Inspect player, proxy server, and error logs</string>
|
||||||
|
<string name="settings_logs_copy">Copy Logs</string>
|
||||||
|
<string name="settings_logs_clear">Clear Logs</string>
|
||||||
|
<string name="settings_logs_copied">Copied to clipboard!</string>
|
||||||
|
<string name="settings_logs_empty">No logs captured yet.</string>
|
||||||
|
<string name="settings_logs_filter_all">All</string>
|
||||||
|
<string name="settings_logs_filter_errors">Errors</string>
|
||||||
|
<string name="settings_logs_filter_warnings">Warnings</string>
|
||||||
|
<string name="settings_logs_search_hint">Filter by tag or keyword…</string>
|
||||||
|
|
||||||
<string name="common_close">Close</string>
|
<string name="common_close">Close</string>
|
||||||
<string name="common_save">Save</string>
|
<string name="common_save">Save</string>
|
||||||
|
|||||||
@ -18,7 +18,13 @@
|
|||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
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.artist.MetadataArtist
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.spotube.core.di.injectLogger
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
|
import org.spotube.spotube.core.newpipe.NewPipeService
|
||||||
|
import org.spotube.spotube.core.newpipe.SearchKind
|
||||||
|
import org.spotube.spotube.core.server.LocalServer
|
||||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||||
@ -37,6 +43,10 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
|
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||||
|
import org.spotube.spotube.getPlatform
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@ -48,6 +58,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
private val streamingUrlRepository by inject<StreamingUrlRepository>()
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@ -93,6 +104,12 @@ class DeviceAudioPlayerQueue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
audioPlayer.errorFlow.collect { error ->
|
||||||
|
logger.e(error) { "AudioPlayer error encountered in queue: ${error.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
audioPlayer.currentMediaItemFlow.collect {
|
audioPlayer.currentMediaItemFlow.collect {
|
||||||
logger.d { "Current media item changed, persisting queue state" }
|
logger.d { "Current media item changed, persisting queue state" }
|
||||||
@ -127,14 +144,15 @@ class DeviceAudioPlayerQueue(
|
|||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
collectionEntry: QueueCollectionEntry?) {
|
collectionEntry: QueueCollectionEntry?) {
|
||||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
|
||||||
if (mediaItems.isEmpty()) {
|
if (paired.isEmpty()) {
|
||||||
logger.w { "load: no valid media items after filtering blanks" }
|
logger.w { "load: no valid media items after filtering blanks" }
|
||||||
}
|
}
|
||||||
|
val mediaItems = paired.map { it.first }
|
||||||
val normalizedStartPosition =
|
val normalizedStartPosition =
|
||||||
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
|
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
|
||||||
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
|
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
|
||||||
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
|
||||||
updateCollectionContext(collectionEntry)
|
updateCollectionContext(collectionEntry)
|
||||||
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
|
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
|
||||||
persistQueueStateIfReady()
|
persistQueueStateIfReady()
|
||||||
@ -160,16 +178,14 @@ class DeviceAudioPlayerQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.i { "Adding ${entries.size} entries to queue" }
|
logger.i { "Adding ${entries.size} entries to queue" }
|
||||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
|
||||||
if (mediaItems.isEmpty()) {
|
if (paired.isEmpty()) {
|
||||||
logger.w { "addAllToQueue: all entries had blank urls, skipping" }
|
logger.w { "addAllToQueue: all entries had blank urls, skipping" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val validEntries = entries.zip(mediaItems).map { (entry) ->
|
val mediaItems = paired.map { it.first }
|
||||||
entry
|
entryByMediaUrl.update { current ->
|
||||||
}
|
current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
|
||||||
entryByMediaUrl.update {
|
|
||||||
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCollectionContext(collectionEntry)
|
updateCollectionContext(collectionEntry)
|
||||||
@ -189,14 +205,14 @@ class DeviceAudioPlayerQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.i { "Adding ${entries.size} entries after current position" }
|
logger.i { "Adding ${entries.size} entries after current position" }
|
||||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
|
||||||
if (mediaItems.isEmpty()) {
|
if (paired.isEmpty()) {
|
||||||
logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" }
|
logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val validEntries = entries.zip(mediaItems).map { (entry) -> entry }
|
val mediaItems = paired.map { it.first }
|
||||||
entryByMediaUrl.update {
|
entryByMediaUrl.update { current ->
|
||||||
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry }
|
current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaItems.forEach { mediaItem ->
|
mediaItems.forEach { mediaItem ->
|
||||||
@ -208,7 +224,9 @@ class DeviceAudioPlayerQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeFromQueue(entry: QueueEntry) {
|
override suspend fun removeFromQueue(entry: QueueEntry) {
|
||||||
val mediaUrl = entry.toMediaItem()?.url ?: return
|
val mediaUrl = entryByMediaUrl.value.entries.firstOrNull { it.value == entry }?.key
|
||||||
|
?: entry.toMediaItem()?.url
|
||||||
|
?: return
|
||||||
removeFromQueueByMediaUrl(mediaUrl)
|
removeFromQueueByMediaUrl(mediaUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,7 +331,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
val settings = settingsProvider.settingsState.value ?: return
|
val settings = settingsProvider.settingsState.value ?: return
|
||||||
if (!settings.enableEndlessPlayback) return
|
if (!settings.enableEndlessPlayback) return
|
||||||
|
|
||||||
val queue = queueFlow.value
|
val queue = getQueue()
|
||||||
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
|
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
|
||||||
if (streamingTracks.isEmpty()) return
|
if (streamingTracks.isEmpty()) return
|
||||||
|
|
||||||
@ -330,19 +348,111 @@ class DeviceAudioPlayerQueue(
|
|||||||
isFetchingRecommendations = true
|
isFetchingRecommendations = true
|
||||||
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
||||||
|
|
||||||
val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run {
|
val metadataService = pluginProvider.selectedMetadataPlugin.value
|
||||||
logger.w { "Endless playback: no metadata plugin available" }
|
var recommendations: List<MetadataTrack> = emptyList()
|
||||||
isFetchingRecommendations = false
|
|
||||||
return
|
if (metadataService != null) {
|
||||||
|
recommendations = runCatching {
|
||||||
|
metadataService.use {
|
||||||
|
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.isEmpty()) {
|
||||||
|
logger.i { "Endless playback: metadata recommendations unavailable or empty, falling back to native YouTube radio mix" }
|
||||||
|
val newPipeService = NewPipeService()
|
||||||
|
for (seedTrack in seedTracks) {
|
||||||
|
val seed = seedTrack.track
|
||||||
|
val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) {
|
||||||
|
seed.id
|
||||||
|
} else {
|
||||||
|
seed.externalUri?.let { uri ->
|
||||||
|
Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""").find(uri)?.groupValues?.get(1)
|
||||||
|
?: Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1)
|
||||||
|
?: Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""").find(uri)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoId.isNullOrBlank()) {
|
||||||
|
val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList())
|
||||||
|
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
|
||||||
|
val newRadioTracks = radioMix.filter { it.id !in existingTrackIds && it.id != videoId }
|
||||||
|
if (newRadioTracks.isNotEmpty()) {
|
||||||
|
logger.i { "Endless playback: got ${newRadioTracks.size} new tracks from YouTube radio mix for video $videoId" }
|
||||||
|
recommendations = newRadioTracks.map { video ->
|
||||||
|
MetadataTrack(
|
||||||
|
id = video.id,
|
||||||
|
title = video.title,
|
||||||
|
durationMs = video.durationMs,
|
||||||
|
trackNumber = null,
|
||||||
|
discNumber = null,
|
||||||
|
artists = listOf(
|
||||||
|
MetadataArtist.Basic(
|
||||||
|
id = video.uploader,
|
||||||
|
name = video.uploader,
|
||||||
|
thumbnails = emptyList(),
|
||||||
|
externalUri = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
album = null,
|
||||||
|
thumbnails = if (video.thumbnailUrl.isNotBlank()) {
|
||||||
|
listOf(Thumbnail(url = video.thumbnailUrl, width = 300, height = 300))
|
||||||
|
} else null,
|
||||||
|
explicit = null,
|
||||||
|
popularity = null,
|
||||||
|
isrcCode = null,
|
||||||
|
externalUri = "https://www.youtube.com/watch?v=${video.id}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.isEmpty() && metadataService != null) {
|
||||||
|
val albumId = seedTracks.mapNotNull { it.track.album?.id }.firstOrNull { it.isNotBlank() }
|
||||||
|
if (!albumId.isNullOrBlank()) {
|
||||||
|
logger.i { "Endless playback: radio mix unavailable or empty, falling back to album tracks for album $albumId" }
|
||||||
|
val albumTracks = runCatching {
|
||||||
|
metadataService.use {
|
||||||
|
metadataAlbumAPI.getAlbumTracks(albumId).items
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
|
||||||
|
val newAlbumTracks = albumTracks.filter { it.id !in existingTrackIds }
|
||||||
|
if (newAlbumTracks.isNotEmpty()) {
|
||||||
|
logger.i { "Endless playback: got ${newAlbumTracks.size} remaining album tracks for album $albumId" }
|
||||||
|
recommendations = newAlbumTracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.isEmpty() && metadataService != null) {
|
||||||
|
logger.i { "Endless playback: album tracks unavailable or empty, falling back to artist top tracks" }
|
||||||
|
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
|
||||||
|
val artistIds = seedTracks.flatMap { it.track.artists }.map { it.id }.filter { it.isNotBlank() }.distinct()
|
||||||
|
for (artistId in artistIds) {
|
||||||
|
val topTracks = runCatching {
|
||||||
|
metadataService.use {
|
||||||
|
metadataArtistAPI.getArtistTop10Tracks(artistId)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val newTracks = topTracks.filter { it.id !in existingTrackIds }
|
||||||
|
if (newTracks.isNotEmpty()) {
|
||||||
|
logger.i { "Endless playback: got ${newTracks.size} top tracks for artist $artistId" }
|
||||||
|
recommendations = newTracks
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val recommendations = metadataService.use {
|
|
||||||
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recommendations.isEmpty()) {
|
if (recommendations.isEmpty()) {
|
||||||
logger.w { "Endless playback: no recommendations returned" }
|
logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" }
|
||||||
isFetchingRecommendations = false
|
isFetchingRecommendations = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -369,7 +479,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
|
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
|
||||||
addAllToQueue(newEntries)
|
addAllToQueue(newEntries)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "Endless playback: failed to fetch recommendations" }
|
logger.e(e) { "Endless playback: failed to add recommendations to queue" }
|
||||||
} finally {
|
} finally {
|
||||||
isFetchingRecommendations = false
|
isFetchingRecommendations = false
|
||||||
}
|
}
|
||||||
@ -393,12 +503,13 @@ class DeviceAudioPlayerQueue(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaItems = state.entries.mapNotNull { it.toMediaItem() }
|
val paired = state.entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
|
||||||
if (mediaItems.isEmpty()) {
|
if (paired.isEmpty()) {
|
||||||
logger.w { "restorePersistedState: all entries had blank urls" }
|
logger.w { "restorePersistedState: all entries had blank urls" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
val mediaItems = paired.map { it.first }
|
||||||
|
entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
|
||||||
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
|
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
|
||||||
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
|
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
|
||||||
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
|
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
|
||||||
@ -456,7 +567,25 @@ class DeviceAudioPlayerQueue(
|
|||||||
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is QueueEntry.StreamingTrack -> {
|
is QueueEntry.StreamingTrack -> {
|
||||||
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
val isIos = getPlatform().type == PlatformType.IOS
|
||||||
|
val requiresIosResolution = isIos && (
|
||||||
|
url.isBlank() ||
|
||||||
|
url.startsWith("http://127.0.0.1") ||
|
||||||
|
url.startsWith("https://127.0.0.1") ||
|
||||||
|
url.startsWith("http://[::1]") ||
|
||||||
|
url.startsWith("https://[::1]") ||
|
||||||
|
url.startsWith("http://localhost") ||
|
||||||
|
url.startsWith("https://localhost"))
|
||||||
|
val resolvedStream = if (requiresIosResolution) {
|
||||||
|
streamingUrlRepository.resolveStreamInfo(track)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val resolvedUrl = if (requiresIosResolution) {
|
||||||
|
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
|
||||||
|
} else {
|
||||||
|
url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||||
|
}
|
||||||
if (resolvedUrl.isBlank()) return null
|
if (resolvedUrl.isBlank()) return null
|
||||||
MediaItem(
|
MediaItem(
|
||||||
title = track.title,
|
title = track.title,
|
||||||
@ -465,7 +594,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = artworkUrl.orEmpty(),
|
coverURL = artworkUrl.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol)
|
protocol = resolvedStream?.protocol ?: protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
@ -492,10 +621,15 @@ class DeviceAudioPlayerQueue(
|
|||||||
url = url)
|
url = url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val localServerOrNull: LocalServer?
|
||||||
|
get() = runCatching { getKoin().getOrNull<LocalServer>() }.getOrNull()
|
||||||
|
|
||||||
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
||||||
val port: Int =
|
val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run {
|
||||||
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
|
val port: Int =
|
||||||
val baseUrl = "http://127.0.0.1:$port"
|
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
|
||||||
|
"http://127.0.0.1:$port"
|
||||||
|
}
|
||||||
return when (protocol) {
|
return when (protocol) {
|
||||||
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
|
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
|
||||||
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"
|
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"
|
||||||
|
|||||||
@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger
|
|||||||
import co.touchlab.kermit.koin.KermitKoinLogger
|
import co.touchlab.kermit.koin.KermitKoinLogger
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
import org.koin.dsl.KoinAppDeclaration
|
||||||
|
import org.spotube.spotube.core.logger.AppLogWriter
|
||||||
|
|
||||||
fun initKoin(configuration: KoinAppDeclaration? = null) {
|
fun initKoin(configuration: KoinAppDeclaration? = null) {
|
||||||
|
Logger.addLogWriter(AppLogWriter())
|
||||||
startKoin {
|
startKoin {
|
||||||
configuration?.invoke(this)
|
configuration?.invoke(this)
|
||||||
logger(
|
logger(
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* 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.logger
|
||||||
|
|
||||||
|
import co.touchlab.kermit.LogWriter
|
||||||
|
import co.touchlab.kermit.Severity
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
data class LogEntry(
|
||||||
|
val id: Long,
|
||||||
|
val timestamp: Instant = Clock.System.now(),
|
||||||
|
val severity: Severity,
|
||||||
|
val tag: String,
|
||||||
|
val message: String,
|
||||||
|
val throwable: Throwable? = null
|
||||||
|
) {
|
||||||
|
val formattedTime: String
|
||||||
|
get() {
|
||||||
|
val dt = timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
val h = dt.hour.toString().padStart(2, '0')
|
||||||
|
val m = dt.minute.toString().padStart(2, '0')
|
||||||
|
val s = dt.second.toString().padStart(2, '0')
|
||||||
|
val ms = (dt.nanosecond / 1_000_000).toString().padStart(3, '0')
|
||||||
|
return "$h:$m:$s.$ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object AppLogBuffer {
|
||||||
|
private const val MAX_LOGS = 1000
|
||||||
|
private var counter = 0L
|
||||||
|
|
||||||
|
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||||
|
val logs = _logs.asStateFlow()
|
||||||
|
|
||||||
|
fun append(severity: Severity, tag: String, message: String, throwable: Throwable? = null) {
|
||||||
|
val entry = LogEntry(
|
||||||
|
id = ++counter,
|
||||||
|
severity = severity,
|
||||||
|
tag = tag,
|
||||||
|
message = message,
|
||||||
|
throwable = throwable
|
||||||
|
)
|
||||||
|
_logs.update { list ->
|
||||||
|
if (list.size >= MAX_LOGS) {
|
||||||
|
list.drop(list.size - MAX_LOGS + 1) + entry
|
||||||
|
} else {
|
||||||
|
list + entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_logs.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatAllLogs(): String {
|
||||||
|
return _logs.value.joinToString("\n") { entry ->
|
||||||
|
val header = "[${entry.formattedTime}] [${entry.severity.name}] [${entry.tag}] ${entry.message}"
|
||||||
|
val err = entry.throwable?.stackTraceToString()?.let { "\n$it" }.orEmpty()
|
||||||
|
header + err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppLogWriter : LogWriter() {
|
||||||
|
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
|
||||||
|
AppLogBuffer.append(
|
||||||
|
severity = severity,
|
||||||
|
tag = tag,
|
||||||
|
message = message,
|
||||||
|
throwable = throwable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
|||||||
import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen
|
import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen
|
||||||
import org.spotube.spotube.modules.search.SearchScreen
|
import org.spotube.spotube.modules.search.SearchScreen
|
||||||
import org.spotube.spotube.modules.settings.SettingsScreen
|
import org.spotube.spotube.modules.settings.SettingsScreen
|
||||||
|
import org.spotube.spotube.modules.settings.logs.LogsScreen
|
||||||
import org.spotube.spotube.modules.settings.JamSettingsScreen
|
import org.spotube.spotube.modules.settings.JamSettingsScreen
|
||||||
import org.spotube.spotube.modules.welcome.WelcomeScreen
|
import org.spotube.spotube.modules.welcome.WelcomeScreen
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@ -101,6 +102,9 @@ sealed interface Routes : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LocalFolder(val folderPath: String) : Routes
|
data class LocalFolder(val folderPath: String) : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Logs : Routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -208,4 +212,7 @@ val navigationModule = module {
|
|||||||
navigation<Routes.Welcome> {
|
navigation<Routes.Welcome> {
|
||||||
WelcomeScreen(viewModel = koinViewModel())
|
WelcomeScreen(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
|
navigation<Routes.Logs> {
|
||||||
|
LogsScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
||||||
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
|
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
|
||||||
|
subclass(Routes.Logs::class, Routes.Logs.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,4 +28,5 @@ enum class SearchKind {
|
|||||||
expect class NewPipeService() {
|
expect class NewPipeService() {
|
||||||
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
|
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
|
||||||
suspend fun getVideoInfo(id: String): VideoInfo
|
suspend fun getVideoInfo(id: String): VideoInfo
|
||||||
|
suspend fun getRadioMix(videoId: String): List<VideoSearchResult>
|
||||||
}
|
}
|
||||||
@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@ -88,6 +93,19 @@ class LocalServer(
|
|||||||
port?.let { "http://$HOST_LOCAL:$it" }
|
port?.let { "http://$HOST_LOCAL:$it" }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
suspend fun awaitPort(timeout: Duration = 3.seconds): Int {
|
||||||
|
val current = activePort.value
|
||||||
|
if (current != null) return current
|
||||||
|
return withTimeoutOrNull(timeout) {
|
||||||
|
activePort.filterNotNull().first()
|
||||||
|
} ?: settingsViewModel.settingsState.value?.playbackProxyServerPort ?: 14769
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun awaitBaseUrl(timeout: Duration = 3.seconds): String {
|
||||||
|
val port = awaitPort(timeout)
|
||||||
|
return "http://$HOST_LOCAL:$port"
|
||||||
|
}
|
||||||
|
|
||||||
private val cachedCacheEnabled = MutableStateFlow(false)
|
private val cachedCacheEnabled = MutableStateFlow(false)
|
||||||
|
|
||||||
private val streamProxy by lazy {
|
private val streamProxy by lazy {
|
||||||
|
|||||||
@ -43,6 +43,7 @@ data class PluginTaggedAudioSource(
|
|||||||
interface TrackSourceRepository {
|
interface TrackSourceRepository {
|
||||||
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
|
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
|
||||||
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
|
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
|
||||||
|
suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
|
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
|
||||||
@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos
|
|||||||
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
|
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) {
|
||||||
|
database.matchedTracksDataStore.edit { prefs ->
|
||||||
|
val key = stringPreferencesKey(track.id)
|
||||||
|
val json = prefs[key] ?: return@edit
|
||||||
|
val tagged = try {
|
||||||
|
Json.decodeFromString<PluginTaggedAudioSource>(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (tagged == null || tagged.pluginId == pluginId) {
|
||||||
|
prefs.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -34,6 +34,7 @@ import io.ktor.http.HeadersBuilder
|
|||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.Url
|
||||||
import io.ktor.http.content.OutgoingContent
|
import io.ktor.http.content.OutgoingContent
|
||||||
import io.ktor.http.decodeURLQueryComponent
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
import io.ktor.http.encodeURLParameter
|
import io.ktor.http.encodeURLParameter
|
||||||
@ -72,6 +73,15 @@ internal class StreamProxy(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val HOST = "127.0.0.1"
|
private const val HOST = "127.0.0.1"
|
||||||
|
private const val YOUTUBE_REFERER = "https://www.youtube.com/"
|
||||||
|
private const val BROWSER_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
private const val ANDROID_USER_AGENT =
|
||||||
|
"com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"
|
||||||
|
private const val IOS_USER_AGENT =
|
||||||
|
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)"
|
||||||
|
private const val VISIONOS_USER_AGENT =
|
||||||
|
"com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val fileSystem = FileSystem.SYSTEM
|
private val fileSystem = FileSystem.SYSTEM
|
||||||
@ -233,8 +243,7 @@ internal class StreamProxy(
|
|||||||
val response = runCatching {
|
val response = runCatching {
|
||||||
httpClient.request(manifestUrl) {
|
httpClient.request(manifestUrl) {
|
||||||
method = HttpMethod.Get
|
method = HttpMethod.Get
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
applyUpstreamHeaders(call, this, manifestUrl, includeRange = false)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
|
||||||
}
|
}
|
||||||
}.getOrElse { throwable ->
|
}.getOrElse { throwable ->
|
||||||
logger.w(throwable) { "Failed to fetch manifest for track $trackId" }
|
logger.w(throwable) { "Failed to fetch manifest for track $trackId" }
|
||||||
@ -284,9 +293,7 @@ internal class StreamProxy(
|
|||||||
runCatching {
|
runCatching {
|
||||||
httpClient.prepareRequest(segmentUrl) {
|
httpClient.prepareRequest(segmentUrl) {
|
||||||
method = HttpMethod.Get
|
method = HttpMethod.Get
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
applyUpstreamHeaders(call, this, segmentUrl, includeRange = true)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
|
||||||
}.execute { response -> respondWithSegment(call, response, trackId) }
|
}.execute { response -> respondWithSegment(call, response, trackId) }
|
||||||
}.onFailure { throwable ->
|
}.onFailure { throwable ->
|
||||||
if (throwable is CancellationException) throw throwable
|
if (throwable is CancellationException) throw throwable
|
||||||
@ -343,10 +350,7 @@ internal class StreamProxy(
|
|||||||
val failure = runCatching {
|
val failure = runCatching {
|
||||||
httpClient.prepareRequest(currentUrl) {
|
httpClient.prepareRequest(currentUrl) {
|
||||||
method = requestMethod
|
method = requestMethod
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
applyUpstreamHeaders(call, this, currentUrl, includeRange = true)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
|
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
|
||||||
}.execute { response ->
|
}.execute { response ->
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " +
|
"[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " +
|
||||||
@ -578,19 +582,83 @@ internal class StreamProxy(
|
|||||||
private fun encodeUrl(url: String): String = url.encodeURLParameter()
|
private fun encodeUrl(url: String): String = url.encodeURLParameter()
|
||||||
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent()
|
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent()
|
||||||
|
|
||||||
|
private fun isYouTubeOrGoogleVideo(url: String): Boolean {
|
||||||
|
val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: ""
|
||||||
|
return host.endsWith("googlevideo.com") ||
|
||||||
|
host.endsWith("youtube.com") ||
|
||||||
|
host.endsWith("youtu.be") ||
|
||||||
|
url.contains("googlevideo.com", ignoreCase = true) ||
|
||||||
|
url.contains("youtube.com", ignoreCase = true) ||
|
||||||
|
url.contains("youtu.be", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyUpstreamHeaders(
|
||||||
|
call: ApplicationCall,
|
||||||
|
builder: io.ktor.client.request.HttpRequestBuilder,
|
||||||
|
targetUrl: String,
|
||||||
|
includeRange: Boolean
|
||||||
|
) {
|
||||||
|
if (includeRange) {
|
||||||
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Range)
|
||||||
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.IfRange)
|
||||||
|
}
|
||||||
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept)
|
||||||
|
|
||||||
|
if (isYouTubeOrGoogleVideo(targetUrl)) {
|
||||||
|
val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull()
|
||||||
|
?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase()
|
||||||
|
|
||||||
|
when (clientParam) {
|
||||||
|
"ANDROID" -> {
|
||||||
|
builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
|
||||||
|
builder.headers.remove(HttpHeaders.Referrer)
|
||||||
|
builder.headers.remove("Referer")
|
||||||
|
builder.headers.remove(HttpHeaders.Origin)
|
||||||
|
builder.headers.remove("Origin")
|
||||||
|
}
|
||||||
|
"IOS" -> {
|
||||||
|
builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
|
||||||
|
builder.headers.remove(HttpHeaders.Referrer)
|
||||||
|
builder.headers.remove("Referer")
|
||||||
|
builder.headers.remove(HttpHeaders.Origin)
|
||||||
|
builder.headers.remove("Origin")
|
||||||
|
}
|
||||||
|
"VISIONOS" -> {
|
||||||
|
builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
|
||||||
|
builder.headers.remove(HttpHeaders.Referrer)
|
||||||
|
builder.headers.remove("Referer")
|
||||||
|
builder.headers.remove(HttpHeaders.Origin)
|
||||||
|
builder.headers.remove("Origin")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER
|
||||||
|
builder.headers[HttpHeaders.Origin] = "https://www.youtube.com"
|
||||||
|
builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent)
|
||||||
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun ApplicationCall.forwardRequestHeaderIfPresent(
|
private fun ApplicationCall.forwardRequestHeaderIfPresent(
|
||||||
builder: HeadersBuilder,
|
builder: HeadersBuilder,
|
||||||
headerName: String
|
headerName: String
|
||||||
) {
|
) {
|
||||||
request.headers.getAll(headerName)?.forEach { value ->
|
request.headers.getAll(headerName)?.forEach { value ->
|
||||||
builder.append(headerName, value)
|
builder[headerName] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun upstreamHeadersToForward(upstream: HttpResponse): Headers {
|
private fun upstreamHeadersToForward(upstream: HttpResponse): Headers {
|
||||||
return HeadersBuilder().apply {
|
return HeadersBuilder().apply {
|
||||||
upstream.headers.forEach { name, values ->
|
upstream.headers.forEach { name, values ->
|
||||||
values.forEach { value -> append(name, value) }
|
if (!name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) &&
|
||||||
|
!name.equals(HttpHeaders.Connection, ignoreCase = true)
|
||||||
|
) {
|
||||||
|
values.forEach { value -> append(name, value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,6 +187,7 @@ class StreamingUrlRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" }
|
logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" }
|
||||||
|
matchedTracksRepository.deleteTrackSource(track, audioPlugin.pluginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Attempting stream resolution for track $trackId using track lookup" }
|
logger.d { "Attempting stream resolution for track $trackId using track lookup" }
|
||||||
|
|||||||
@ -55,29 +55,47 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
var confidence = 0f
|
var confidence = 0f
|
||||||
|
|
||||||
val videoTitle = normalize(video.title)
|
val videoTitle = normalize(video.title)
|
||||||
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true)
|
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) ||
|
||||||
val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), ""))
|
video.uploader.trim().contains("Topic", ignoreCase = true)
|
||||||
|
val uploaderRaw = video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "").trim()
|
||||||
|
val uploader = normalize(uploaderRaw)
|
||||||
val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() }
|
val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() }
|
||||||
val title = coreTitle(track.title)
|
val title = coreTitle(track.title)
|
||||||
|
|
||||||
val videoTokens = videoTitle.split(' ').toSet()
|
val videoTokens = videoTitle.split(' ').toSet()
|
||||||
val isTitleMatch = title.isNotEmpty() &&
|
val isTitleMatch = title.isNotEmpty() &&
|
||||||
(title in videoTitle || title.split(' ').all { it in videoTokens })
|
(title in videoTitle || videoTitle in title || title.split(' ').filter { it.length > 2 }.all { it in videoTokens })
|
||||||
val isArtistInTitle = artistNames.any { it in videoTitle }
|
val isArtistInTitle = artistNames.any { it in videoTitle }
|
||||||
val isAlbumInTitle = track.album?.title?.let { normalize(it) }
|
val isAlbumInTitle = track.album?.title?.let { normalize(it) }
|
||||||
?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false
|
?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false
|
||||||
val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it }
|
|
||||||
// Unknown duration (0) must neither reward nor punish
|
// Normalize whitespace and punctuation for uploader comparison (e.g. "Taylor Swift" vs "TaylorSwiftVEVO")
|
||||||
val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE
|
val uploaderCompact = uploader.replace(Regex("(?i)vevo$"), "").replace(" ", "")
|
||||||
|
val artistCompacts = artistNames.map { it.replace(" ", "") }
|
||||||
|
val isUploaderMatch = uploader.isNotEmpty() && (
|
||||||
|
artistNames.any { it in uploader || uploader in it } ||
|
||||||
|
(uploaderCompact.isNotEmpty() && artistCompacts.any { it.isNotEmpty() && (it in uploaderCompact || uploaderCompact in it) })
|
||||||
|
)
|
||||||
|
val isVevo = video.uploader.contains("VEVO", ignoreCase = true)
|
||||||
|
val isOfficialTrack = listOf("official audio", "official video", "official music video", "official visualizer", "official lyric video").any { videoTitle.contains(it) }
|
||||||
|
|
||||||
|
val hasValidDurations = video.durationMs > 0 && track.durationMs > 0
|
||||||
|
val durationDiff = if (hasValidDurations) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE
|
||||||
|
|
||||||
if (isTitleMatch) confidence += 0.3f
|
if (isTitleMatch) confidence += 0.3f
|
||||||
if (isUploaderMatch) confidence += 0.3f
|
if (isUploaderMatch) confidence += 0.3f
|
||||||
|
if (isVevo) confidence += 0.15f
|
||||||
|
if (isOfficialTrack) confidence += 0.1f
|
||||||
|
if (isTopicChannel) confidence += if (isUploaderMatch) 0.2f else 0.1f
|
||||||
|
|
||||||
if (durationDiff <= 10_000) confidence += 0.2f
|
if (durationDiff <= 10_000) confidence += 0.2f
|
||||||
else if (durationDiff <= 30_000) confidence += 0.1f
|
else if (durationDiff <= 30_000) confidence += 0.15f
|
||||||
|
else if (durationDiff <= 60_000) confidence += 0.1f
|
||||||
|
else if (durationDiff <= 120_000) confidence += 0.05f
|
||||||
|
else if (!hasValidDurations) confidence += 0.1f // Don't penalize when duration is unknown
|
||||||
|
|
||||||
if (isArtistInTitle) confidence += 0.1f
|
if (isArtistInTitle) confidence += 0.1f
|
||||||
if (isAlbumInTitle) confidence += 0.1f
|
if (isAlbumInTitle) confidence += 0.1f
|
||||||
// Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos
|
|
||||||
if (isTopicChannel && isUploaderMatch) confidence += 0.1f
|
|
||||||
confidence = confidence.coerceAtMost(1f)
|
confidence = confidence.coerceAtMost(1f)
|
||||||
|
|
||||||
logger.i {
|
logger.i {
|
||||||
@ -122,12 +140,29 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun extractYouTubeVideoId(urlOrId: String?): String? {
|
||||||
|
if (urlOrId.isNullOrBlank()) return null
|
||||||
|
val trimmed = urlOrId.trim()
|
||||||
|
if (trimmed.matches(youtubeIDRegex)) return trimmed
|
||||||
|
|
||||||
|
val vParamRegex = Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""")
|
||||||
|
vParamRegex.find(trimmed)?.groupValues?.get(1)?.let { return it }
|
||||||
|
|
||||||
|
val youtuBeRegex = Regex("""youtu\.be/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""")
|
||||||
|
youtuBeRegex.find(trimmed)?.groupValues?.get(1)?.let { return it }
|
||||||
|
|
||||||
|
val pathRegex = Regex("""youtube\.com/(?:embed|v|shorts)/([a-zA-Z0-9_-]{11})(?:[?#/]|$)""")
|
||||||
|
pathRegex.find(trimmed)?.groupValues?.get(1)?.let { return it }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
|
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
|
||||||
val isYouTubeUrl =
|
val resolvedVideoId = if (track.id.matches(youtubeIDRegex)) {
|
||||||
track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains(
|
track.id
|
||||||
"youtu.be"
|
} else {
|
||||||
))
|
extractYouTubeVideoId(track.externalUri)
|
||||||
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
}
|
||||||
|
|
||||||
logger.i {
|
logger.i {
|
||||||
"Searching streams for track(${track.id}): ${track.title} by ${
|
"Searching streams for track(${track.id}): ${track.title} by ${
|
||||||
@ -136,9 +171,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
) { it.name }
|
) { it.name }
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
if (isYouTubeID || isYouTubeUrl) {
|
if (resolvedVideoId != null) {
|
||||||
logger.i { "Track has YouTube ID or URL, fetching video info directly" }
|
logger.i { "Track has YouTube ID or URL ($resolvedVideoId), fetching video info directly" }
|
||||||
return newPipeService.getVideoInfo(track.id).let { videoInfo ->
|
return newPipeService.getVideoInfo(resolvedVideoId).let { videoInfo ->
|
||||||
listOf(
|
listOf(
|
||||||
AudioSource.Streamed(
|
AudioSource.Streamed(
|
||||||
id = videoInfo.id,
|
id = videoInfo.id,
|
||||||
@ -228,17 +263,32 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
|
|
||||||
if (matched.isNotEmpty()) return matched.take(5)
|
if (matched.isNotEmpty()) return matched.take(5)
|
||||||
|
|
||||||
// Nothing scored well. Playing YouTube's best text-search guess beats failing playback,
|
// When confidence scoring yields no candidates >= 0.3, ensure the top relevance search
|
||||||
// and ISRC results are skipped because an ISRC text search tends to return unrelated songs.
|
// result is returned as fallback rather than throwing TrackNotFoundError or failing.
|
||||||
val fallback = textSearchResults.values.toList()
|
val fallback = if (textSearchResults.isNotEmpty()) {
|
||||||
.sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order
|
val list = textSearchResults.values.toList()
|
||||||
.take(5)
|
val topRelevance = list.first()
|
||||||
.ifEmpty { ranked.take(5) }
|
val rest = list.drop(1).sortedByDescending { it.confidence }
|
||||||
logger.w {
|
(listOf(topRelevance) + rest).take(5)
|
||||||
"[Search] no confident match for '${track.title}'; falling back to best guesses: " +
|
} else if (ranked.isNotEmpty()) {
|
||||||
fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" }
|
ranked.take(5)
|
||||||
|
} else {
|
||||||
|
runCatching {
|
||||||
|
newPipeService.searchVideos(plainTitle.ifEmpty { track.title }, SearchKind.Videos)
|
||||||
|
.map { transformVideo(track, it) }
|
||||||
|
}.getOrDefault(emptyList()).take(5)
|
||||||
}
|
}
|
||||||
return fallback
|
|
||||||
|
if (fallback.isNotEmpty()) {
|
||||||
|
logger.w {
|
||||||
|
"[Search] no confident match for '${track.title}'; falling back to top relevance result: " +
|
||||||
|
fallback.joinToString { "${it.id} conf=${it.confidence} '${it.title}'" }
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.w { "[Search] no search results found at all for '${track.title}'" }
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
|
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
|
||||||
|
|||||||
@ -34,8 +34,10 @@ import io.ktor.client.request.headers
|
|||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.Url
|
||||||
import io.ktor.http.contentLength
|
import io.ktor.http.contentLength
|
||||||
import io.ktor.http.isSuccess
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.discard
|
||||||
import io.ktor.utils.io.readRemaining
|
import io.ktor.utils.io.readRemaining
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -95,6 +97,15 @@ class DownloadManager(
|
|||||||
const val MAX_CONCURRENT_DOWNLOADS = 4
|
const val MAX_CONCURRENT_DOWNLOADS = 4
|
||||||
private const val SEGMENT_COUNT = 4
|
private const val SEGMENT_COUNT = 4
|
||||||
private const val CHUNK_SIZE = 256 * 1024
|
private const val CHUNK_SIZE = 256 * 1024
|
||||||
|
private const val YOUTUBE_REFERER = "https://www.youtube.com/"
|
||||||
|
private const val BROWSER_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
private const val ANDROID_USER_AGENT =
|
||||||
|
"com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip"
|
||||||
|
private const val IOS_USER_AGENT =
|
||||||
|
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)"
|
||||||
|
private const val VISIONOS_USER_AGENT =
|
||||||
|
"com.google.visionos.youtube/1.04(RealityDevice17,1; U; CPU visionOS 26_6_0 like Mac OS X; US)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val logger = Logger.withTag("DownloadManager")
|
private val logger = Logger.withTag("DownloadManager")
|
||||||
@ -362,6 +373,7 @@ class DownloadManager(
|
|||||||
headers {
|
headers {
|
||||||
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
|
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
|
||||||
}
|
}
|
||||||
|
applyClientHeaders(url)
|
||||||
}
|
}
|
||||||
logger.d {
|
logger.d {
|
||||||
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
|
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
|
||||||
@ -398,7 +410,9 @@ class DownloadManager(
|
|||||||
downloadDir: Path) {
|
downloadDir: Path) {
|
||||||
val tempFile = tempDir / "${item.id}.tmp"
|
val tempFile = tempDir / "${item.id}.tmp"
|
||||||
|
|
||||||
val response = httpClient.get(url)
|
val response = httpClient.get(url) {
|
||||||
|
applyClientHeaders(url)
|
||||||
|
}
|
||||||
val totalSize = response.contentLength() ?: 0L
|
val totalSize = response.contentLength() ?: 0L
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
|
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
|
||||||
@ -442,21 +456,34 @@ class DownloadManager(
|
|||||||
|
|
||||||
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
|
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
|
||||||
return try {
|
return try {
|
||||||
val response = httpClient.head(url)
|
val response = httpClient.get(url) {
|
||||||
val contentLength = response.contentLength() ?: 0L
|
headers {
|
||||||
|
append(HttpHeaders.Range, "bytes=0-0")
|
||||||
|
}
|
||||||
|
applyClientHeaders(url)
|
||||||
|
}
|
||||||
|
if (!response.status.isSuccess() && response.status != HttpStatusCode.PartialContent) {
|
||||||
|
logger.w { "[Download $id] PROBE returned HTTP ${response.status.value}; failing download early" }
|
||||||
|
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
|
||||||
|
}
|
||||||
|
val contentRange = response.headers[HttpHeaders.ContentRange]
|
||||||
|
val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull()
|
||||||
|
val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L
|
||||||
val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
|
val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
|
||||||
val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true
|
val supportsRanges = response.status == HttpStatusCode.PartialContent ||
|
||||||
|
acceptRanges?.equals("bytes", ignoreCase = true) == true
|
||||||
val contentType = response.headers[HttpHeaders.ContentType]
|
val contentType = response.headers[HttpHeaders.ContentType]
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
|
"[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " +
|
||||||
"length=$contentLength acceptRanges=$acceptRanges"
|
"length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges"
|
||||||
}
|
}
|
||||||
|
runCatching { response.bodyAsChannel().discard() }
|
||||||
rejectVideo(id, contentType)
|
rejectVideo(id, contentType)
|
||||||
Pair(contentLength, supportsRanges)
|
Pair(contentLength, supportsRanges)
|
||||||
} catch (e: DownloadException) {
|
} catch (e: DownloadException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" }
|
logger.w(e) { "[Download $id] PROBE request failed, falling back to single connection" }
|
||||||
Pair(0L, false)
|
Pair(0L, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -472,6 +499,52 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isYouTubeOrGoogleVideo(url: String): Boolean {
|
||||||
|
val host = runCatching { Url(url).host.lowercase() }.getOrNull() ?: ""
|
||||||
|
return host.endsWith("googlevideo.com") ||
|
||||||
|
host.endsWith("youtube.com") ||
|
||||||
|
host.endsWith("youtu.be") ||
|
||||||
|
url.contains("googlevideo.com", ignoreCase = true) ||
|
||||||
|
url.contains("youtube.com", ignoreCase = true) ||
|
||||||
|
url.contains("youtu.be", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun io.ktor.client.request.HttpRequestBuilder.applyClientHeaders(targetUrl: String) {
|
||||||
|
if (isYouTubeOrGoogleVideo(targetUrl)) {
|
||||||
|
val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull()
|
||||||
|
?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase()
|
||||||
|
|
||||||
|
when (clientParam) {
|
||||||
|
"ANDROID" -> {
|
||||||
|
headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
|
||||||
|
headers.remove(HttpHeaders.Referrer)
|
||||||
|
headers.remove("Referer")
|
||||||
|
headers.remove(HttpHeaders.Origin)
|
||||||
|
headers.remove("Origin")
|
||||||
|
}
|
||||||
|
"IOS" -> {
|
||||||
|
headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
|
||||||
|
headers.remove(HttpHeaders.Referrer)
|
||||||
|
headers.remove("Referer")
|
||||||
|
headers.remove(HttpHeaders.Origin)
|
||||||
|
headers.remove("Origin")
|
||||||
|
}
|
||||||
|
"VISIONOS" -> {
|
||||||
|
headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
|
||||||
|
headers.remove(HttpHeaders.Referrer)
|
||||||
|
headers.remove("Referer")
|
||||||
|
headers.remove(HttpHeaders.Origin)
|
||||||
|
headers.remove("Origin")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
headers[HttpHeaders.Referrer] = YOUTUBE_REFERER
|
||||||
|
headers[HttpHeaders.Origin] = "https://www.youtube.com"
|
||||||
|
headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
|
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
val wanted = settings.downloadMusicFormat
|
val wanted = settings.downloadMusicFormat
|
||||||
|
|||||||
@ -72,7 +72,7 @@ class GitHubPluginRepository(private val httpClient: HttpClient) {
|
|||||||
append("Accept", "application/vnd.github+json")
|
append("Accept", "application/vnd.github+json")
|
||||||
append("X-GitHub-Api-Version", "2022-11-28")
|
append("X-GitHub-Api-Version", "2022-11-28")
|
||||||
}
|
}
|
||||||
parameter("q", "topic:spotube-zipline-plugin")
|
parameter("q", "topic:spotube-zipline-plugin fork:true")
|
||||||
parameter("sort", "stars")
|
parameter("sort", "stars")
|
||||||
parameter("order", "desc")
|
parameter("order", "desc")
|
||||||
parameter("page", page)
|
parameter("page", page)
|
||||||
|
|||||||
@ -579,7 +579,12 @@ class PluginManager(
|
|||||||
val updatedSelectedPlugins =
|
val updatedSelectedPlugins =
|
||||||
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
||||||
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
||||||
|
}.toMutableMap()
|
||||||
|
for (ability in plugin.abilities) {
|
||||||
|
if (updatedSelectedPlugins[ability] == null) {
|
||||||
|
updatedSelectedPlugins[ability] = plugin
|
||||||
}
|
}
|
||||||
|
}
|
||||||
val newState = PluginManagerStates.Data(
|
val newState = PluginManagerStates.Data(
|
||||||
plugins = updatedPlugins,
|
plugins = updatedPlugins,
|
||||||
selectedPlugins = updatedSelectedPlugins,
|
selectedPlugins = updatedSelectedPlugins,
|
||||||
@ -599,12 +604,21 @@ class PluginManager(
|
|||||||
if (currentState is PluginManagerStates.Data) {
|
if (currentState is PluginManagerStates.Data) {
|
||||||
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
||||||
val updatedSelectedPlugins =
|
val updatedSelectedPlugins =
|
||||||
currentState.selectedPlugins.filterValues { it.id != plugin.id }
|
currentState.selectedPlugins.filterValues { it.id != plugin.id }.toMutableMap()
|
||||||
|
for (ability in plugin.abilities) {
|
||||||
|
if (updatedSelectedPlugins[ability] == null) {
|
||||||
|
val fallback = updatedPlugins.firstOrNull { ability in it.abilities }
|
||||||
|
if (fallback != null) {
|
||||||
|
updatedSelectedPlugins[ability] = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val newState = PluginManagerStates.Data(
|
val newState = PluginManagerStates.Data(
|
||||||
plugins = updatedPlugins,
|
plugins = updatedPlugins,
|
||||||
selectedPlugins = updatedSelectedPlugins,
|
selectedPlugins = updatedSelectedPlugins,
|
||||||
generation = currentState.generation
|
generation = currentState.generation + 1
|
||||||
)
|
)
|
||||||
|
logger.d { "removePlugin: bumped generation to ${newState.generation} after removing ${plugin.id}" }
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val pluginDir = pluginsDir / plugin.id.toPath()
|
val pluginDir = pluginsDir / plugin.id.toPath()
|
||||||
|
|||||||
@ -182,7 +182,7 @@ import spotube.composeapp.generated.resources.settings_plugins_plugin_content_de
|
|||||||
|
|
||||||
private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube")
|
private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube")
|
||||||
|
|
||||||
private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
|
private val VERIFIED_PLUGIN_OWNERS = setOf("pgwiz", "sonic-liberation")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PluginScreen(
|
fun PluginScreen(
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import org.spotube.spotube.modules.settings.sections.languageRegionSection
|
|||||||
import org.spotube.spotube.modules.settings.sections.playbackSection
|
import org.spotube.spotube.modules.settings.sections.playbackSection
|
||||||
import org.spotube.spotube.modules.settings.sections.pluginsSection
|
import org.spotube.spotube.modules.settings.sections.pluginsSection
|
||||||
import org.spotube.spotube.modules.settings.sections.supportSection
|
import org.spotube.spotube.modules.settings.sections.supportSection
|
||||||
|
import org.spotube.spotube.modules.settings.sections.logsSection
|
||||||
import org.spotube.spotube.modules.settings.sections.updatesSection
|
import org.spotube.spotube.modules.settings.sections.updatesSection
|
||||||
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@ -161,6 +162,8 @@ private fun CompactSettingsContent(
|
|||||||
updatesSection(
|
updatesSection(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
settingsViewModel = settingsViewModel)
|
settingsViewModel = settingsViewModel)
|
||||||
|
logsSection(
|
||||||
|
navigatorCommands = navigatorCommands)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,8 @@ import spotube.composeapp.generated.resources.common_close
|
|||||||
import spotube.composeapp.generated.resources.settings_blacklist_title
|
import spotube.composeapp.generated.resources.settings_blacklist_title
|
||||||
import spotube.composeapp.generated.resources.settings_screen_title
|
import spotube.composeapp.generated.resources.settings_screen_title
|
||||||
import spotube.composeapp.generated.resources.settings_section_about
|
import spotube.composeapp.generated.resources.settings_section_about
|
||||||
|
import spotube.composeapp.generated.resources.settings_section_logs
|
||||||
|
import org.spotube.spotube.modules.settings.logs.LogsContent
|
||||||
import spotube.composeapp.generated.resources.settings_section_appearance
|
import spotube.composeapp.generated.resources.settings_section_appearance
|
||||||
import spotube.composeapp.generated.resources.settings_section_caching
|
import spotube.composeapp.generated.resources.settings_section_caching
|
||||||
import spotube.composeapp.generated.resources.settings_section_desktop
|
import spotube.composeapp.generated.resources.settings_section_desktop
|
||||||
@ -129,6 +131,7 @@ internal enum class SettingsTab(
|
|||||||
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
|
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
|
||||||
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
|
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
|
||||||
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
|
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
|
||||||
|
Logs(Res.string.settings_section_logs, Iconsax.IconsaxDocumentText),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -283,6 +286,10 @@ private fun SettingsTabContent(
|
|||||||
JamSettingsContent(viewModel = koinViewModel())
|
JamSettingsContent(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsTab.Logs -> {
|
||||||
|
LogsContent(modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
if (settings == null) {
|
if (settings == null) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@ -0,0 +1,460 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.settings.logs
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import co.touchlab.kermit.Severity
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumTextField
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.logger.AppLogBuffer
|
||||||
|
import org.spotube.spotube.core.logger.LogEntry
|
||||||
|
import org.spotube.spotube.core.server.LocalServer
|
||||||
|
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||||
|
import org.spotube.spotube.modules.plugin.PluginManager
|
||||||
|
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxDocumentText
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxShare
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxTrash
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_clear
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_copied
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_copy
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_empty
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_filter_all
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_filter_errors
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_filter_warnings
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_search_hint
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_title
|
||||||
|
|
||||||
|
private enum class LogFilter {
|
||||||
|
ALL,
|
||||||
|
ERRORS,
|
||||||
|
WARNINGS
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogsScreen() {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ApplicationMainBar(
|
||||||
|
backButton = true,
|
||||||
|
title = { Text(stringResource(Res.string.settings_logs_title)) }
|
||||||
|
)
|
||||||
|
LogsContent(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LogsContent(modifier: Modifier = Modifier) {
|
||||||
|
val logs by AppLogBuffer.logs.collectAsStateWithLifecycle()
|
||||||
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val localServer = runCatching { koinInject<LocalServer>() }.getOrNull()
|
||||||
|
val localServerPort by (localServer?.port?.collectAsStateWithLifecycle() ?: remember { mutableStateOf<Int?>(null) })
|
||||||
|
val pluginManager = runCatching { koinInject<PluginManager>() }.getOrNull()
|
||||||
|
val audioPlugin by (pluginManager?.selectedAudioPlugin?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
|
||||||
|
val audioPlayerQueue = runCatching { koinInject<AudioPlayerQueue>() }.getOrNull()
|
||||||
|
val currentQueueEntry by (audioPlayerQueue?.currentQueueEntryFlow?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
|
||||||
|
|
||||||
|
val searchState = rememberTextFieldState("")
|
||||||
|
val searchQuery = searchState.text.toString().trim().lowercase()
|
||||||
|
var selectedFilter by remember { mutableStateOf(LogFilter.ALL) }
|
||||||
|
var copiedFeedback by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val filteredLogs = remember(logs, searchQuery, selectedFilter) {
|
||||||
|
logs.filter { entry ->
|
||||||
|
val matchesSeverity = when (selectedFilter) {
|
||||||
|
LogFilter.ALL -> true
|
||||||
|
LogFilter.ERRORS -> entry.severity == Severity.Error || entry.severity == Severity.Assert
|
||||||
|
LogFilter.WARNINGS -> entry.severity == Severity.Warn || entry.severity == Severity.Error || entry.severity == Severity.Assert
|
||||||
|
}
|
||||||
|
val matchesSearch = if (searchQuery.isBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
entry.tag.lowercase().contains(searchQuery) || entry.message.lowercase().contains(searchQuery)
|
||||||
|
}
|
||||||
|
matchesSeverity && matchesSearch
|
||||||
|
}.asReversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||||
|
// Diagnostic status overview card
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "System Diagnostics",
|
||||||
|
style = AppTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = AppTheme.colors.primary
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Proxy Server:",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (localServerPort != null) "127.0.0.1:$localServerPort (Active)" else "Starting...",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (localServerPort != null) AppTheme.colors.primary else AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Audio Plugin:",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = audioPlugin?.name ?: "NewPipe YouTube (Built-in)",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = AppTheme.colors.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Current Track:",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = currentQueueEntry?.let { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is org.spotube.spotube.core.audioplayer.QueueEntry.StreamingTrack -> entry.track.title
|
||||||
|
is org.spotube.spotube.core.audioplayer.QueueEntry.LocalTrack -> entry.name
|
||||||
|
}
|
||||||
|
} ?: "None",
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = AppTheme.colors.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action & Filter row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
MillenniumTextField(
|
||||||
|
state = searchState,
|
||||||
|
placeholder = { Text(stringResource(Res.string.settings_logs_search_hint), fontSize = 12.sp) },
|
||||||
|
leadingIcon = {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.IconsaxFilterSearch,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
MillenniumButton(
|
||||||
|
onClick = {
|
||||||
|
val fullLogs = AppLogBuffer.formatAllLogs()
|
||||||
|
clipboardManager.setText(AnnotatedString(fullLogs))
|
||||||
|
copiedFeedback = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
delay(2000)
|
||||||
|
copiedFeedback = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MillenniumButtonStyle.Secondary
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.IconsaxShare,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (copiedFeedback) stringResource(Res.string.settings_logs_copied) else stringResource(Res.string.settings_logs_copy),
|
||||||
|
style = AppTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = { AppLogBuffer.clear() },
|
||||||
|
style = MillenniumIconButtonStyle.Secondary,
|
||||||
|
contentDescription = stringResource(Res.string.settings_logs_clear)
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.IconsaxTrash,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter chips row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
LogFilterChip(
|
||||||
|
label = stringResource(Res.string.settings_logs_filter_all),
|
||||||
|
selected = selectedFilter == LogFilter.ALL,
|
||||||
|
count = logs.size,
|
||||||
|
onClick = { selectedFilter = LogFilter.ALL }
|
||||||
|
)
|
||||||
|
LogFilterChip(
|
||||||
|
label = stringResource(Res.string.settings_logs_filter_errors),
|
||||||
|
selected = selectedFilter == LogFilter.ERRORS,
|
||||||
|
count = logs.count { it.severity == Severity.Error || it.severity == Severity.Assert },
|
||||||
|
chipColor = Color(0xFFE53935),
|
||||||
|
onClick = { selectedFilter = LogFilter.ERRORS }
|
||||||
|
)
|
||||||
|
LogFilterChip(
|
||||||
|
label = stringResource(Res.string.settings_logs_filter_warnings),
|
||||||
|
selected = selectedFilter == LogFilter.WARNINGS,
|
||||||
|
count = logs.count { it.severity == Severity.Warn },
|
||||||
|
chipColor = Color(0xFFFB8C00),
|
||||||
|
onClick = { selectedFilter = LogFilter.WARNINGS }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs list
|
||||||
|
if (filteredLogs.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.IconsaxDocumentText,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = AppTheme.colors.textMuted,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_logs_empty),
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp + shellBottomInset),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(filteredLogs, key = { it.id }) { logEntry ->
|
||||||
|
LogEntryCard(entry = logEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogFilterChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
count: Int,
|
||||||
|
chipColor: Color = AppTheme.colors.primary,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (selected) chipColor.copy(alpha = 0.2f) else AppTheme.colors.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$label ($count)",
|
||||||
|
style = AppTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (selected) chipColor else AppTheme.colors.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogEntryCard(entry: LogEntry) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val severityColor = when (entry.severity) {
|
||||||
|
Severity.Error, Severity.Assert -> Color(0xFFE53935)
|
||||||
|
Severity.Warn -> Color(0xFFFB8C00)
|
||||||
|
Severity.Info -> AppTheme.colors.primary
|
||||||
|
Severity.Debug -> AppTheme.colors.textMuted
|
||||||
|
Severity.Verbose -> AppTheme.colors.textMuted.copy(alpha = 0.7f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.35f))
|
||||||
|
.clickable(enabled = entry.throwable != null) { expanded = !expanded }
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Severity label
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(severityColor.copy(alpha = 0.18f))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = entry.severity.name.take(4),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = severityColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
Text(
|
||||||
|
text = entry.formattedTime,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = AppTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag
|
||||||
|
Text(
|
||||||
|
text = "[${entry.tag}]",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = AppTheme.colors.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message
|
||||||
|
Text(
|
||||||
|
text = entry.message,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = AppTheme.colors.onSurface,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Throwable toggle / stack trace
|
||||||
|
if (entry.throwable != null) {
|
||||||
|
Text(
|
||||||
|
text = if (expanded) "▲ Hide stack trace" else "▼ Show stack trace (${entry.throwable::class.simpleName})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = severityColor
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Text(
|
||||||
|
text = entry.throwable.stackTraceToString(),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = AppTheme.colors.textMuted,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.3f))
|
||||||
|
.padding(6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.settings.sections
|
||||||
|
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||||
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
|
import org.spotube.spotube.modules.settings.components.SettingCardItem
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxDocumentText
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.settings_section_logs
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_title
|
||||||
|
import spotube.composeapp.generated.resources.settings_logs_subtitle
|
||||||
|
|
||||||
|
internal fun LazyListScope.logsSection(
|
||||||
|
navigatorCommands: NavigationCommands,
|
||||||
|
includeHeader: Boolean = true
|
||||||
|
) {
|
||||||
|
if (includeHeader) {
|
||||||
|
settingsSectionHeader(Res.string.settings_section_logs)
|
||||||
|
}
|
||||||
|
settingsSectionCard(
|
||||||
|
items = listOf {
|
||||||
|
SettingCardItem(
|
||||||
|
title = stringResource(Res.string.settings_logs_title),
|
||||||
|
subtitle = stringResource(Res.string.settings_logs_subtitle),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
imageVector = Iconsax.IconsaxDocumentText,
|
||||||
|
contentDescription = stringResource(Res.string.settings_logs_title)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
navigatorCommands.navigateTo(Routes.Logs)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
assertEquals("Fallback", local.name)
|
assertEquals("Fallback", local.name)
|
||||||
assertEquals(listOf("Artist1", "Artist2"), local.artists)
|
assertEquals(listOf("Artist1", "Artist2"), local.artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun waitUntil(maxAttempts: Int = 100, condition: suspend () -> Boolean) {
|
||||||
|
var attempts = 0
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
if (condition()) return
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Thread.sleep(25)
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
assertTrue { condition() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- endless playback ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback appends recommendations when queue finishes`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback cascades to album tracks when recommendations empty`() = runTest {
|
||||||
|
val albumTrack = streamingTrack("album-track-1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
|
||||||
|
metadataAlbumAPI = FakeMetadataAlbumAPI(
|
||||||
|
albumTracks = mapOf("album-t1" to listOf(streamingTrack("t1"), albumTrack))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "album-track-1" }
|
||||||
|
}
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback cascades to artist top tracks when album tracks empty`() = runTest {
|
||||||
|
val artistTrack = streamingTrack("top-track-1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
|
||||||
|
metadataAlbumAPI = FakeMetadataAlbumAPI(albumTracks = emptyMap()),
|
||||||
|
metadataArtistAPI = FakeMetadataArtistAPI(
|
||||||
|
topTracks = mapOf("artist-t1" to listOf(streamingTrack("t1"), artistTrack))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "top-track-1" }
|
||||||
|
}
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback filters out blacklisted tracks`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
fakeBlacklist.toggleTrack(recTrack)
|
||||||
|
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { Thread.sleep(200) }
|
||||||
|
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertTrue { queueTracks.none { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback does not fetch when setting is disabled`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { Thread.sleep(200) }
|
||||||
|
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.size)
|
||||||
|
assertEquals("t1", (queueTracks[0] as? QueueEntry.StreamingTrack)?.track?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||||
import org.spotube.spotube.core.zipline.PluginService
|
import org.spotube.spotube.core.zipline.PluginService
|
||||||
import org.spotube.spotube.core.zipline.PluginServiceScope
|
import org.spotube.spotube.core.zipline.PluginServiceScope
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
|
import app.cash.zipline.ZiplineService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class FakeMetadataTrackAPI(
|
class FakeMetadataTrackAPI(
|
||||||
private val recommendations: List<MetadataTrack> = emptyList()
|
private val recommendations: List<MetadataTrack> = emptyList()
|
||||||
@ -32,8 +40,44 @@ class FakeMetadataTrackAPI(
|
|||||||
): List<MetadataTrack> = recommendations
|
): List<MetadataTrack> = recommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeMetadataAlbumAPI(
|
||||||
|
private val albumTracks: Map<String, List<MetadataTrack>> = emptyMap()
|
||||||
|
) : MetadataAlbumAPI {
|
||||||
|
override suspend fun getAlbum(id: String): MetadataAlbum.Detailed = error("not mocked")
|
||||||
|
override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed = error("not mocked")
|
||||||
|
override suspend fun getAlbumTracks(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataTrack> {
|
||||||
|
val tracks = albumTracks[id] ?: emptyList()
|
||||||
|
return PaginationResult(
|
||||||
|
items = tracks,
|
||||||
|
totalCount = tracks.size,
|
||||||
|
nextPagination = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
|
||||||
|
override suspend fun isSavedAlbums(ids: List<String>): List<Boolean> = error("not mocked")
|
||||||
|
override suspend fun saveAlbums(ids: List<String>) = error("not mocked")
|
||||||
|
override suspend fun removeSavedAlbums(ids: List<String>) = error("not mocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMetadataArtistAPI(
|
||||||
|
private val topTracks: Map<String, List<MetadataTrack>> = emptyMap()
|
||||||
|
) : MetadataArtistAPI {
|
||||||
|
override suspend fun getArtist(id: String): MetadataArtist.Detailed = error("not mocked")
|
||||||
|
override suspend fun artistOverview(id: String): MetadataArtistOverview = error("not mocked")
|
||||||
|
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> = topTracks[id] ?: emptyList()
|
||||||
|
override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Basic> = error("not mocked")
|
||||||
|
override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataPlaylist> = error("not mocked")
|
||||||
|
override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
|
||||||
|
override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Detailed> = error("not mocked")
|
||||||
|
override suspend fun isSavedArtists(ids: List<String>): List<Boolean> = error("not mocked")
|
||||||
|
override suspend fun saveArtists(ids: List<String>) = error("not mocked")
|
||||||
|
override suspend fun removeSavedArtists(ids: List<String>) = error("not mocked")
|
||||||
|
}
|
||||||
|
|
||||||
class FakePluginService(
|
class FakePluginService(
|
||||||
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
||||||
|
private val metadataAlbumAPI: MetadataAlbumAPI? = null,
|
||||||
|
private val metadataArtistAPI: MetadataArtistAPI? = null,
|
||||||
override val pluginId: String = "fake-plugin") : PluginService {
|
override val pluginId: String = "fake-plugin") : PluginService {
|
||||||
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
@ -41,9 +85,12 @@ class FakePluginService(
|
|||||||
override suspend fun stop() = Unit
|
override suspend fun stop() = Unit
|
||||||
|
|
||||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||||
val scope = PluginServiceScope(
|
val registry = buildMap<KClass<*>, ZiplineService> {
|
||||||
mapOf(MetadataTrackAPI::class to metadataTrackAPI)
|
put(MetadataTrackAPI::class, metadataTrackAPI)
|
||||||
)
|
metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) }
|
||||||
|
metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) }
|
||||||
|
}
|
||||||
|
val scope = PluginServiceScope(registry)
|
||||||
return scope.block()
|
return scope.block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -455,6 +455,23 @@ class StreamingUrlRepositoryBehaviorTest {
|
|||||||
assertEquals("https://example.com/fresh.opus", info?.url)
|
assertEquals("https://example.com/fresh.opus", info?.url)
|
||||||
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
|
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
|
||||||
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
||||||
|
assertEquals(1, h.matchedTracksRepository.deleteCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deletes cached track source when cached source fails to provide a stream`() = runTest {
|
||||||
|
val h = Harness()
|
||||||
|
val t = track("t1")
|
||||||
|
val plugin = h.selectPlugin("pluginA")
|
||||||
|
h.matchedTracksRepository.seed(t, basicSource("stale-1"), pluginId = "pluginA")
|
||||||
|
plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() }
|
||||||
|
plugin.audioAPI.onGetStreamsByTrack = { emptyList() }
|
||||||
|
|
||||||
|
val info = h.repository.resolveStreamInfo(t)
|
||||||
|
|
||||||
|
assertNull(info)
|
||||||
|
assertEquals(1, h.matchedTracksRepository.deleteCallCount)
|
||||||
|
assertNull(h.matchedTracksRepository.rawEntryFor(t.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository {
|
|||||||
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
|
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
|
||||||
var getCallCount = 0
|
var getCallCount = 0
|
||||||
var saveCallCount = 0
|
var saveCallCount = 0
|
||||||
|
var deleteCallCount = 0
|
||||||
|
|
||||||
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
|
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
|
||||||
getCallCount++
|
getCallCount++
|
||||||
@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository {
|
|||||||
store[track.id] = PluginTaggedAudioSource(pluginId, source)
|
store[track.id] = PluginTaggedAudioSource(pluginId, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) {
|
||||||
|
deleteCallCount++
|
||||||
|
val tagged = store[track.id] ?: return
|
||||||
|
if (tagged.pluginId == pluginId) {
|
||||||
|
store.remove(track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
|
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
|
||||||
|
|
||||||
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
||||||
|
|||||||
@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
private val avPlayer: AVPlayer = AVPlayer()
|
private val avPlayer: AVPlayer = AVPlayer()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val mediaSession = IosMediaSession()
|
||||||
|
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
mediaSession.configure(
|
||||||
|
onPlay = { scope.launch { play() } },
|
||||||
|
onPause = { scope.launch { pause() } },
|
||||||
|
onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } },
|
||||||
|
onNext = { scope.launch { skipToNext() } },
|
||||||
|
onPrevious = { scope.launch { skipToPrevious() } })
|
||||||
|
|
||||||
avPlayer.addPeriodicTimeObserverForInterval(
|
avPlayer.addPeriodicTimeObserverForInterval(
|
||||||
interval = CMTimeMake(1, 4),
|
interval = CMTimeMake(1, 4),
|
||||||
queue = null
|
queue = null
|
||||||
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
// val range = loadedRanges.objectAtIndex(0u)
|
// val range = loadedRanges.objectAtIndex(0u)
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
mediaSession.update(
|
||||||
|
item = _currentMediaItem.value,
|
||||||
|
positionSeconds = seconds,
|
||||||
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,6 +155,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
if (avPlayer.currentItem == null) {
|
if (avPlayer.currentItem == null) {
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaSession.update(
|
||||||
|
item = _currentMediaItem.value,
|
||||||
|
positionSeconds = _position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = status == AVPlayerTimeControlStatusPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveState(): PlayerState {
|
private fun resolveState(): PlayerState {
|
||||||
@ -159,17 +178,29 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun play() {
|
actual override suspend fun play() {
|
||||||
|
mediaSession.activate()
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
|
mediaSession.update(
|
||||||
|
item = _currentMediaItem.value,
|
||||||
|
positionSeconds = _position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.update(
|
||||||
|
item = _currentMediaItem.value,
|
||||||
|
positionSeconds = 0.0,
|
||||||
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
@ -220,9 +251,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(item)
|
_currentMediaItem.tryEmit(item)
|
||||||
|
mediaSession.update(
|
||||||
|
item = item,
|
||||||
|
positionSeconds = 0.0,
|
||||||
|
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = false)
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
if (avItem != null) {
|
if (avItem != null) {
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(nextItem)
|
_currentMediaItem.tryEmit(nextItem)
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,7 +329,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
if (avItem != null) {
|
if (avItem != null) {
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(prevItem)
|
_currentMediaItem.tryEmit(prevItem)
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -327,6 +363,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
disposed = true
|
disposed = true
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.clear()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,201 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import platform.AVFAudio.AVAudioSession
|
||||||
|
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||||
|
import platform.AVFAudio.AVAudioSessionModeDefault
|
||||||
|
import platform.Foundation.NSNotificationCenter
|
||||||
|
import platform.Foundation.NSOperationQueue
|
||||||
|
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyArtist
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyTitle
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoCenter
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
|
||||||
|
import platform.MediaPlayer.MPRemoteCommand
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandCenter
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
|
||||||
|
|
||||||
|
internal class IosMediaSession {
|
||||||
|
private val audioSession = AVAudioSession.sharedInstance()
|
||||||
|
private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter()
|
||||||
|
private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter()
|
||||||
|
|
||||||
|
private var configured = false
|
||||||
|
private val notificationCenter = NSNotificationCenter.defaultCenter()
|
||||||
|
private val notificationTokens = mutableListOf<Any>()
|
||||||
|
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
|
||||||
|
private var onPlay: (() -> Unit)? = null
|
||||||
|
private var onPause: (() -> Unit)? = null
|
||||||
|
private var onSeek: ((Double) -> Unit)? = null
|
||||||
|
private var onNext: (() -> Unit)? = null
|
||||||
|
private var onPrevious: (() -> Unit)? = null
|
||||||
|
|
||||||
|
private var lastIsPlaying = false
|
||||||
|
|
||||||
|
fun configure(
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onSeek: (Double) -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onPrevious: () -> Unit
|
||||||
|
) {
|
||||||
|
this.onPlay = onPlay
|
||||||
|
this.onPause = onPause
|
||||||
|
this.onSeek = onSeek
|
||||||
|
this.onNext = onNext
|
||||||
|
this.onPrevious = onPrevious
|
||||||
|
|
||||||
|
if (configured) return
|
||||||
|
configured = true
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
audioSession.setCategory(
|
||||||
|
category = AVAudioSessionCategoryPlayback,
|
||||||
|
mode = AVAudioSessionModeDefault,
|
||||||
|
options = 0u,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
registerAudioSessionNotifications()
|
||||||
|
registerRemoteCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activate() {
|
||||||
|
// AVPlayer automatically activates the shared audio session on play.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deactivate() {
|
||||||
|
// AVPlayer automatically manages session deactivation when paused or stopped.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
item: MediaItem?,
|
||||||
|
positionSeconds: Double,
|
||||||
|
durationSeconds: Double,
|
||||||
|
isPlaying: Boolean
|
||||||
|
) {
|
||||||
|
if (item == null) {
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = mutableMapOf<Any?, Any?>()
|
||||||
|
info[MPMediaItemPropertyTitle] = item.title
|
||||||
|
info[MPMediaItemPropertyArtist] = item.artist
|
||||||
|
item.album.takeIf { it.isNotBlank() }?.let {
|
||||||
|
info[MPMediaItemPropertyAlbumTitle] = it
|
||||||
|
}
|
||||||
|
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
||||||
|
info[MPMediaItemPropertyPlaybackDuration] = durationSeconds
|
||||||
|
}
|
||||||
|
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = positionSeconds
|
||||||
|
}
|
||||||
|
info[MPNowPlayingInfoPropertyPlaybackRate] = if (isPlaying) 1.0 else 0.0
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
lastIsPlaying = false
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = null
|
||||||
|
notificationTokens.forEach { notificationCenter.removeObserver(it) }
|
||||||
|
notificationTokens.clear()
|
||||||
|
remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) }
|
||||||
|
remoteCommandTokens.clear()
|
||||||
|
configured = false
|
||||||
|
onPlay = null
|
||||||
|
onPause = null
|
||||||
|
onSeek = null
|
||||||
|
onNext = null
|
||||||
|
onPrevious = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerAudioSessionNotifications() {
|
||||||
|
notificationTokens += notificationCenter.addObserverForName(
|
||||||
|
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
|
||||||
|
`object` = audioSession,
|
||||||
|
queue = NSOperationQueue.mainQueue()
|
||||||
|
) { notification ->
|
||||||
|
val type = (notification?.userInfo?.get(
|
||||||
|
platform.AVFAudio.AVAudioSessionInterruptionTypeKey
|
||||||
|
) as? Number)?.toLong()?.toULong()
|
||||||
|
if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
||||||
|
onPause?.invoke()
|
||||||
|
} else if (type == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
||||||
|
activate()
|
||||||
|
val options = (notification?.userInfo?.get(
|
||||||
|
platform.AVFAudio.AVAudioSessionInterruptionOptionKey
|
||||||
|
) as? Number)?.toLong()?.toULong() ?: 0uL
|
||||||
|
val shouldResume = (options and
|
||||||
|
platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume) != 0uL
|
||||||
|
if (shouldResume) onPlay?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerRemoteCommands() {
|
||||||
|
remoteCommandTokens += remoteCommandCenter.playCommand to
|
||||||
|
remoteCommandCenter.playCommand.addTargetWithHandler {
|
||||||
|
onPlay?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
remoteCommandTokens += remoteCommandCenter.pauseCommand to
|
||||||
|
remoteCommandCenter.pauseCommand.addTargetWithHandler {
|
||||||
|
onPause?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
remoteCommandTokens += remoteCommandCenter.togglePlayPauseCommand to
|
||||||
|
remoteCommandCenter.togglePlayPauseCommand.addTargetWithHandler {
|
||||||
|
if (lastIsPlaying) {
|
||||||
|
onPause?.invoke()
|
||||||
|
} else {
|
||||||
|
onPlay?.invoke()
|
||||||
|
}
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to
|
||||||
|
remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event ->
|
||||||
|
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
|
||||||
|
if (position != null && position.isFinite() && position >= 0) {
|
||||||
|
onSeek?.invoke(position)
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
} else {
|
||||||
|
MPRemoteCommandHandlerStatusCommandFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remoteCommandTokens += remoteCommandCenter.nextTrackCommand to
|
||||||
|
remoteCommandCenter.nextTrackCommand.addTargetWithHandler {
|
||||||
|
onNext?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
remoteCommandTokens += remoteCommandCenter.previousTrackCommand to
|
||||||
|
remoteCommandCenter.previousTrackCommand.addTargetWithHandler {
|
||||||
|
onPrevious?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -86,4 +86,8 @@ actual class NewPipeService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun getRadioMix(videoId: String): List<VideoSearchResult> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -75,7 +75,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
|||||||
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
||||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
||||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
|
||||||
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=600000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user