diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c261e85..7c17cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,15 @@ ### 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/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). -- **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$. - **newpipe/NewPipeService**: Implement native YouTube radio mix extractor (`RD{videoId}`) across commonMain, androidJvmMain, and iosMain platforms with safe thumbnail fallbacks. -- **audioplayer/DeviceAudioPlayerQueue**: Add native YouTube radio mix fallback for endless playback queue continuation when metadata recommendations fail or are unauthenticated, supporting complete YouTube URL patterns. ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26) 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 268b5aab..4c987325 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 @@ -137,14 +137,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() @@ -170,16 +171,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) @@ -199,14 +198,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 -> @@ -218,7 +217,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) } @@ -323,7 +324,7 @@ class DeviceAudioPlayerQueue( val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return - val queue = queueFlow.value + val queue = getQueue() val streamingTracks = queue.filterIsInstance() if (streamingTracks.isEmpty()) return @@ -359,23 +360,20 @@ class DeviceAudioPlayerQueue( val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { seed.id } else { - val extracted = seed.externalUri?.let { uri -> + 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) } - extracted ?: runCatching { - val artist = seed.artists.firstOrNull()?.name.orEmpty() - newPipeService.searchVideos("${seed.title} $artist".trim(), SearchKind.Songs) - .firstOrNull()?.id - }.getOrNull() } if (!videoId.isNullOrBlank()) { val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList()) - if (radioMix.isNotEmpty()) { - logger.i { "Endless playback: got ${radioMix.size} tracks from YouTube radio mix for video $videoId" } - recommendations = radioMix.map { video -> + val existingTrackIds = queue.filterIsInstance().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, @@ -498,12 +496,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) 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 b510fcbc..83fad135 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 @@ -612,17 +612,23 @@ internal class StreamProxy( "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 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 a5f71c2f..79013aa3 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 @@ -462,6 +462,10 @@ class DownloadManager( } 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 @@ -514,17 +518,23 @@ class DownloadManager( "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 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 49b9b4ab..564fbddf 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 @@ -616,8 +616,9 @@ class PluginManager( 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() diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index 5dde1c65..bb3ad2b3 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -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) + } } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt index b3daab34..8a922a03 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/audioplayer/FakePluginProvider.kt @@ -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 = emptyList() @@ -32,8 +40,44 @@ class FakeMetadataTrackAPI( ): List = recommendations } +class FakeMetadataAlbumAPI( + private val albumTracks: Map> = 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 { + val tracks = albumTracks[id] ?: emptyList() + return PaginationResult( + items = tracks, + totalCount = tracks.size, + nextPagination = null + ) + } + override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedAlbums(ids: List): List = error("not mocked") + override suspend fun saveAlbums(ids: List) = error("not mocked") + override suspend fun removeSavedAlbums(ids: List) = error("not mocked") +} + +class FakeMetadataArtistAPI( + private val topTracks: Map> = 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 = topTracks[id] ?: emptyList() + override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult = error("not mocked") + override suspend fun isSavedArtists(ids: List): List = error("not mocked") + override suspend fun saveArtists(ids: List) = error("not mocked") + override suspend fun removeSavedArtists(ids: List) = 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 = MutableStateFlow(true) @@ -41,9 +85,12 @@ class FakePluginService( override suspend fun stop() = Unit override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - val scope = PluginServiceScope( - mapOf(MetadataTrackAPI::class to metadataTrackAPI) - ) + val registry = buildMap, ZiplineService> { + put(MetadataTrackAPI::class, metadataTrackAPI) + metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) } + metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) } + } + val scope = PluginServiceScope(registry) return scope.block() } } 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 2b5d4c4c..2827ceca 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 @@ -155,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 { @@ -178,6 +184,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface 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() { 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 index 18b021fb..d4bbeee1 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -55,6 +55,8 @@ internal class IosMediaSession { private var onNext: (() -> Unit)? = null private var onPrevious: (() -> Unit)? = null + private var lastIsPlaying = false + fun configure( onPlay: () -> Unit, onPause: () -> Unit, @@ -112,10 +114,12 @@ internal class IosMediaSession { info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) } info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) + lastIsPlaying = isPlaying nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary } fun clear() { + lastIsPlaying = false nowPlayingInfoCenter.nowPlayingInfo = null notificationTokens.forEach { notificationCenter.removeObserver(it) } notificationTokens.clear() @@ -160,6 +164,15 @@ internal class IosMediaSession { 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