diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index fb283055..04950834 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -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.plugin.PluginProvider import org.spotube.spotube.modules.settings.SettingsProvider @@ -37,6 +40,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @@ -48,6 +52,7 @@ class DeviceAudioPlayerQueue( private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() + private val streamingUrlRepository by inject() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -456,7 +461,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, @@ -465,7 +488,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol) + protocol = resolvedStream?.protocol ?: protocol) } is QueueEntry.LocalTrack -> { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 3c99747b..2b5d4c4c 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -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() diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt new file mode 100644 index 00000000..18b021fb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -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 . + */ + +@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() + private val remoteCommandTokens = mutableListOf>() + 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 + } + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5663e50d..4a484863 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,10 @@ CADisableMinimumFrameDurationOnPhone + UIBackgroundModes + + audio + CFBundleURLTypes