This commit is contained in:
Peter Brian Gwadenya 2026-10-30 11:36:01 +03:00 committed by GitHub
commit 2832d7829d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1882 additions and 117 deletions

118
.github/workflows/build-android-apk.yml vendored Normal file
View 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

View 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/*

View File

@ -13,7 +13,7 @@ on:
- beta
- nightly
platform:
description: 'Platform to build (nightly only; stable and beta always build everything)'
description: 'Platform to build'
required: true
type: choice
default: 'all'
@ -36,9 +36,8 @@ on:
type: boolean
default: false
# Read-only by default; only create-release gets write access
permissions:
contents: read
contents: write
# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side
concurrency:
@ -216,25 +215,28 @@ jobs:
- name: Make gradlew executable
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
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: |
cat >> local.properties <<EOF
release.signing.storeFile=${{ github.workspace }}/composeApp/upload-keystore.jks
release.signing.storePassword=${KEYSTORE_PASSWORD}
release.signing.keyAlias=${KEYSTORE_KEY_ALIAS}
release.signing.keyPassword=${KEYSTORE_KEY_PASSWORD}
EOF
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 Release
run: |
@ -754,10 +756,23 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
continue-on-error: true
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
if: ${{ inputs.platform == 'all' }}
if: steps.check_artifacts.outputs.has_artifacts == 'true' && inputs.platform == 'all'
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
@ -765,23 +780,24 @@ jobs:
if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md
for f in "$dir"/*; do
echo "- \`$(basename "$f")\`" >> release-notes.md
[ -f "$f" ] && echo "- \`$(basename "$f")\`" >> release-notes.md
done
echo "" >> release-notes.md
fi
done
- name: Create Release
uses: softprops/action-gh-release@v3
- name: Create or Update Release
if: steps.check_artifacts.outputs.has_artifacts == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.vars.outputs.tag }}
# A new tag (stable) would otherwise point at the default branch instead of the built commit
target_commitish: ${{ github.sha }}
# 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),
# 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' || '' }}
draft: ${{ steps.vars.outputs.is_draft }}
prerelease: ${{ steps.vars.outputs.is_prerelease }}

View File

@ -1,5 +1,25 @@
# 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)
### Features

View File

@ -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).
### 🧩 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
<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>

View File

@ -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()
}
}
}
/**

View File

@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer
import android.content.Context
import android.content.Intent
import android.util.Log
import co.touchlab.kermit.Logger
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual val context: Any = context
private val appContext: Context = (context as Context).applicationContext
private val kermitLogger = Logger.withTag("AudioPlayer")
private fun ensureServiceStarted() {
// 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) {
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)
}

View File

@ -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.track.MetadataTrack
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.playback.CollectionPlaybackHelper
import org.spotube.spotube.modules.album.AlbumRepository
@ -662,9 +664,12 @@ class MediaBrowseHelper(
}
private suspend fun buildStreamingUrl(trackId: String): String? {
val baseUrl = localServer?.awaitBaseUrl() ?: run {
val settings = settingsRepository.userSettings.first()
val port = settings.playbackProxyServerPort
if (port == 0) return null
return "http://127.0.0.1:$port/stream/$trackId"
"http://127.0.0.1:$port"
}
return "${baseUrl.trimEnd('/')}/stream/$trackId"
}
}

View File

@ -112,6 +112,17 @@
<string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</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_save">Save</string>

View File

@ -18,7 +18,13 @@
package org.spotube.spotube.core.audioplayer
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.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.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider
@ -37,6 +43,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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.time.Duration.Companion.milliseconds
@ -48,6 +58,7 @@ class DeviceAudioPlayerQueue(
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>()
private val streamingUrlRepository by inject<StreamingUrlRepository>()
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 {
audioPlayer.currentMediaItemFlow.collect {
logger.d { "Current media item changed, persisting queue state" }
@ -127,14 +144,15 @@ class DeviceAudioPlayerQueue(
startPosition: Int,
collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) {
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (paired.isEmpty()) {
logger.w { "load: no valid media items after filtering blanks" }
}
val mediaItems = paired.map { it.first }
val normalizedStartPosition =
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
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)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady()
@ -160,16 +178,14 @@ class DeviceAudioPlayerQueue(
}
logger.i { "Adding ${entries.size} entries to queue" }
val mediaItems = entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) {
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (paired.isEmpty()) {
logger.w { "addAllToQueue: all entries had blank urls, skipping" }
return
}
val validEntries = entries.zip(mediaItems).map { (entry) ->
entry
}
entryByMediaUrl.update {
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry }
val mediaItems = paired.map { it.first }
entryByMediaUrl.update { current ->
current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
}
updateCollectionContext(collectionEntry)
@ -189,14 +205,14 @@ class DeviceAudioPlayerQueue(
}
logger.i { "Adding ${entries.size} entries after current position" }
val mediaItems = entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) {
val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (paired.isEmpty()) {
logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" }
return
}
val validEntries = entries.zip(mediaItems).map { (entry) -> entry }
entryByMediaUrl.update {
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry }
val mediaItems = paired.map { it.first }
entryByMediaUrl.update { current ->
current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
}
mediaItems.forEach { mediaItem ->
@ -208,7 +224,9 @@ class DeviceAudioPlayerQueue(
}
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)
}
@ -313,7 +331,7 @@ class DeviceAudioPlayerQueue(
val settings = settingsProvider.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return
val queue = queueFlow.value
val queue = getQueue()
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
if (streamingTracks.isEmpty()) return
@ -330,19 +348,111 @@ class DeviceAudioPlayerQueue(
isFetchingRecommendations = true
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
val metadataService = pluginProvider.selectedMetadataPlugin.value ?: run {
logger.w { "Endless playback: no metadata plugin available" }
isFetchingRecommendations = false
return
}
val metadataService = pluginProvider.selectedMetadataPlugin.value
var recommendations: List<MetadataTrack> = emptyList()
try {
val recommendations = metadataService.use {
if (metadataService != null) {
recommendations = runCatching {
metadataService.use {
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
}
}.getOrDefault(emptyList())
}
if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations returned" }
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 {
if (recommendations.isEmpty()) {
logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" }
isFetchingRecommendations = false
return
}
@ -369,7 +479,7 @@ class DeviceAudioPlayerQueue(
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
addAllToQueue(newEntries)
} catch (e: Exception) {
logger.e(e) { "Endless playback: failed to fetch recommendations" }
logger.e(e) { "Endless playback: failed to add recommendations to queue" }
} finally {
isFetchingRecommendations = false
}
@ -393,12 +503,13 @@ class DeviceAudioPlayerQueue(
return
}
val mediaItems = state.entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) {
val paired = state.entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (paired.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" }
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)
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
@ -456,7 +567,25 @@ class DeviceAudioPlayerQueue(
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
return when (this) {
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
MediaItem(
title = track.title,
@ -465,7 +594,7 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds,
coverURL = artworkUrl.orEmpty(),
url = resolvedUrl,
protocol = protocol)
protocol = resolvedStream?.protocol ?: protocol)
}
is QueueEntry.LocalTrack -> {
@ -492,10 +621,15 @@ class DeviceAudioPlayerQueue(
url = url)
}
private val localServerOrNull: LocalServer?
get() = runCatching { getKoin().getOrNull<LocalServer>() }.getOrNull()
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run {
val port: Int =
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
val baseUrl = "http://127.0.0.1:$port"
"http://127.0.0.1:$port"
}
return when (protocol) {
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"

View File

@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.koin.KermitKoinLogger
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.spotube.spotube.core.logger.AppLogWriter
fun initKoin(configuration: KoinAppDeclaration? = null) {
Logger.addLogWriter(AppLogWriter())
startKoin {
configuration?.invoke(this)
logger(

View File

@ -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
)
}
}

View File

@ -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.search.SearchScreen
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.welcome.WelcomeScreen
import kotlinx.serialization.Serializable
@ -101,6 +102,9 @@ sealed interface Routes : NavKey {
@Serializable
data class LocalFolder(val folderPath: String) : Routes
@Serializable
data object Logs : Routes
}
@OptIn(KoinExperimentalAPI::class)
@ -208,4 +212,7 @@ val navigationModule = module {
navigation<Routes.Welcome> {
WelcomeScreen(viewModel = koinViewModel())
}
navigation<Routes.Logs> {
LogsScreen()
}
}

View File

@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
subclass(Routes.Logs::class, Routes.Logs.serializer())
}
}
}

View File

@ -28,4 +28,5 @@ enum class SearchKind {
expect class NewPipeService() {
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
suspend fun getVideoInfo(id: String): VideoInfo
suspend fun getRadioMix(videoId: String): List<VideoSearchResult>
}

View File

@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
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.launch
import kotlinx.coroutines.sync.Mutex
@ -88,6 +93,19 @@ class LocalServer(
port?.let { "http://$HOST_LOCAL:$it" }
}.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 streamProxy by lazy {

View File

@ -43,6 +43,7 @@ data class PluginTaggedAudioSource(
interface TrackSourceRepository {
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String)
}
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos
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)
}
}
}
}

View File

@ -34,6 +34,7 @@ import io.ktor.http.HeadersBuilder
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.content.OutgoingContent
import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter
@ -72,6 +73,15 @@ internal class StreamProxy(
companion object {
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
@ -233,8 +243,7 @@ internal class StreamProxy(
val response = runCatching {
httpClient.request(manifestUrl) {
method = HttpMethod.Get
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
applyUpstreamHeaders(call, this, manifestUrl, includeRange = false)
}
}.getOrElse { throwable ->
logger.w(throwable) { "Failed to fetch manifest for track $trackId" }
@ -284,9 +293,7 @@ internal class StreamProxy(
runCatching {
httpClient.prepareRequest(segmentUrl) {
method = HttpMethod.Get
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
applyUpstreamHeaders(call, this, segmentUrl, includeRange = true)
}.execute { response -> respondWithSegment(call, response, trackId) }
}.onFailure { throwable ->
if (throwable is CancellationException) throw throwable
@ -343,10 +350,7 @@ internal class StreamProxy(
val failure = runCatching {
httpClient.prepareRequest(currentUrl) {
method = requestMethod
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
applyUpstreamHeaders(call, this, currentUrl, includeRange = true)
}.execute { response ->
logger.i {
"[Req] upstream headers for $trackId after ${upstreamStart.elapsedNow()}: " +
@ -578,20 +582,84 @@ internal class StreamProxy(
private fun encodeUrl(url: String): String = url.encodeURLParameter()
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(
builder: HeadersBuilder,
headerName: String
) {
request.headers.getAll(headerName)?.forEach { value ->
builder.append(headerName, value)
builder[headerName] = value
}
}
private fun upstreamHeadersToForward(upstream: HttpResponse): Headers {
return HeadersBuilder().apply {
upstream.headers.forEach { name, values ->
if (!name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) &&
!name.equals(HttpHeaders.Connection, ignoreCase = true)
) {
values.forEach { value -> append(name, value) }
}
}
}.build()
}

View File

@ -187,6 +187,7 @@ class StreamingUrlRepository(
}
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" }

View File

@ -55,29 +55,47 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
var confidence = 0f
val videoTitle = normalize(video.title)
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true)
val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), ""))
val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) ||
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 title = coreTitle(track.title)
val videoTokens = videoTitle.split(' ').toSet()
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 isAlbumInTitle = track.album?.title?.let { normalize(it) }
?.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
val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE
// Normalize whitespace and punctuation for uploader comparison (e.g. "Taylor Swift" vs "TaylorSwiftVEVO")
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 (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
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 (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)
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> {
val isYouTubeUrl =
track.externalUri != null && (track.externalUri!!.contains("youtube.com") || track.externalUri!!.contains(
"youtu.be"
))
val isYouTubeID = track.id.matches(youtubeIDRegex)
val resolvedVideoId = if (track.id.matches(youtubeIDRegex)) {
track.id
} else {
extractYouTubeVideoId(track.externalUri)
}
logger.i {
"Searching streams for track(${track.id}): ${track.title} by ${
@ -136,9 +171,9 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
) { it.name }
}"
}
if (isYouTubeID || isYouTubeUrl) {
logger.i { "Track has YouTube ID or URL, fetching video info directly" }
return newPipeService.getVideoInfo(track.id).let { videoInfo ->
if (resolvedVideoId != null) {
logger.i { "Track has YouTube ID or URL ($resolvedVideoId), fetching video info directly" }
return newPipeService.getVideoInfo(resolvedVideoId).let { videoInfo ->
listOf(
AudioSource.Streamed(
id = videoInfo.id,
@ -228,19 +263,34 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
if (matched.isNotEmpty()) return matched.take(5)
// Nothing scored well. Playing YouTube's best text-search guess beats failing playback,
// and ISRC results are skipped because an ISRC text search tends to return unrelated songs.
val fallback = textSearchResults.values.toList()
.sortedByDescending { it.confidence } // stable: ties keep YouTube's relevance order
.take(5)
.ifEmpty { ranked.take(5) }
// When confidence scoring yields no candidates >= 0.3, ensure the top relevance search
// result is returned as fallback rather than throwing TrackNotFoundError or failing.
val fallback = if (textSearchResults.isNotEmpty()) {
val list = textSearchResults.values.toList()
val topRelevance = list.first()
val rest = list.drop(1).sortedByDescending { it.confidence }
(listOf(topRelevance) + rest).take(5)
} else if (ranked.isNotEmpty()) {
ranked.take(5)
} else {
runCatching {
newPipeService.searchVideos(plainTitle.ifEmpty { track.title }, SearchKind.Videos)
.map { transformVideo(track, it) }
}.getOrDefault(emptyList()).take(5)
}
if (fallback.isNotEmpty()) {
logger.w {
"[Search] no confident match for '${track.title}'; falling back to best guesses: " +
"[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> {
return newPipeService.getVideoInfo(source.id).let { videoInfo ->
listOf(

View File

@ -34,8 +34,10 @@ import io.ktor.client.request.headers
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.discard
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -95,6 +97,15 @@ class DownloadManager(
const val MAX_CONCURRENT_DOWNLOADS = 4
private const val SEGMENT_COUNT = 4
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")
@ -362,6 +373,7 @@ class DownloadManager(
headers {
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
}
applyClientHeaders(url)
}
logger.d {
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
@ -398,7 +410,9 @@ class DownloadManager(
downloadDir: Path) {
val tempFile = tempDir / "${item.id}.tmp"
val response = httpClient.get(url)
val response = httpClient.get(url) {
applyClientHeaders(url)
}
val totalSize = response.contentLength() ?: 0L
logger.i {
"[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> {
return try {
val response = httpClient.head(url)
val contentLength = response.contentLength() ?: 0L
val response = httpClient.get(url) {
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 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]
logger.i {
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
"length=$contentLength acceptRanges=$acceptRanges"
"[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " +
"length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges"
}
runCatching { response.bodyAsChannel().discard() }
rejectVideo(id, contentType)
Pair(contentLength, supportsRanges)
} catch (e: DownloadException) {
throw e
} 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)
}
}
@ -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) {
val settings = settingsRepository.userSettings.value
val wanted = settings.downloadMusicFormat

View File

@ -72,7 +72,7 @@ class GitHubPluginRepository(private val httpClient: HttpClient) {
append("Accept", "application/vnd.github+json")
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("order", "desc")
parameter("page", page)

View File

@ -579,6 +579,11 @@ class PluginManager(
val updatedSelectedPlugins =
currentState.selectedPlugins.mapValues { (_, 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(
plugins = updatedPlugins,
@ -599,12 +604,21 @@ class PluginManager(
if (currentState is PluginManagerStates.Data) {
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
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(
plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins,
generation = currentState.generation
generation = currentState.generation + 1
)
logger.d { "removePlugin: bumped generation to ${newState.generation} after removing ${plugin.id}" }
withContext(Dispatchers.IO) {
val pluginDir = pluginsDir / plugin.id.toPath()

View File

@ -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 VERIFIED_PLUGIN_OWNERS = setOf<String>()
private val VERIFIED_PLUGIN_OWNERS = setOf("pgwiz", "sonic-liberation")
@Composable
fun PluginScreen(

View File

@ -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.pluginsSection
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.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
@ -161,6 +162,8 @@ private fun CompactSettingsContent(
updatesSection(
settings = settings,
settingsViewModel = settingsViewModel)
logsSection(
navigatorCommands = navigatorCommands)
}
}
}

View File

@ -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_screen_title
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_caching
import spotube.composeapp.generated.resources.settings_section_desktop
@ -129,6 +131,7 @@ internal enum class SettingsTab(
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
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())
}
SettingsTab.Logs -> {
LogsContent(modifier = Modifier.fillMaxSize())
}
else -> {
if (settings == null) {
Box(

View File

@ -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)
)
}
}
}
}
}

View File

@ -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)
}
)
}
)
}

View File

@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest {
assertEquals("Fallback", local.name)
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)
}
}

View File

@ -1,14 +1,22 @@
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.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.MetadataTrackAPI
import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.PluginServiceScope
import org.spotube.spotube.modules.plugin.PluginProvider
import app.cash.zipline.ZiplineService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.reflect.KClass
class FakeMetadataTrackAPI(
private val recommendations: List<MetadataTrack> = emptyList()
@ -32,8 +40,44 @@ class FakeMetadataTrackAPI(
): 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(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
private val metadataAlbumAPI: MetadataAlbumAPI? = null,
private val metadataArtistAPI: MetadataArtistAPI? = null,
override val pluginId: String = "fake-plugin") : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
@ -41,9 +85,12 @@ class FakePluginService(
override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope(
mapOf(MetadataTrackAPI::class to metadataTrackAPI)
)
val registry = buildMap<KClass<*>, ZiplineService> {
put(MetadataTrackAPI::class, metadataTrackAPI)
metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) }
metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) }
}
val scope = PluginServiceScope(registry)
return scope.block()
}
}

View File

@ -455,6 +455,23 @@ class StreamingUrlRepositoryBehaviorTest {
assertEquals("https://example.com/fresh.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
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

View File

@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository {
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
var getCallCount = 0
var saveCallCount = 0
var deleteCallCount = 0
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
getCallCount++
@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository {
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 seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {

View File

@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val avPlayer: AVPlayer = AVPlayer()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mediaSession = IosMediaSession()
private var disposed = false
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
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(
interval = CMTimeMake(1, 4),
queue = null
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
// 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) {
_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 {
@ -159,17 +178,29 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
}
actual override suspend fun play() {
mediaSession.activate()
avPlayer.play()
}
actual override suspend fun 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() {
avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1))
_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) {
@ -220,9 +251,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
mediaSession.update(
item = item,
positionSeconds = 0.0,
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
isPlaying = false)
if (autoPlay) {
avPlayer.play()
play()
}
}
@ -277,7 +313,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(nextItem)
avPlayer.play()
play()
}
}
}
@ -293,7 +329,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(prevItem)
avPlayer.play()
play()
}
}
}
@ -327,6 +363,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
disposed = true
avPlayer.pause()
avPlayer.replaceCurrentItemWithPlayerItem(null)
mediaSession.deactivate()
mediaSession.clear()
currentPlaylist.clear()
urlIndexMap.clear()

View File

@ -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
}
}
}

View File

@ -86,4 +86,8 @@ actual class NewPipeService {
)
}
}
actual suspend fun getRadioMix(videoId: String): List<VideoSearchResult> {
return emptyList()
}
}

View File

@ -75,7 +75,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
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" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }

View File

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip
networkTimeout=10000
networkTimeout=600000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -4,6 +4,10 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>