fix(player): harden queue resolution, iOS media session controls, endless playback, and download status checks

Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
pgwiz 2026-10-28 08:46:51 +03:00
parent 2a2ca8c5a6
commit 13fef429f4
9 changed files with 270 additions and 37 deletions

View File

@ -10,14 +10,15 @@
### Fixes ### 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). - **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). - **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 and using `Range: bytes=0-0` byte-range probes instead of bare `HEAD` requests (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/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**: 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/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. - **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) ## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26)

View File

@ -137,14 +137,15 @@ class DeviceAudioPlayerQueue(
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?) { collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (mediaItems.isEmpty()) { if (paired.isEmpty()) {
logger.w { "load: no valid media items after filtering blanks" } logger.w { "load: no valid media items after filtering blanks" }
} }
val mediaItems = paired.map { it.first }
val normalizedStartPosition = val normalizedStartPosition =
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
updateCollectionContext(collectionEntry) updateCollectionContext(collectionEntry)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady() persistQueueStateIfReady()
@ -170,16 +171,14 @@ class DeviceAudioPlayerQueue(
} }
logger.i { "Adding ${entries.size} entries to queue" } logger.i { "Adding ${entries.size} entries to queue" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (mediaItems.isEmpty()) { if (paired.isEmpty()) {
logger.w { "addAllToQueue: all entries had blank urls, skipping" } logger.w { "addAllToQueue: all entries had blank urls, skipping" }
return return
} }
val validEntries = entries.zip(mediaItems).map { (entry) -> val mediaItems = paired.map { it.first }
entry entryByMediaUrl.update { current ->
} current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
entryByMediaUrl.update {
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry }
} }
updateCollectionContext(collectionEntry) updateCollectionContext(collectionEntry)
@ -199,14 +198,14 @@ class DeviceAudioPlayerQueue(
} }
logger.i { "Adding ${entries.size} entries after current position" } logger.i { "Adding ${entries.size} entries after current position" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val paired = entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (mediaItems.isEmpty()) { if (paired.isEmpty()) {
logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" } logger.w { "addAllAfterCurrent: all entries had blank urls, skipping" }
return return
} }
val validEntries = entries.zip(mediaItems).map { (entry) -> entry } val mediaItems = paired.map { it.first }
entryByMediaUrl.update { entryByMediaUrl.update { current ->
it + mediaItems.zip(validEntries).associate { (mediaItem, entry) -> mediaItem.url to entry } current + paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
} }
mediaItems.forEach { mediaItem -> mediaItems.forEach { mediaItem ->
@ -218,7 +217,9 @@ class DeviceAudioPlayerQueue(
} }
override suspend fun removeFromQueue(entry: QueueEntry) { override suspend fun removeFromQueue(entry: QueueEntry) {
val mediaUrl = entry.toMediaItem()?.url ?: return val mediaUrl = entryByMediaUrl.value.entries.firstOrNull { it.value == entry }?.key
?: entry.toMediaItem()?.url
?: return
removeFromQueueByMediaUrl(mediaUrl) removeFromQueueByMediaUrl(mediaUrl)
} }
@ -323,7 +324,7 @@ class DeviceAudioPlayerQueue(
val settings = settingsProvider.settingsState.value ?: return val settings = settingsProvider.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return if (!settings.enableEndlessPlayback) return
val queue = queueFlow.value val queue = getQueue()
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>() val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
if (streamingTracks.isEmpty()) return if (streamingTracks.isEmpty()) return
@ -359,23 +360,20 @@ class DeviceAudioPlayerQueue(
val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) { val videoId = if (seed.id.matches(Regex("^[a-zA-Z0-9_-]{11}$"))) {
seed.id seed.id
} else { } else {
val extracted = seed.externalUri?.let { uri -> seed.externalUri?.let { uri ->
Regex("""[?&]v=([a-zA-Z0-9_-]{11})(?:[&#]|$)""").find(uri)?.groupValues?.get(1) 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("""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) ?: 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()) { if (!videoId.isNullOrBlank()) {
val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList()) val radioMix = runCatching { newPipeService.getRadioMix(videoId) }.getOrDefault(emptyList())
if (radioMix.isNotEmpty()) { val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
logger.i { "Endless playback: got ${radioMix.size} tracks from YouTube radio mix for video $videoId" } val newRadioTracks = radioMix.filter { it.id !in existingTrackIds && it.id != videoId }
recommendations = radioMix.map { video -> if (newRadioTracks.isNotEmpty()) {
logger.i { "Endless playback: got ${newRadioTracks.size} new tracks from YouTube radio mix for video $videoId" }
recommendations = newRadioTracks.map { video ->
MetadataTrack( MetadataTrack(
id = video.id, id = video.id,
title = video.title, title = video.title,
@ -498,12 +496,13 @@ class DeviceAudioPlayerQueue(
return return
} }
val mediaItems = state.entries.mapNotNull { it.toMediaItem() } val paired = state.entries.mapNotNull { entry -> entry.toMediaItem()?.let { mediaItem -> mediaItem to entry } }
if (mediaItems.isEmpty()) { if (paired.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" } logger.w { "restorePersistedState: all entries had blank urls" }
return return
} }
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } val mediaItems = paired.map { it.first }
entryByMediaUrl.value = paired.associate { (mediaItem, entry) -> mediaItem.url to entry }
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)

View File

@ -612,17 +612,23 @@ internal class StreamProxy(
"ANDROID" -> { "ANDROID" -> {
builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer) builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove("Referer")
builder.headers.remove(HttpHeaders.Origin) builder.headers.remove(HttpHeaders.Origin)
builder.headers.remove("Origin")
} }
"IOS" -> { "IOS" -> {
builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT builder.headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer) builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove("Referer")
builder.headers.remove(HttpHeaders.Origin) builder.headers.remove(HttpHeaders.Origin)
builder.headers.remove("Origin")
} }
"VISIONOS" -> { "VISIONOS" -> {
builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT builder.headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
builder.headers.remove(HttpHeaders.Referrer) builder.headers.remove(HttpHeaders.Referrer)
builder.headers.remove("Referer")
builder.headers.remove(HttpHeaders.Origin) builder.headers.remove(HttpHeaders.Origin)
builder.headers.remove("Origin")
} }
else -> { else -> {
builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER builder.headers[HttpHeaders.Referrer] = YOUTUBE_REFERER

View File

@ -462,6 +462,10 @@ class DownloadManager(
} }
applyClientHeaders(url) 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 contentRange = response.headers[HttpHeaders.ContentRange]
val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull() val totalFromContentRange = contentRange?.substringAfterLast('/')?.trim()?.toLongOrNull()
val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L val contentLength = totalFromContentRange ?: response.contentLength() ?: 0L
@ -514,17 +518,23 @@ class DownloadManager(
"ANDROID" -> { "ANDROID" -> {
headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
headers.remove(HttpHeaders.Referrer) headers.remove(HttpHeaders.Referrer)
headers.remove("Referer")
headers.remove(HttpHeaders.Origin) headers.remove(HttpHeaders.Origin)
headers.remove("Origin")
} }
"IOS" -> { "IOS" -> {
headers[HttpHeaders.UserAgent] = IOS_USER_AGENT headers[HttpHeaders.UserAgent] = IOS_USER_AGENT
headers.remove(HttpHeaders.Referrer) headers.remove(HttpHeaders.Referrer)
headers.remove("Referer")
headers.remove(HttpHeaders.Origin) headers.remove(HttpHeaders.Origin)
headers.remove("Origin")
} }
"VISIONOS" -> { "VISIONOS" -> {
headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT headers[HttpHeaders.UserAgent] = VISIONOS_USER_AGENT
headers.remove(HttpHeaders.Referrer) headers.remove(HttpHeaders.Referrer)
headers.remove("Referer")
headers.remove(HttpHeaders.Origin) headers.remove(HttpHeaders.Origin)
headers.remove("Origin")
} }
else -> { else -> {
headers[HttpHeaders.Referrer] = YOUTUBE_REFERER headers[HttpHeaders.Referrer] = YOUTUBE_REFERER

View File

@ -616,8 +616,9 @@ class PluginManager(
val newState = PluginManagerStates.Data( val newState = PluginManagerStates.Data(
plugins = updatedPlugins, plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins, selectedPlugins = updatedSelectedPlugins,
generation = currentState.generation generation = currentState.generation + 1
) )
logger.d { "removePlugin: bumped generation to ${newState.generation} after removing ${plugin.id}" }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val pluginDir = pluginsDir / plugin.id.toPath() val pluginDir = pluginsDir / plugin.id.toPath()

View File

@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest {
assertEquals("Fallback", local.name) assertEquals("Fallback", local.name)
assertEquals(listOf("Artist1", "Artist2"), local.artists) assertEquals(listOf("Artist1", "Artist2"), local.artists)
} }
private suspend fun waitUntil(maxAttempts: Int = 100, condition: suspend () -> Boolean) {
var attempts = 0
while (attempts < maxAttempts) {
if (condition()) return
withContext(Dispatchers.IO) {
Thread.sleep(25)
}
attempts++
}
assertTrue { condition() }
}
// --- endless playback ---
@Test
fun `endless playback appends recommendations when queue finishes`() = runTest {
val recTrack = streamingTrack("rec1")
val service = FakePluginService(
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
)
fakePlugin.setPlugin(service)
fakeSettings.setSettings(
org.spotube.spotube.modules.settings.UserSettings(
playbackProxyServerPort = 14769,
enableEndlessPlayback = true
)
)
val queue = createQueue()
val seed = streamingEntry("t1")
queue.load(listOf(seed))
waitUntil {
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" }
}
}
@Test
fun `endless playback cascades to album tracks when recommendations empty`() = runTest {
val albumTrack = streamingTrack("album-track-1")
val service = FakePluginService(
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
metadataAlbumAPI = FakeMetadataAlbumAPI(
albumTracks = mapOf("album-t1" to listOf(streamingTrack("t1"), albumTrack))
)
)
fakePlugin.setPlugin(service)
fakeSettings.setSettings(
org.spotube.spotube.modules.settings.UserSettings(
playbackProxyServerPort = 14769,
enableEndlessPlayback = true
)
)
val queue = createQueue()
val seed = streamingEntry("t1")
queue.load(listOf(seed))
waitUntil {
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "album-track-1" }
}
val queueTracks = queue.getQueue()
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
}
@Test
fun `endless playback cascades to artist top tracks when album tracks empty`() = runTest {
val artistTrack = streamingTrack("top-track-1")
val service = FakePluginService(
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
metadataAlbumAPI = FakeMetadataAlbumAPI(albumTracks = emptyMap()),
metadataArtistAPI = FakeMetadataArtistAPI(
topTracks = mapOf("artist-t1" to listOf(streamingTrack("t1"), artistTrack))
)
)
fakePlugin.setPlugin(service)
fakeSettings.setSettings(
org.spotube.spotube.modules.settings.UserSettings(
playbackProxyServerPort = 14769,
enableEndlessPlayback = true
)
)
val queue = createQueue()
val seed = streamingEntry("t1")
queue.load(listOf(seed))
waitUntil {
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "top-track-1" }
}
val queueTracks = queue.getQueue()
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
}
@Test
fun `endless playback filters out blacklisted tracks`() = runTest {
val recTrack = streamingTrack("rec1")
fakeBlacklist.toggleTrack(recTrack)
val service = FakePluginService(
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
)
fakePlugin.setPlugin(service)
fakeSettings.setSettings(
org.spotube.spotube.modules.settings.UserSettings(
playbackProxyServerPort = 14769,
enableEndlessPlayback = true
)
)
val queue = createQueue()
val seed = streamingEntry("t1")
queue.load(listOf(seed))
withContext(Dispatchers.IO) { Thread.sleep(200) }
val queueTracks = queue.getQueue()
assertTrue { queueTracks.none { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } }
}
@Test
fun `endless playback does not fetch when setting is disabled`() = runTest {
val recTrack = streamingTrack("rec1")
val service = FakePluginService(
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
)
fakePlugin.setPlugin(service)
fakeSettings.setSettings(
org.spotube.spotube.modules.settings.UserSettings(
playbackProxyServerPort = 14769,
enableEndlessPlayback = false
)
)
val queue = createQueue()
val seed = streamingEntry("t1")
queue.load(listOf(seed))
withContext(Dispatchers.IO) { Thread.sleep(200) }
val queueTracks = queue.getQueue()
assertEquals(1, queueTracks.size)
assertEquals("t1", (queueTracks[0] as? QueueEntry.StreamingTrack)?.track?.id)
}
} }

View File

@ -1,14 +1,22 @@
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.PluginServiceScope import org.spotube.spotube.core.zipline.PluginServiceScope
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import app.cash.zipline.ZiplineService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.reflect.KClass
class FakeMetadataTrackAPI( class FakeMetadataTrackAPI(
private val recommendations: List<MetadataTrack> = emptyList() private val recommendations: List<MetadataTrack> = emptyList()
@ -32,8 +40,44 @@ class FakeMetadataTrackAPI(
): List<MetadataTrack> = recommendations ): List<MetadataTrack> = recommendations
} }
class FakeMetadataAlbumAPI(
private val albumTracks: Map<String, List<MetadataTrack>> = emptyMap()
) : MetadataAlbumAPI {
override suspend fun getAlbum(id: String): MetadataAlbum.Detailed = error("not mocked")
override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed = error("not mocked")
override suspend fun getAlbumTracks(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataTrack> {
val tracks = albumTracks[id] ?: emptyList()
return PaginationResult(
items = tracks,
totalCount = tracks.size,
nextPagination = null
)
}
override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
override suspend fun isSavedAlbums(ids: List<String>): List<Boolean> = error("not mocked")
override suspend fun saveAlbums(ids: List<String>) = error("not mocked")
override suspend fun removeSavedAlbums(ids: List<String>) = error("not mocked")
}
class FakeMetadataArtistAPI(
private val topTracks: Map<String, List<MetadataTrack>> = emptyMap()
) : MetadataArtistAPI {
override suspend fun getArtist(id: String): MetadataArtist.Detailed = error("not mocked")
override suspend fun artistOverview(id: String): MetadataArtistOverview = error("not mocked")
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> = topTracks[id] ?: emptyList()
override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Basic> = error("not mocked")
override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataPlaylist> = error("not mocked")
override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Detailed> = error("not mocked")
override suspend fun isSavedArtists(ids: List<String>): List<Boolean> = error("not mocked")
override suspend fun saveArtists(ids: List<String>) = error("not mocked")
override suspend fun removeSavedArtists(ids: List<String>) = error("not mocked")
}
class FakePluginService( class FakePluginService(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
private val metadataAlbumAPI: MetadataAlbumAPI? = null,
private val metadataArtistAPI: MetadataArtistAPI? = null,
override val pluginId: String = "fake-plugin") : PluginService { override val pluginId: String = "fake-plugin") : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true) override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
@ -41,9 +85,12 @@ class FakePluginService(
override suspend fun stop() = Unit override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T { override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope( val registry = buildMap<KClass<*>, ZiplineService> {
mapOf(MetadataTrackAPI::class to metadataTrackAPI) put(MetadataTrackAPI::class, metadataTrackAPI)
) metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) }
metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) }
}
val scope = PluginServiceScope(registry)
return scope.block() return scope.block()
} }
} }

View File

@ -155,6 +155,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avPlayer.currentItem == null) { if (avPlayer.currentItem == null) {
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
} }
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = _position.value.inWholeMilliseconds / 1000.0,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = status == AVPlayerTimeControlStatusPlaying)
} }
private fun resolveState(): PlayerState { private fun resolveState(): PlayerState {
@ -178,6 +184,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override suspend fun pause() { actual override suspend fun pause() {
avPlayer.pause() avPlayer.pause()
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = _position.value.inWholeMilliseconds / 1000.0,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = false)
} }
actual override suspend fun stop() { actual override suspend fun stop() {

View File

@ -55,6 +55,8 @@ internal class IosMediaSession {
private var onNext: (() -> Unit)? = null private var onNext: (() -> Unit)? = null
private var onPrevious: (() -> Unit)? = null private var onPrevious: (() -> Unit)? = null
private var lastIsPlaying = false
fun configure( fun configure(
onPlay: () -> Unit, onPlay: () -> Unit,
onPause: () -> Unit, onPause: () -> Unit,
@ -112,10 +114,12 @@ internal class IosMediaSession {
info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime) info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime)
} }
info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate) info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate)
lastIsPlaying = isPlaying
nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary
} }
fun clear() { fun clear() {
lastIsPlaying = false
nowPlayingInfoCenter.nowPlayingInfo = null nowPlayingInfoCenter.nowPlayingInfo = null
notificationTokens.forEach { notificationCenter.removeObserver(it) } notificationTokens.forEach { notificationCenter.removeObserver(it) }
notificationTokens.clear() notificationTokens.clear()
@ -160,6 +164,15 @@ internal class IosMediaSession {
onPause?.invoke() onPause?.invoke()
MPRemoteCommandHandlerStatusSuccess MPRemoteCommandHandlerStatusSuccess
} }
remoteCommandTokens += remoteCommandCenter.togglePlayPauseCommand to
remoteCommandCenter.togglePlayPauseCommand.addTargetWithHandler {
if (lastIsPlaying) {
onPause?.invoke()
} else {
onPlay?.invoke()
}
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to
remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event -> remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event ->
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime