From 2a2ca8c5a69cdd94c280c6eadc8ebbcbae4123e4 Mon Sep 17 00:00:00 2001 From: pgwiz Date: Wed, 28 Oct 2026 07:36:57 +0300 Subject: [PATCH] feat: port upstream PRs #3132, #3170, #3150, #3092, #3134, #3147 Co-Authored-By: pgwiz --- CHANGELOG.md | 10 + .../audioplayer/DeviceAudioPlayerQueue.kt | 68 ++++++- .../core/server/MatchTracksRepository.kt | 16 ++ .../spotube/core/server/StreamProxy.kt | 34 +++- .../core/server/StreamingUrlRepository.kt | 1 + .../modules/downloads/DownloadManager.kt | 77 +++++++- .../spotube/modules/plugin/PluginManager.kt | 15 +- .../StreamingUrlRepositoryBehaviorTest.kt | 17 ++ .../server/StreamingUrlRepositoryFakes.kt | 9 + .../core/audioplayer/AudioPlayer.ios.kt | 33 +++- .../core/audioplayer/IosMediaSession.kt | 184 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- iosApp/iosApp/Info.plist | 4 + 13 files changed, 452 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 266ae3ba..6c261e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ ## [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 `Origin` and `Referer` on mobile streams (PR #3150). +- **downloads/DownloadManager**: Prevent HTTP 403 Forbidden on YouTube audio downloads by injecting matching client User-Agents and using `Range: bytes=0-0` byte-range probes instead of bare `HEAD` requests (PR #3150). +- **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). - **server/StreamProxy**: Prevent GoogleVideo CDN HTTP 403 Forbidden by injecting `Referer: https://www.youtube.com/`, `Origin: https://www.youtube.com`, and browser `User-Agent` headers on `googlevideo.com`, `youtube.com`, and `youtu.be` proxy requests, preserving stream range headers (`Range`, `If-Range`) without header duplication, and filtering hop-by-hop headers. - **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$. diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index df28649c..268b5aab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -42,6 +42,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 @@ -53,6 +57,7 @@ class DeviceAudioPlayerQueue( private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() + private val streamingUrlRepository by inject() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -401,9 +406,48 @@ class DeviceAudioPlayerQueue( } } + 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().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().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 or radio mix tracks found" } + logger.w { "Endless playback: no recommendations, radio mix, album tracks, or artist top tracks found" } isFetchingRecommendations = false return } @@ -517,7 +561,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, @@ -526,7 +588,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol) + protocol = resolvedStream?.protocol ?: protocol) } is QueueEntry.LocalTrack -> { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt index a93bb012..f4baff0f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/MatchTracksRepository.kt @@ -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(json) + } catch (_: Exception) { + null + } + if (tagged == null || tagged.pluginId == pluginId) { + prefs.remove(key) + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 73671788..b510fcbc 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -76,6 +76,12 @@ internal class StreamProxy( 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 @@ -599,9 +605,31 @@ internal class StreamProxy( call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept) if (isYouTubeOrGoogleVideo(targetUrl)) { - builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER - builder.headers[HttpHeaders.Origin] = "https://www.youtube.com" - builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT + 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(HttpHeaders.Origin) + } + "IOS" -> { + builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove(HttpHeaders.Origin) + } + "VISIONOS" -> { + builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + builder.headers.remove(HttpHeaders.Referrer) + builder.headers.remove(HttpHeaders.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) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 19692bf6..5b327533 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -186,6 +186,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" } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 2cbd6775..a5f71c2f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -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,30 @@ class DownloadManager( private suspend fun probeUrl(id: String, url: String): Pair { 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) + } + 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 +495,46 @@ 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(HttpHeaders.Origin) + } + "IOS" -> { + headers[HttpHeaders.UserAgent] = IOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove(HttpHeaders.Origin) + } + "VISIONOS" -> { + headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT + headers.remove(HttpHeaders.Referrer) + headers.remove(HttpHeaders.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 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 13d1f380..49b9b4ab 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -579,7 +579,12 @@ 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, selectedPlugins = updatedSelectedPlugins, @@ -599,7 +604,15 @@ 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, diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index f297b17a..0e104019 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -447,6 +447,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 diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt index d8f1af5f..79570f4d 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository { private val store = mutableMapOf() 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) { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 3c99747b..2b5d4c4c 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -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) } } @@ -159,6 +172,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } actual override suspend fun play() { + mediaSession.activate() avPlayer.play() } @@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface 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 +240,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 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(nextItem) - avPlayer.play() + play() } } } @@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface if (avItem != null) { avPlayer.replaceCurrentItemWithPlayerItem(avItem) _currentMediaItem.tryEmit(prevItem) - avPlayer.play() + play() } } } @@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true avPlayer.pause() avPlayer.replaceCurrentItemWithPlayerItem(null) + mediaSession.deactivate() + mediaSession.clear() currentPlaylist.clear() urlIndexMap.clear() diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt new file mode 100644 index 00000000..18b021fb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -0,0 +1,184 @@ +/* + * 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 . + */ + +@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.NSDictionary +import platform.Foundation.NSMutableDictionary +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() + private val remoteCommandTokens = mutableListOf>() + 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 + + 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 + + audioSession.setCategory( + category = AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null) + registerAudioSessionNotifications() + registerRemoteCommands() + } + + fun activate() { + audioSession.setActive(true, error = null) + } + + fun deactivate() { + audioSession.setActive(false, error = null) + } + + fun update( + item: MediaItem?, + positionSeconds: Double, + durationSeconds: Double, + isPlaying: Boolean + ) { + if (item == null) { + nowPlayingInfoCenter.nowPlayingInfo = null + return + } + + val info = NSMutableDictionary() + info.setObject(item.title, forKey = MPMediaItemPropertyTitle) + info.setObject(item.artist, forKey = MPMediaItemPropertyArtist) + item.album.takeIf { it.isNotBlank() }?.let { + info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle) + } + if (durationSeconds.isFinite() && durationSeconds > 0) { + info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration) + } + if (positionSeconds.isFinite() && positionSeconds >= 0) { + info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) + } + info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) + nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary + } + + fun clear() { + 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 + if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { + onPause?.invoke() + } else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { + activate() + val options = notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number + val shouldResume = (options?.intValue ?: 0) and + platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0 + 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.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 + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838df455..278b5a12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,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" } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5663e50d..4a484863 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,10 @@ CADisableMinimumFrameDurationOnPhone + UIBackgroundModes + + audio + CFBundleURLTypes