mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
parent
2e84018155
commit
2a2ca8c5a6
10
CHANGELOG.md
10
CHANGELOG.md
@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- **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$.
|
||||||
|
|||||||
@ -42,6 +42,10 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
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.random.Random
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@ -53,6 +57,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
private val streamingUrlRepository by inject<StreamingUrlRepository>()
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
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<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
|
||||||
|
val newAlbumTracks = albumTracks.filter { it.id !in existingTrackIds }
|
||||||
|
if (newAlbumTracks.isNotEmpty()) {
|
||||||
|
logger.i { "Endless playback: got ${newAlbumTracks.size} remaining album tracks for album $albumId" }
|
||||||
|
recommendations = newAlbumTracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.isEmpty() && metadataService != null) {
|
||||||
|
logger.i { "Endless playback: album tracks unavailable or empty, falling back to artist top tracks" }
|
||||||
|
val existingTrackIds = queue.filterIsInstance<QueueEntry.StreamingTrack>().map { it.track.id }.toSet()
|
||||||
|
val artistIds = seedTracks.flatMap { it.track.artists }.map { it.id }.filter { it.isNotBlank() }.distinct()
|
||||||
|
for (artistId in artistIds) {
|
||||||
|
val topTracks = runCatching {
|
||||||
|
metadataService.use {
|
||||||
|
metadataArtistAPI.getArtistTop10Tracks(artistId)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
val newTracks = topTracks.filter { it.id !in existingTrackIds }
|
||||||
|
if (newTracks.isNotEmpty()) {
|
||||||
|
logger.i { "Endless playback: got ${newTracks.size} top tracks for artist $artistId" }
|
||||||
|
recommendations = newTracks
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (recommendations.isEmpty()) {
|
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
|
isFetchingRecommendations = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -517,7 +561,25 @@ class DeviceAudioPlayerQueue(
|
|||||||
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is QueueEntry.StreamingTrack -> {
|
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
|
if (resolvedUrl.isBlank()) return null
|
||||||
MediaItem(
|
MediaItem(
|
||||||
title = track.title,
|
title = track.title,
|
||||||
@ -526,7 +588,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = artworkUrl.orEmpty(),
|
coverURL = artworkUrl.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol)
|
protocol = resolvedStream?.protocol ?: protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
|
|||||||
@ -43,6 +43,7 @@ data class PluginTaggedAudioSource(
|
|||||||
interface TrackSourceRepository {
|
interface TrackSourceRepository {
|
||||||
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
|
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
|
||||||
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
|
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
|
||||||
|
suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
|
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
|
||||||
@ -72,4 +73,19 @@ class MatchedTracksRepository(private val database: Database) : TrackSourceRepos
|
|||||||
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
|
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTrackSource(track: MetadataTrack, pluginId: String) {
|
||||||
|
database.matchedTracksDataStore.edit { prefs ->
|
||||||
|
val key = stringPreferencesKey(track.id)
|
||||||
|
val json = prefs[key] ?: return@edit
|
||||||
|
val tagged = try {
|
||||||
|
Json.decodeFromString<PluginTaggedAudioSource>(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (tagged == null || tagged.pluginId == pluginId) {
|
||||||
|
prefs.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -76,6 +76,12 @@ internal class StreamProxy(
|
|||||||
private const val YOUTUBE_REFERER = "https://www.youtube.com/"
|
private const val YOUTUBE_REFERER = "https://www.youtube.com/"
|
||||||
private const val BROWSER_USER_AGENT =
|
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"
|
"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
|
private val fileSystem = FileSystem.SYSTEM
|
||||||
@ -599,9 +605,31 @@ internal class StreamProxy(
|
|||||||
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept)
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Accept)
|
||||||
|
|
||||||
if (isYouTubeOrGoogleVideo(targetUrl)) {
|
if (isYouTubeOrGoogleVideo(targetUrl)) {
|
||||||
|
val clientParam = runCatching { Url(targetUrl).parameters["c"]?.uppercase() }.getOrNull()
|
||||||
|
?: Regex("""[?&]c=([^&#]+)""").find(targetUrl)?.groupValues?.get(1)?.uppercase()
|
||||||
|
|
||||||
|
when (clientParam) {
|
||||||
|
"ANDROID" -> {
|
||||||
|
builder.headers[HttpHeaders.UserAgent] = ANDROID_USER_AGENT
|
||||||
|
builder.headers.remove(HttpHeaders.Referrer)
|
||||||
|
builder.headers.remove(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.Referrer] = YOUTUBE_REFERER
|
||||||
builder.headers[HttpHeaders.Origin] = "https://www.youtube.com"
|
builder.headers[HttpHeaders.Origin] = "https://www.youtube.com"
|
||||||
builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
|
builder.headers[HttpHeaders.UserAgent] = BROWSER_USER_AGENT
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent)
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.UserAgent)
|
||||||
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer)
|
call.forwardRequestHeaderIfPresent(builder.headers, HttpHeaders.Referrer)
|
||||||
|
|||||||
@ -186,6 +186,7 @@ class StreamingUrlRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Cached source did not provide a stream for track $trackId; falling back to track lookup" }
|
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" }
|
logger.d { "Attempting stream resolution for track $trackId using track lookup" }
|
||||||
|
|||||||
@ -34,8 +34,10 @@ import io.ktor.client.request.headers
|
|||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.Url
|
||||||
import io.ktor.http.contentLength
|
import io.ktor.http.contentLength
|
||||||
import io.ktor.http.isSuccess
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.discard
|
||||||
import io.ktor.utils.io.readRemaining
|
import io.ktor.utils.io.readRemaining
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -95,6 +97,15 @@ class DownloadManager(
|
|||||||
const val MAX_CONCURRENT_DOWNLOADS = 4
|
const val MAX_CONCURRENT_DOWNLOADS = 4
|
||||||
private const val SEGMENT_COUNT = 4
|
private const val SEGMENT_COUNT = 4
|
||||||
private const val CHUNK_SIZE = 256 * 1024
|
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")
|
private val logger = Logger.withTag("DownloadManager")
|
||||||
@ -362,6 +373,7 @@ class DownloadManager(
|
|||||||
headers {
|
headers {
|
||||||
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
|
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
|
||||||
}
|
}
|
||||||
|
applyClientHeaders(url)
|
||||||
}
|
}
|
||||||
logger.d {
|
logger.d {
|
||||||
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
|
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
|
||||||
@ -398,7 +410,9 @@ class DownloadManager(
|
|||||||
downloadDir: Path) {
|
downloadDir: Path) {
|
||||||
val tempFile = tempDir / "${item.id}.tmp"
|
val tempFile = tempDir / "${item.id}.tmp"
|
||||||
|
|
||||||
val response = httpClient.get(url)
|
val response = httpClient.get(url) {
|
||||||
|
applyClientHeaders(url)
|
||||||
|
}
|
||||||
val totalSize = response.contentLength() ?: 0L
|
val totalSize = response.contentLength() ?: 0L
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
|
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
|
||||||
@ -442,21 +456,30 @@ class DownloadManager(
|
|||||||
|
|
||||||
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
|
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
|
||||||
return try {
|
return try {
|
||||||
val response = httpClient.head(url)
|
val response = httpClient.get(url) {
|
||||||
val contentLength = response.contentLength() ?: 0L
|
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 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]
|
val contentType = response.headers[HttpHeaders.ContentType]
|
||||||
logger.i {
|
logger.i {
|
||||||
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
|
"[Download $id] PROBE (bytes=0-0) -> HTTP ${response.status.value} type=$contentType " +
|
||||||
"length=$contentLength acceptRanges=$acceptRanges"
|
"length=$contentLength contentRange=$contentRange acceptRanges=$acceptRanges"
|
||||||
}
|
}
|
||||||
|
runCatching { response.bodyAsChannel().discard() }
|
||||||
rejectVideo(id, contentType)
|
rejectVideo(id, contentType)
|
||||||
Pair(contentLength, supportsRanges)
|
Pair(contentLength, supportsRanges)
|
||||||
} catch (e: DownloadException) {
|
} catch (e: DownloadException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} 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)
|
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) {
|
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
|
||||||
val settings = settingsRepository.userSettings.value
|
val settings = settingsRepository.userSettings.value
|
||||||
val wanted = settings.downloadMusicFormat
|
val wanted = settings.downloadMusicFormat
|
||||||
|
|||||||
@ -579,6 +579,11 @@ class PluginManager(
|
|||||||
val updatedSelectedPlugins =
|
val updatedSelectedPlugins =
|
||||||
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
||||||
if (selectedPlugin.id == plugin.id) plugin else 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(
|
val newState = PluginManagerStates.Data(
|
||||||
plugins = updatedPlugins,
|
plugins = updatedPlugins,
|
||||||
@ -599,7 +604,15 @@ class PluginManager(
|
|||||||
if (currentState is PluginManagerStates.Data) {
|
if (currentState is PluginManagerStates.Data) {
|
||||||
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
||||||
val updatedSelectedPlugins =
|
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(
|
val newState = PluginManagerStates.Data(
|
||||||
plugins = updatedPlugins,
|
plugins = updatedPlugins,
|
||||||
selectedPlugins = updatedSelectedPlugins,
|
selectedPlugins = updatedSelectedPlugins,
|
||||||
|
|||||||
@ -447,6 +447,23 @@ class StreamingUrlRepositoryBehaviorTest {
|
|||||||
assertEquals("https://example.com/fresh.opus", info?.url)
|
assertEquals("https://example.com/fresh.opus", info?.url)
|
||||||
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
|
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
|
||||||
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
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
|
@Test
|
||||||
|
|||||||
@ -68,6 +68,7 @@ class FakeTrackSourceRepository : TrackSourceRepository {
|
|||||||
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
|
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
|
||||||
var getCallCount = 0
|
var getCallCount = 0
|
||||||
var saveCallCount = 0
|
var saveCallCount = 0
|
||||||
|
var deleteCallCount = 0
|
||||||
|
|
||||||
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
|
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
|
||||||
getCallCount++
|
getCallCount++
|
||||||
@ -81,6 +82,14 @@ class FakeTrackSourceRepository : TrackSourceRepository {
|
|||||||
store[track.id] = PluginTaggedAudioSource(pluginId, source)
|
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 rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
|
||||||
|
|
||||||
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
|
||||||
|
|||||||
@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
private val avPlayer: AVPlayer = AVPlayer()
|
private val avPlayer: AVPlayer = AVPlayer()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val mediaSession = IosMediaSession()
|
||||||
|
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||||
|
|
||||||
init {
|
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(
|
avPlayer.addPeriodicTimeObserverForInterval(
|
||||||
interval = CMTimeMake(1, 4),
|
interval = CMTimeMake(1, 4),
|
||||||
queue = null
|
queue = null
|
||||||
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
// val range = loadedRanges.objectAtIndex(0u)
|
// 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() {
|
actual override suspend fun play() {
|
||||||
|
mediaSession.activate()
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_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) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
@ -220,9 +240,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(item)
|
_currentMediaItem.tryEmit(item)
|
||||||
|
mediaSession.update(
|
||||||
|
item = item,
|
||||||
|
positionSeconds = 0.0,
|
||||||
|
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = false)
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
if (avItem != null) {
|
if (avItem != null) {
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(nextItem)
|
_currentMediaItem.tryEmit(nextItem)
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
if (avItem != null) {
|
if (avItem != null) {
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
_currentMediaItem.tryEmit(prevItem)
|
_currentMediaItem.tryEmit(prevItem)
|
||||||
avPlayer.play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
disposed = true
|
disposed = true
|
||||||
avPlayer.pause()
|
avPlayer.pause()
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.clear()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
urlIndexMap.clear()
|
urlIndexMap.clear()
|
||||||
|
|
||||||
|
|||||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import platform.AVFAudio.AVAudioSession
|
||||||
|
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||||
|
import platform.AVFAudio.AVAudioSessionModeDefault
|
||||||
|
import platform.Foundation.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<Any>()
|
||||||
|
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
|
||||||
|
private var onPlay: (() -> Unit)? = null
|
||||||
|
private var onPause: (() -> Unit)? = null
|
||||||
|
private var onSeek: ((Double) -> Unit)? = null
|
||||||
|
private var onNext: (() -> Unit)? = null
|
||||||
|
private var onPrevious: (() -> Unit)? = null
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -86,7 +86,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
|||||||
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
||||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
||||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
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" }
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user