mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
Merge pull request #1457 from WhiteGiso/feat/ios-now-playing
Add iOS Now Playing integration
This commit is contained in:
commit
79cc4b4d17
8 changed files with 498 additions and 4 deletions
|
|
@ -21,6 +21,8 @@ interface PlayerEngineController {
|
|||
fun applySubtitleStyle(style: SubtitleStyleState) {}
|
||||
fun setSubtitleDelayMs(delayMs: Int) {}
|
||||
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
|
||||
fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {}
|
||||
fun clearNowPlayingInfo() {}
|
||||
}
|
||||
|
||||
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {
|
||||
|
|
|
|||
|
|
@ -224,3 +224,9 @@ data class PlayerPlaybackSnapshot(
|
|||
val bufferedPositionMs: Long = 0L,
|
||||
val playbackSpeed: Float = 1f,
|
||||
)
|
||||
|
||||
data class PlayerNowPlayingInfo(
|
||||
val title: String,
|
||||
val subtitle: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -164,6 +164,23 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
playerController?.applySubtitleStyle(subtitleStyle)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
playerController,
|
||||
playerControllerSourceUrl,
|
||||
activeSourceUrl,
|
||||
title,
|
||||
activeStreamTitle,
|
||||
activeSeasonNumber,
|
||||
activeEpisodeNumber,
|
||||
activeEpisodeTitle,
|
||||
poster,
|
||||
background,
|
||||
) {
|
||||
val controller = playerController ?: return@LaunchedEffect
|
||||
if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect
|
||||
controller.updateNowPlayingMetadata(buildNowPlayingInfo())
|
||||
}
|
||||
|
||||
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
|
||||
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
|
||||
if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
|
||||
|
|
@ -255,6 +272,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
playerController?.clearNowPlayingInfo()
|
||||
P2pStreamingEngine.shutdown()
|
||||
PlayerStreamsRepository.clearAll()
|
||||
}
|
||||
|
|
@ -469,6 +487,45 @@ private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.buildNowPlayingInfo(): PlayerNowPlayingInfo {
|
||||
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
|
||||
return PlayerNowPlayingInfo(
|
||||
title = title.ifBlank { activeStreamTitle },
|
||||
subtitle = buildNowPlayingSubtitle(
|
||||
isEpisode = isEpisode,
|
||||
seasonNumber = activeSeasonNumber,
|
||||
episodeNumber = activeEpisodeNumber,
|
||||
episodeTitle = activeEpisodeTitle,
|
||||
),
|
||||
artworkUrl = firstNonBlankUrl(poster, background),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNowPlayingSubtitle(
|
||||
isEpisode: Boolean,
|
||||
seasonNumber: Int?,
|
||||
episodeNumber: Int?,
|
||||
episodeTitle: String?,
|
||||
): String? {
|
||||
if (!isEpisode) return null
|
||||
|
||||
val episodeParts = buildList {
|
||||
if (seasonNumber != null && episodeNumber != null) {
|
||||
add("S${seasonNumber}E${episodeNumber}")
|
||||
}
|
||||
episodeTitle?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
}
|
||||
|
||||
return when (episodeParts.size) {
|
||||
0 -> null
|
||||
1 -> episodeParts.first()
|
||||
else -> "${episodeParts[0]} - ${episodeParts[1]}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstNonBlankUrl(vararg values: String?): String? =
|
||||
values.firstOrNull { !it.isNullOrBlank() }?.trim()
|
||||
|
||||
internal fun PlayerScreenRuntime.removeFailedStreamFromCache() {
|
||||
val currentVideoId = activeVideoId ?: return
|
||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ interface NuvioPlayerBridge {
|
|||
fun seekTo(positionMs: Long)
|
||||
fun seekBy(offsetMs: Long)
|
||||
fun retry()
|
||||
fun updateNowPlayingMetadata(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?,
|
||||
)
|
||||
fun clearNowPlayingInfo()
|
||||
fun configureVideoOutput(
|
||||
hardwareDecoder: String,
|
||||
targetColorspaceHint: Boolean,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,26 @@ actual fun PlatformPlayerSurface(
|
|||
bridge.retry()
|
||||
}
|
||||
|
||||
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
|
||||
runCatching {
|
||||
bridge.updateNowPlayingMetadata(
|
||||
title = info.title,
|
||||
subtitle = info.subtitle,
|
||||
artworkUrl = info.artworkUrl,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Logger.w(TAG, error) { "Failed to update iOS Now Playing metadata" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearNowPlayingInfo() {
|
||||
runCatching {
|
||||
bridge.clearNowPlayingInfo()
|
||||
}.onFailure { error ->
|
||||
Logger.w(TAG, error) { "Failed to clear iOS Now Playing metadata" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureIosVideoOutput(settings: PlayerSettingsUiState) {
|
||||
bridge.applyIosVideoOutputSettings(settings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@
|
|||
<string>outplayer</string>
|
||||
<string>open-vidhub</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
import Libmpv
|
||||
import ComposeApp
|
||||
|
||||
|
|
@ -10,14 +11,19 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
|
|||
private var playerVC: MPVPlayerViewController?
|
||||
|
||||
func createPlayerViewController() -> UIViewController {
|
||||
return ensurePlayerViewController()
|
||||
}
|
||||
|
||||
private func ensurePlayerViewController() -> MPVPlayerViewController {
|
||||
if let playerVC { return playerVC }
|
||||
let vc = MPVPlayerViewController()
|
||||
self.playerVC = vc
|
||||
return vc
|
||||
}
|
||||
|
||||
func loadFile(url: String) { playerVC?.loadFile(url) }
|
||||
func loadFile(url: String) { ensurePlayerViewController().loadFile(url) }
|
||||
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) {
|
||||
playerVC?.loadFile(
|
||||
ensurePlayerViewController().loadFile(
|
||||
videoUrl,
|
||||
audioUrl: audioUrl,
|
||||
requestHeaders: parseRequestHeaders(headersJson),
|
||||
|
|
@ -49,6 +55,18 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
|
|||
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
|
||||
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
|
||||
func retry() { playerVC?.retryPlayback() }
|
||||
func updateNowPlayingMetadata(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?
|
||||
) {
|
||||
ensurePlayerViewController().updateNowPlayingMetadata(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkUrl: artworkUrl
|
||||
)
|
||||
}
|
||||
func clearNowPlayingInfo() { playerVC?.clearNowPlayingInfo() }
|
||||
func configureVideoOutput(
|
||||
hardwareDecoder: String,
|
||||
targetColorspaceHint: Bool,
|
||||
|
|
@ -226,12 +244,20 @@ final class MPVPlayerViewController: UIViewController {
|
|||
|
||||
private static let defaultAudioOutput = "audiounit"
|
||||
|
||||
private struct CachedNowPlayingMetadata {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let artworkUrl: String?
|
||||
}
|
||||
|
||||
private let errorStateLock = NSLock()
|
||||
private var metalLayer = MetalLayer()
|
||||
private var lastAppliedDrawableSize: CGSize = .zero
|
||||
private var pendingLoadRequest: PendingLoadRequest?
|
||||
private var pendingLoadRetryWorkItem: DispatchWorkItem?
|
||||
private var mpv: OpaquePointer?
|
||||
private var cachedNowPlayingMetadata: CachedNowPlayingMetadata?
|
||||
private lazy var nowPlayingController = PlayerNowPlayingController(owner: self)
|
||||
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
|
||||
private var recentPlaybackLogs: [String] = []
|
||||
private var activeRequestHeaders: [String: String] = [:]
|
||||
|
|
@ -255,6 +281,10 @@ final class MPVPlayerViewController: UIViewController {
|
|||
}
|
||||
private var _currentErrorMessage: String?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
|
@ -287,6 +317,7 @@ final class MPVPlayerViewController: UIViewController {
|
|||
layoutMetalLayer()
|
||||
|
||||
setupMpv()
|
||||
activateAudioSessionForPlayback()
|
||||
setupNotifications()
|
||||
refreshImmersiveSystemUI()
|
||||
}
|
||||
|
|
@ -305,6 +336,10 @@ final class MPVPlayerViewController: UIViewController {
|
|||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
refreshImmersiveSystemUI()
|
||||
becomeFirstResponder()
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
publishCachedNowPlayingInfoIfNeeded()
|
||||
layoutMetalLayer()
|
||||
attemptStartPendingLoad()
|
||||
}
|
||||
|
||||
|
|
@ -489,12 +524,17 @@ final class MPVPlayerViewController: UIViewController {
|
|||
|
||||
func playPlayback() {
|
||||
guard mpv != nil else { return }
|
||||
publishNowPlayingForPlaybackSession()
|
||||
setFlag("pause", false)
|
||||
isPlayerPlaying = true
|
||||
syncNowPlayingPlaybackState(isPlaying: true)
|
||||
}
|
||||
|
||||
func pausePlayback() {
|
||||
guard mpv != nil else { return }
|
||||
setFlag("pause", true)
|
||||
isPlayerPlaying = false
|
||||
syncNowPlayingPlaybackState(isPlaying: false)
|
||||
}
|
||||
|
||||
func seekToMs(_ ms: Int64) {
|
||||
|
|
@ -503,10 +543,11 @@ final class MPVPlayerViewController: UIViewController {
|
|||
command("seek", args: [String(format: "%.3f", seconds), "absolute"])
|
||||
}
|
||||
|
||||
func seekByMs(_ ms: Int64) {
|
||||
func seekByMs(_ ms: Int64, exact: Bool = false) {
|
||||
guard mpv != nil else { return }
|
||||
let seconds = Double(ms) / 1000.0
|
||||
command("seek", args: [String(format: "%.3f", seconds), "relative"])
|
||||
let seekMode = exact ? "relative+exact" : "relative"
|
||||
command("seek", args: [String(format: "%.3f", seconds), seekMode])
|
||||
}
|
||||
|
||||
func retryPlayback() {
|
||||
|
|
@ -702,15 +743,37 @@ final class MPVPlayerViewController: UIViewController {
|
|||
|
||||
func destroyPlayer() {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
resignFirstResponder()
|
||||
pendingLoadRetryWorkItem?.cancel()
|
||||
pendingLoadRetryWorkItem = nil
|
||||
pendingLoadRequest = nil
|
||||
nowPlayingController.invalidate()
|
||||
clearPlaybackError()
|
||||
deactivateAudioSession()
|
||||
guard let ctx = mpv else { return }
|
||||
mpv = nil // nil first so event loop stops reading
|
||||
mpv_terminate_destroy(ctx)
|
||||
}
|
||||
|
||||
private func activateAudioSessionForPlayback() {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .moviePlayback)
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("[NowPlaying] Failed to activate audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
print("[NowPlaying] Failed to deactivate audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Update
|
||||
|
||||
/// Lightweight state refresh — called by Kotlin polling (every 250ms).
|
||||
|
|
@ -734,6 +797,20 @@ final class MPVPlayerViewController: UIViewController {
|
|||
positionMs = Int64(max(position, 0) * 1000)
|
||||
bufferedMs = Int64(max(position + cached, 0) * 1000)
|
||||
currentSpeed = Float(speed > 0 ? speed : 1.0)
|
||||
|
||||
let shouldPublishNowPlayingState = !isPlayerLoading || isPlayerPlaying || durationMs > 0 || positionMs > 0
|
||||
if shouldPublishNowPlayingState {
|
||||
syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncNowPlayingPlaybackState(isPlaying: Bool) {
|
||||
nowPlayingController.syncPlayback(
|
||||
positionMs: positionMs,
|
||||
durationMs: durationMs,
|
||||
isPlaying: isPlaying,
|
||||
playbackSpeed: currentSpeed
|
||||
)
|
||||
}
|
||||
|
||||
/// Full state + track refresh — called from MPV event loop on property changes.
|
||||
|
|
@ -783,6 +860,48 @@ final class MPVPlayerViewController: UIViewController {
|
|||
subtitleTracks = subs
|
||||
}
|
||||
|
||||
func updateNowPlayingMetadata(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?
|
||||
) {
|
||||
cachedNowPlayingMetadata = CachedNowPlayingMetadata(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkUrl: artworkUrl
|
||||
)
|
||||
nowPlayingController.updateMetadata(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkUrl: artworkUrl
|
||||
)
|
||||
publishNowPlayingForPlaybackSession()
|
||||
}
|
||||
|
||||
func clearNowPlayingInfo() {
|
||||
cachedNowPlayingMetadata = nil
|
||||
nowPlayingController.clear()
|
||||
}
|
||||
|
||||
private func publishCachedNowPlayingInfoIfNeeded() {
|
||||
guard let metadata = cachedNowPlayingMetadata else { return }
|
||||
nowPlayingController.updateMetadata(
|
||||
title: metadata.title,
|
||||
subtitle: metadata.subtitle,
|
||||
artworkUrl: metadata.artworkUrl
|
||||
)
|
||||
}
|
||||
|
||||
private func publishNowPlayingForPlaybackSession() {
|
||||
activateAudioSessionForPlayback()
|
||||
if isViewLoaded, view.window != nil {
|
||||
becomeFirstResponder()
|
||||
}
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
publishCachedNowPlayingInfoIfNeeded()
|
||||
syncNowPlayingPlaybackState(isPlaying: isPlayerPlaying)
|
||||
}
|
||||
|
||||
private func getTrackString(_ index: Int, _ field: String) -> String {
|
||||
(getString("track-list/\(index)/\(field)") ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -926,8 +1045,14 @@ final class MPVPlayerViewController: UIViewController {
|
|||
self.clearPlaybackError()
|
||||
self.isPlayerLoading = false
|
||||
self.updateState()
|
||||
self.publishNowPlayingForPlaybackSession()
|
||||
self.logCurrentAudioOutput()
|
||||
}
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
DispatchQueue.main.async {
|
||||
self.updateState()
|
||||
self.publishNowPlayingForPlaybackSession()
|
||||
}
|
||||
case MPV_EVENT_END_FILE:
|
||||
if let data = eventPtr.pointee.data {
|
||||
let endFile = UnsafePointer<mpv_event_end_file>(OpaquePointer(data)).pointee
|
||||
|
|
|
|||
274
iosApp/iosApp/Player/NowPlayingController.swift
Normal file
274
iosApp/iosApp/Player/NowPlayingController.swift
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class PlayerNowPlayingController {
|
||||
private struct Metadata {
|
||||
var title: String?
|
||||
var subtitle: String?
|
||||
var artworkUrl: String?
|
||||
}
|
||||
|
||||
private struct PlaybackState {
|
||||
var isPlaying: Bool = false
|
||||
var positionMs: Int64 = 0
|
||||
var durationMs: Int64 = 0
|
||||
var playbackSpeed: Float = 1.0
|
||||
}
|
||||
|
||||
private struct RemoteCommandTarget {
|
||||
let command: MPRemoteCommand
|
||||
let token: Any
|
||||
}
|
||||
|
||||
private weak var owner: MPVPlayerViewController?
|
||||
private var metadata = Metadata()
|
||||
private var playbackState = PlaybackState()
|
||||
private var currentArtworkImage: UIImage?
|
||||
private var currentArtworkURL: String?
|
||||
private var artworkTask: URLSessionDataTask?
|
||||
private var remoteTargets: [RemoteCommandTarget] = []
|
||||
private let artworkCache = NSCache<NSString, UIImage>()
|
||||
|
||||
init(owner: MPVPlayerViewController) {
|
||||
self.owner = owner
|
||||
configureRemoteCommands()
|
||||
}
|
||||
|
||||
deinit {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
func updateMetadata(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?
|
||||
) {
|
||||
let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedSubtitle = subtitle?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedArtworkUrl = artworkUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if metadata.title == normalizedTitle,
|
||||
metadata.subtitle == normalizedSubtitle,
|
||||
currentArtworkURL == normalizedArtworkUrl {
|
||||
applyNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
|
||||
metadata.title = normalizedTitle
|
||||
metadata.subtitle = normalizedSubtitle
|
||||
|
||||
if normalizedArtworkUrl != currentArtworkURL {
|
||||
currentArtworkURL = normalizedArtworkUrl
|
||||
currentArtworkImage = nil
|
||||
artworkTask?.cancel()
|
||||
artworkTask = nil
|
||||
|
||||
guard let urlString = normalizedArtworkUrl, !urlString.isEmpty else {
|
||||
applyNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
|
||||
if let cached = artworkCache.object(forKey: urlString as NSString) {
|
||||
currentArtworkImage = cached
|
||||
applyNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
applyNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
|
||||
let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let self else { return }
|
||||
guard let data, let image = UIImage(data: data) else { return }
|
||||
self.artworkCache.setObject(image, forKey: urlString as NSString)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.currentArtworkURL == urlString else { return }
|
||||
self.currentArtworkImage = image
|
||||
self.applyNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
artworkTask = task
|
||||
task.resume()
|
||||
}
|
||||
|
||||
applyNowPlayingInfo()
|
||||
}
|
||||
|
||||
func syncPlayback(
|
||||
positionMs: Int64,
|
||||
durationMs: Int64,
|
||||
isPlaying: Bool,
|
||||
playbackSpeed: Float
|
||||
) {
|
||||
let nextState = PlaybackState(
|
||||
isPlaying: isPlaying,
|
||||
positionMs: max(0, positionMs),
|
||||
durationMs: max(0, durationMs),
|
||||
playbackSpeed: playbackSpeed > 0 ? playbackSpeed : 1.0
|
||||
)
|
||||
let positionChanged = abs(nextState.positionMs - playbackState.positionMs) >= 1_000
|
||||
guard playbackState.isPlaying != nextState.isPlaying ||
|
||||
playbackState.durationMs != nextState.durationMs ||
|
||||
playbackState.playbackSpeed != nextState.playbackSpeed ||
|
||||
positionChanged else {
|
||||
return
|
||||
}
|
||||
|
||||
playbackState = nextState
|
||||
applyNowPlayingInfo()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
artworkTask?.cancel()
|
||||
artworkTask = nil
|
||||
currentArtworkImage = nil
|
||||
currentArtworkURL = nil
|
||||
metadata = Metadata()
|
||||
playbackState = PlaybackState()
|
||||
DispatchQueue.main.async {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
}
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
clear()
|
||||
removeRemoteCommandTargets()
|
||||
}
|
||||
|
||||
private func applyNowPlayingInfo() {
|
||||
guard let title = metadata.title, !title.isEmpty else { return }
|
||||
|
||||
let buildInfo = {
|
||||
var info: [String: Any] = [:]
|
||||
info[MPMediaItemPropertyTitle] = title
|
||||
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||
if let subtitle = self.metadata.subtitle, !subtitle.isEmpty {
|
||||
info[MPMediaItemPropertyArtist] = subtitle
|
||||
}
|
||||
if self.playbackState.durationMs > 0 {
|
||||
info[MPMediaItemPropertyPlaybackDuration] = Double(self.playbackState.durationMs) / 1000.0
|
||||
}
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(self.playbackState.positionMs) / 1000.0
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = Double(
|
||||
self.playbackState.isPlaying ? self.playbackState.playbackSpeed : 0.0
|
||||
)
|
||||
info[MPNowPlayingInfoPropertyIsLiveStream] = self.playbackState.durationMs <= 0
|
||||
if let artwork = self.currentArtworkImage {
|
||||
info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork }
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
buildInfo()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: buildInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureRemoteCommands() {
|
||||
guard remoteTargets.isEmpty else { return }
|
||||
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.isEnabled = true
|
||||
center.pauseCommand.isEnabled = true
|
||||
center.togglePlayPauseCommand.isEnabled = true
|
||||
center.skipForwardCommand.isEnabled = true
|
||||
center.skipBackwardCommand.isEnabled = true
|
||||
center.changePlaybackPositionCommand.isEnabled = true
|
||||
center.skipForwardCommand.preferredIntervals = [10]
|
||||
center.skipBackwardCommand.preferredIntervals = [10]
|
||||
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.playCommand,
|
||||
token: center.playCommand.addTarget { [weak self] _ in
|
||||
DispatchQueue.main.async { self?.owner?.playPlayback() }
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.pauseCommand,
|
||||
token: center.pauseCommand.addTarget { [weak self] _ in
|
||||
DispatchQueue.main.async { self?.owner?.pausePlayback() }
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.togglePlayPauseCommand,
|
||||
token: center.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let owner = self?.owner else { return }
|
||||
if owner.isPlayerPlaying {
|
||||
owner.pausePlayback()
|
||||
} else {
|
||||
owner.playPlayback()
|
||||
}
|
||||
}
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.skipForwardCommand,
|
||||
token: center.skipForwardCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
||||
DispatchQueue.main.async {
|
||||
self?.owner?.seekByMs(Int64(event.interval * 1000.0), exact: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.skipBackwardCommand,
|
||||
token: center.skipBackwardCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
||||
DispatchQueue.main.async {
|
||||
self?.owner?.seekByMs(-Int64(event.interval * 1000.0), exact: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
remoteTargets.append(
|
||||
RemoteCommandTarget(
|
||||
command: center.changePlaybackPositionCommand,
|
||||
token: center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
DispatchQueue.main.async {
|
||||
self?.owner?.seekToMs(Int64(event.positionTime * 1000.0))
|
||||
}
|
||||
return .success
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func removeRemoteCommandTargets() {
|
||||
guard !remoteTargets.isEmpty else { return }
|
||||
|
||||
remoteTargets.forEach { target in
|
||||
target.command.removeTarget(target.token)
|
||||
}
|
||||
remoteTargets.removeAll(keepingCapacity: false)
|
||||
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.isEnabled = false
|
||||
center.pauseCommand.isEnabled = false
|
||||
center.togglePlayPauseCommand.isEnabled = false
|
||||
center.skipForwardCommand.isEnabled = false
|
||||
center.skipBackwardCommand.isEnabled = false
|
||||
center.changePlaybackPositionCommand.isEnabled = false
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue