mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Add iOS background playback support
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f9c040015e
commit
b76a69d049
127
.github/workflows/ios-validation.yml
vendored
Normal file
127
.github/workflows/ios-validation.yml
vendored
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
name: iOS validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "composeApp/src/commonMain/**"
|
||||||
|
- "composeApp/src/iosMain/**"
|
||||||
|
- "iosApp/**"
|
||||||
|
- "composeApp/build.gradle.kts"
|
||||||
|
- "gradle/**"
|
||||||
|
- "gradle.properties"
|
||||||
|
- "settings.gradle.kts"
|
||||||
|
- ".github/workflows/ios-validation.yml"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- "composeApp/src/commonMain/**"
|
||||||
|
- "composeApp/src/iosMain/**"
|
||||||
|
- "iosApp/**"
|
||||||
|
- "composeApp/build.gradle.kts"
|
||||||
|
- "gradle/**"
|
||||||
|
- "gradle.properties"
|
||||||
|
- "settings.gradle.kts"
|
||||||
|
- ".github/workflows/ios-validation.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ios-validation-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
|
||||||
|
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-deps:
|
||||||
|
name: Prepare dependencies
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out ComposeNativeWebview
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
|
||||||
|
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
|
||||||
|
path: compose-webview
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Publish ComposeNativeWebview locally
|
||||||
|
working-directory: compose-webview
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew publishToMavenLocal
|
||||||
|
|
||||||
|
- name: Upload Maven local repository
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: maven-local
|
||||||
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
|
build-ios:
|
||||||
|
name: Build unsigned iOS app
|
||||||
|
needs: prepare-deps
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- name: Check out Spotube
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Maven local repository
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: maven-local
|
||||||
|
path: ~/.m2/repository/
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Make Gradle executable
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Compile Kotlin iOS simulator target
|
||||||
|
run: ./gradlew :composeApp:compileKotlinIosSimulatorArm64
|
||||||
|
|
||||||
|
- name: Build unsigned iOS app
|
||||||
|
run: >
|
||||||
|
xcodebuild
|
||||||
|
-project iosApp/iosApp.xcodeproj
|
||||||
|
-scheme iosApp
|
||||||
|
-configuration Release
|
||||||
|
-sdk iphonesimulator
|
||||||
|
-destination 'generic/platform=iOS Simulator'
|
||||||
|
-derivedDataPath build/ios
|
||||||
|
CODE_SIGNING_ALLOWED=NO
|
||||||
|
CODE_SIGNING_REQUIRED=NO
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
DEVELOPMENT_TEAM=""
|
||||||
|
build
|
||||||
@ -18,7 +18,10 @@
|
|||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
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.di.injectLogger
|
||||||
|
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
@ -51,6 +54,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())
|
||||||
|
|
||||||
@ -515,7 +519,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,
|
||||||
@ -524,7 +546,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 -> {
|
||||||
|
|||||||
@ -48,6 +48,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
}.create()
|
}.create()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
|
private val mediaSession = IosMediaSession()
|
||||||
|
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
@ -94,8 +95,22 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
|
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
|
||||||
NativePlayerState.OPENING,
|
NativePlayerState.OPENING,
|
||||||
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
|
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
|
||||||
NativePlayerState.PLAYING -> _playerState.tryEmit(PlayerState.PLAYING)
|
NativePlayerState.PLAYING -> {
|
||||||
NativePlayerState.PAUSED -> _playerState.tryEmit(PlayerState.PAUSED)
|
_playerState.tryEmit(PlayerState.PLAYING)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
_position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
true)
|
||||||
|
}
|
||||||
|
NativePlayerState.PAUSED -> {
|
||||||
|
_playerState.tryEmit(PlayerState.PAUSED)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
_position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
false)
|
||||||
|
}
|
||||||
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
|
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,6 +118,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
override fun onTime(positionMs: Long) {
|
override fun onTime(positionMs: Long) {
|
||||||
if (disposed || positionMs < 0) return
|
if (disposed || positionMs < 0) return
|
||||||
_position.tryEmit(positionMs.milliseconds)
|
_position.tryEmit(positionMs.milliseconds)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
positionMs / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_playerState.value == PlayerState.PLAYING)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDuration(durationMs: Long) {
|
override fun onDuration(durationMs: Long) {
|
||||||
@ -128,6 +148,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
engine.setListener(engineListener)
|
engine.setListener(engineListener)
|
||||||
|
mediaSession.configure(
|
||||||
|
onPlay = { scope.launch { play() } },
|
||||||
|
onPause = { scope.launch { pause() } },
|
||||||
|
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
|
||||||
|
onNext = { scope.launch { skipToNext() } },
|
||||||
|
onPrevious = { scope.launch { skipToPrevious() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
||||||
@ -167,6 +193,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
actual override suspend fun play() {
|
actual override suspend fun play() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
|
mediaSession.activate()
|
||||||
if (currentIndex !in currentPlaylist.indices) {
|
if (currentIndex !in currentPlaylist.indices) {
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[0])
|
_currentMediaItem.tryEmit(currentPlaylist[0])
|
||||||
@ -186,6 +213,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
engine.stop()
|
engine.stop()
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
0.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
false)
|
||||||
engineHasMedia = false
|
engineHasMedia = false
|
||||||
pendingStartMs = 0L
|
pendingStartMs = 0L
|
||||||
_position.tryEmit(Duration.ZERO)
|
_position.tryEmit(Duration.ZERO)
|
||||||
@ -275,6 +308,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||||
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
0.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
false)
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
@ -414,6 +452,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
disposed = true
|
disposed = true
|
||||||
engine.setListener(null)
|
engine.setListener(null)
|
||||||
engine.destroy()
|
engine.destroy()
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.clear()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|
||||||
originalPlaylist.clear()
|
originalPlaylist.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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