Merge pull request #1457 from WhiteGiso/feat/ios-now-playing

Add iOS Now Playing integration
This commit is contained in:
Nayif 2026-07-06 01:59:03 +05:30 committed by GitHub
commit 79cc4b4d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 498 additions and 4 deletions

View file

@ -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> {

View file

@ -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,
)

View file

@ -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(

View file

@ -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,

View file

@ -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)
}

View file

@ -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>

View file

@ -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

View 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
}
}