mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
2a2ca8c5a6
commit
13fef429f4
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -494,4 +494,149 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
assertEquals("Fallback", local.name)
|
assertEquals("Fallback", local.name)
|
||||||
assertEquals(listOf("Artist1", "Artist2"), local.artists)
|
assertEquals(listOf("Artist1", "Artist2"), local.artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun waitUntil(maxAttempts: Int = 100, condition: suspend () -> Boolean) {
|
||||||
|
var attempts = 0
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
if (condition()) return
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Thread.sleep(25)
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
assertTrue { condition() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- endless playback ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback appends recommendations when queue finishes`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback cascades to album tracks when recommendations empty`() = runTest {
|
||||||
|
val albumTrack = streamingTrack("album-track-1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
|
||||||
|
metadataAlbumAPI = FakeMetadataAlbumAPI(
|
||||||
|
albumTracks = mapOf("album-t1" to listOf(streamingTrack("t1"), albumTrack))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "album-track-1" }
|
||||||
|
}
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback cascades to artist top tracks when album tracks empty`() = runTest {
|
||||||
|
val artistTrack = streamingTrack("top-track-1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = emptyList()),
|
||||||
|
metadataAlbumAPI = FakeMetadataAlbumAPI(albumTracks = emptyMap()),
|
||||||
|
metadataArtistAPI = FakeMetadataArtistAPI(
|
||||||
|
topTracks = mapOf("artist-t1" to listOf(streamingTrack("t1"), artistTrack))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
waitUntil {
|
||||||
|
queue.getQueue().any { (it as? QueueEntry.StreamingTrack)?.track?.id == "top-track-1" }
|
||||||
|
}
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.count { (it as? QueueEntry.StreamingTrack)?.track?.id == "t1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback filters out blacklisted tracks`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
fakeBlacklist.toggleTrack(recTrack)
|
||||||
|
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { Thread.sleep(200) }
|
||||||
|
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertTrue { queueTracks.none { (it as? QueueEntry.StreamingTrack)?.track?.id == "rec1" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endless playback does not fetch when setting is disabled`() = runTest {
|
||||||
|
val recTrack = streamingTrack("rec1")
|
||||||
|
val service = FakePluginService(
|
||||||
|
metadataTrackAPI = FakeMetadataTrackAPI(recommendations = listOf(recTrack))
|
||||||
|
)
|
||||||
|
fakePlugin.setPlugin(service)
|
||||||
|
fakeSettings.setSettings(
|
||||||
|
org.spotube.spotube.modules.settings.UserSettings(
|
||||||
|
playbackProxyServerPort = 14769,
|
||||||
|
enableEndlessPlayback = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val queue = createQueue()
|
||||||
|
val seed = streamingEntry("t1")
|
||||||
|
queue.load(listOf(seed))
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { Thread.sleep(200) }
|
||||||
|
|
||||||
|
val queueTracks = queue.getQueue()
|
||||||
|
assertEquals(1, queueTracks.size)
|
||||||
|
assertEquals("t1", (queueTracks[0] as? QueueEntry.StreamingTrack)?.track?.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||||
import org.spotube.spotube.core.zipline.PluginService
|
import org.spotube.spotube.core.zipline.PluginService
|
||||||
import org.spotube.spotube.core.zipline.PluginServiceScope
|
import org.spotube.spotube.core.zipline.PluginServiceScope
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
|
import app.cash.zipline.ZiplineService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class FakeMetadataTrackAPI(
|
class FakeMetadataTrackAPI(
|
||||||
private val recommendations: List<MetadataTrack> = emptyList()
|
private val recommendations: List<MetadataTrack> = emptyList()
|
||||||
@ -32,8 +40,44 @@ class FakeMetadataTrackAPI(
|
|||||||
): List<MetadataTrack> = recommendations
|
): List<MetadataTrack> = recommendations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeMetadataAlbumAPI(
|
||||||
|
private val albumTracks: Map<String, List<MetadataTrack>> = emptyMap()
|
||||||
|
) : MetadataAlbumAPI {
|
||||||
|
override suspend fun getAlbum(id: String): MetadataAlbum.Detailed = error("not mocked")
|
||||||
|
override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed = error("not mocked")
|
||||||
|
override suspend fun getAlbumTracks(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataTrack> {
|
||||||
|
val tracks = albumTracks[id] ?: emptyList()
|
||||||
|
return PaginationResult(
|
||||||
|
items = tracks,
|
||||||
|
totalCount = tracks.size,
|
||||||
|
nextPagination = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override suspend fun savedAlbums(pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
|
||||||
|
override suspend fun isSavedAlbums(ids: List<String>): List<Boolean> = error("not mocked")
|
||||||
|
override suspend fun saveAlbums(ids: List<String>) = error("not mocked")
|
||||||
|
override suspend fun removeSavedAlbums(ids: List<String>) = error("not mocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMetadataArtistAPI(
|
||||||
|
private val topTracks: Map<String, List<MetadataTrack>> = emptyMap()
|
||||||
|
) : MetadataArtistAPI {
|
||||||
|
override suspend fun getArtist(id: String): MetadataArtist.Detailed = error("not mocked")
|
||||||
|
override suspend fun artistOverview(id: String): MetadataArtistOverview = error("not mocked")
|
||||||
|
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> = topTracks[id] ?: emptyList()
|
||||||
|
override suspend fun relatedArtists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Basic> = error("not mocked")
|
||||||
|
override suspend fun featuredPlaylists(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataPlaylist> = error("not mocked")
|
||||||
|
override suspend fun getArtistAlbums(id: String, pagination: PaginationStrategy?): PaginationResult<MetadataAlbum.Detailed> = error("not mocked")
|
||||||
|
override suspend fun savedArtists(pagination: PaginationStrategy?): PaginationResult<MetadataArtist.Detailed> = error("not mocked")
|
||||||
|
override suspend fun isSavedArtists(ids: List<String>): List<Boolean> = error("not mocked")
|
||||||
|
override suspend fun saveArtists(ids: List<String>) = error("not mocked")
|
||||||
|
override suspend fun removeSavedArtists(ids: List<String>) = error("not mocked")
|
||||||
|
}
|
||||||
|
|
||||||
class FakePluginService(
|
class FakePluginService(
|
||||||
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
|
||||||
|
private val metadataAlbumAPI: MetadataAlbumAPI? = null,
|
||||||
|
private val metadataArtistAPI: MetadataArtistAPI? = null,
|
||||||
override val pluginId: String = "fake-plugin") : PluginService {
|
override val pluginId: String = "fake-plugin") : PluginService {
|
||||||
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
@ -41,9 +85,12 @@ class FakePluginService(
|
|||||||
override suspend fun stop() = Unit
|
override suspend fun stop() = Unit
|
||||||
|
|
||||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||||
val scope = PluginServiceScope(
|
val registry = buildMap<KClass<*>, ZiplineService> {
|
||||||
mapOf(MetadataTrackAPI::class to metadataTrackAPI)
|
put(MetadataTrackAPI::class, metadataTrackAPI)
|
||||||
)
|
metadataAlbumAPI?.let { put(MetadataAlbumAPI::class, it) }
|
||||||
|
metadataArtistAPI?.let { put(MetadataArtistAPI::class, it) }
|
||||||
|
}
|
||||||
|
val scope = PluginServiceScope(registry)
|
||||||
return scope.block()
|
return scope.block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user