mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
4 Commits
6d5f0b2cce
...
42b416fcd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b416fcd1 | ||
|
|
1f228a2aa0 | ||
|
|
d7b00d6230 | ||
|
|
38a1a8ee83 |
@ -18,7 +18,10 @@
|
||||
package org.spotube.spotube.core.audioplayer
|
||||
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||
@ -51,6 +54,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
private val streamingUrlRepository = get<StreamingUrlRepository>()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -515,7 +519,25 @@ class DeviceAudioPlayerQueue(
|
||||
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> {
|
||||
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||
val isIos = getPlatform().type == PlatformType.IOS
|
||||
val requiresIosResolution = isIos && (
|
||||
url.isBlank() ||
|
||||
url.startsWith("http://127.0.0.1") ||
|
||||
url.startsWith("https://127.0.0.1") ||
|
||||
url.startsWith("http://[::1]") ||
|
||||
url.startsWith("https://[::1]") ||
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("https://localhost"))
|
||||
val resolvedStream = if (requiresIosResolution) {
|
||||
streamingUrlRepository.resolveStreamInfo(track)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val resolvedUrl = if (requiresIosResolution) {
|
||||
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
|
||||
} else {
|
||||
url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||
}
|
||||
if (resolvedUrl.isBlank()) return null
|
||||
MediaItem(
|
||||
title = track.title,
|
||||
@ -524,7 +546,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = track.durationMs.milliseconds,
|
||||
coverURL = artworkUrl.orEmpty(),
|
||||
url = resolvedUrl,
|
||||
protocol = protocol)
|
||||
protocol = resolvedStream?.protocol ?: protocol)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
|
||||
@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
|
||||
private val avPlayer: AVPlayer = AVPlayer()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val mediaSession = IosMediaSession()
|
||||
|
||||
private var disposed = false
|
||||
|
||||
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||
|
||||
init {
|
||||
mediaSession.configure(
|
||||
onPlay = { scope.launch { play() } },
|
||||
onPause = { scope.launch { pause() } },
|
||||
onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } },
|
||||
onNext = { scope.launch { skipToNext() } },
|
||||
onPrevious = { scope.launch { skipToPrevious() } })
|
||||
|
||||
avPlayer.addPeriodicTimeObserverForInterval(
|
||||
interval = CMTimeMake(1, 4),
|
||||
queue = null
|
||||
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
// val range = loadedRanges.objectAtIndex(0u)
|
||||
// }
|
||||
}
|
||||
mediaSession.update(
|
||||
item = _currentMediaItem.value,
|
||||
positionSeconds = seconds,
|
||||
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||
isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +172,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
}
|
||||
|
||||
actual override suspend fun play() {
|
||||
mediaSession.activate()
|
||||
avPlayer.play()
|
||||
}
|
||||
|
||||
@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
avPlayer.pause()
|
||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
mediaSession.deactivate()
|
||||
mediaSession.update(
|
||||
item = _currentMediaItem.value,
|
||||
positionSeconds = 0.0,
|
||||
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||
isPlaying = false)
|
||||
}
|
||||
|
||||
actual override suspend fun seekTo(position: Duration) {
|
||||
@ -220,9 +240,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(item)
|
||||
mediaSession.update(
|
||||
item = item,
|
||||
positionSeconds = 0.0,
|
||||
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
|
||||
isPlaying = false)
|
||||
|
||||
if (autoPlay) {
|
||||
avPlayer.play()
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
@ -277,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
if (avItem != null) {
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(nextItem)
|
||||
avPlayer.play()
|
||||
play()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
if (avItem != null) {
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(prevItem)
|
||||
avPlayer.play()
|
||||
play()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
disposed = true
|
||||
avPlayer.pause()
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||
mediaSession.deactivate()
|
||||
mediaSession.clear()
|
||||
currentPlaylist.clear()
|
||||
urlIndexMap.clear()
|
||||
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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.AVFAudio.setActive
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSNumber
|
||||
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 = buildMap<Any?, Any?> {
|
||||
put(MPMediaItemPropertyTitle, item.title)
|
||||
put(MPMediaItemPropertyArtist, item.artist)
|
||||
item.album.takeIf { it.isNotBlank() }?.let {
|
||||
put(MPMediaItemPropertyAlbumTitle, it)
|
||||
}
|
||||
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
||||
put(MPMediaItemPropertyPlaybackDuration, durationSeconds)
|
||||
}
|
||||
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
||||
put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds)
|
||||
}
|
||||
put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0)
|
||||
}
|
||||
nowPlayingInfoCenter.nowPlayingInfo = info
|
||||
}
|
||||
|
||||
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() {
|
||||
notificationCenter.addObserverForName(
|
||||
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
|
||||
`object` = audioSession,
|
||||
queue = NSOperationQueue.mainQueue()) { notification ->
|
||||
val type = notification?.userInfo?.get(
|
||||
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber
|
||||
if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
||||
onPause?.invoke()
|
||||
} else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
||||
activate()
|
||||
val options = notification?.userInfo?.get(
|
||||
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber
|
||||
val shouldResume = options?.unsignedLongValue
|
||||
?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume)
|
||||
?.let { it != 0UL } == true
|
||||
if (shouldResume) onPlay?.invoke()
|
||||
}
|
||||
}?.let { notificationTokens += it }
|
||||
}
|
||||
|
||||
private fun registerRemoteCommands() {
|
||||
fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) {
|
||||
command.addTargetWithHandler(handler)?.let {
|
||||
remoteCommandTokens += command to it
|
||||
}
|
||||
}
|
||||
|
||||
register(remoteCommandCenter.playCommand) {
|
||||
onPlay?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.pauseCommand) {
|
||||
onPause?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.changePlaybackPositionCommand) { event ->
|
||||
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
|
||||
if (position != null && position.isFinite() && position >= 0) {
|
||||
onSeek?.invoke(position)
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
} else {
|
||||
MPRemoteCommandHandlerStatusCommandFailed
|
||||
}
|
||||
}
|
||||
register(remoteCommandCenter.nextTrackCommand) {
|
||||
onNext?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.previousTrackCommand) {
|
||||
onPrevious?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,10 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user