From 452351a8ef40eccc23aee97b6c6b729776dd5503 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Tue, 27 Jan 2026 18:00:17 +1100 Subject: [PATCH] Re-add native apple controls for mpv --- app/(auth)/player/direct-player.tsx | 27 +++ .../expo/modules/mpvplayer/MpvPlayerModule.kt | 6 + modules/mpv-player/ios/MPVLayerRenderer.swift | 13 +- .../mpv-player/ios/MPVNowPlayingManager.swift | 188 ++++++++++++++++++ modules/mpv-player/ios/MpvPlayerModule.swift | 15 ++ modules/mpv-player/ios/MpvPlayerView.swift | 112 ++++++++++- modules/mpv-player/src/MpvPlayer.types.ts | 9 + 7 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 modules/mpv-player/ios/MPVNowPlayingManager.swift diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b5dcac73..a4cffe77 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -48,6 +48,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getMpvAudioId, @@ -504,6 +505,31 @@ export default function page() { return ticksToSeconds(getInitialPlaybackTicks()); }, [getInitialPlaybackTicks]); + /** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */ + const nowPlayingMetadata = useMemo(() => { + if (!item || !api) return undefined; + + const artworkUri = getPrimaryImageUrl({ + api, + item, + quality: 90, + width: 500, + }); + + return { + title: item.Name || "", + artist: + item.Type === "Episode" + ? item.SeriesName || "" + : item.AlbumArtist || "", + albumTitle: + item.Type === "Episode" && item.SeasonName + ? item.SeasonName + : undefined, + artworkUri: artworkUri || undefined, + }; + }, [item, api]); + /** Build video source config for MPV */ const videoSource = useMemo(() => { if (!stream?.url) return undefined; @@ -932,6 +958,7 @@ export default function page() { ref={videoRef} source={videoSource} style={{ width: "100%", height: "100%" }} + nowPlayingMetadata={nowPlayingMetadata} onProgress={onProgress} onPlaybackStateChange={onPlaybackStateChanged} onLoad={() => setIsVideoLoaded(true)} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 245c4ef5..1735c14c 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -43,6 +43,12 @@ class MpvPlayerModule : Module() { view.loadVideo(config) } + // Now Playing metadata for media controls (iOS-only, no-op on Android) + // Android handles media session differently via MediaSessionCompat + Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map? -> + // No-op on Android - media session integration would require MediaSessionCompat + } + // Async function to play video AsyncFunction("play") { view: MpvPlayerView -> view.play() diff --git a/modules/mpv-player/ios/MPVLayerRenderer.swift b/modules/mpv-player/ios/MPVLayerRenderer.swift index e2c4573a..9cb5540c 100644 --- a/modules/mpv-player/ios/MPVLayerRenderer.swift +++ b/modules/mpv-player/ios/MPVLayerRenderer.swift @@ -10,6 +10,7 @@ protocol MPVLayerRendererDelegate: AnyObject { func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) + func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) } /// MPV player using vo_avfoundation for video output. @@ -347,7 +348,8 @@ final class MPVLayerRenderer { ("pause", MPV_FORMAT_FLAG), ("track-list/count", MPV_FORMAT_INT64), ("paused-for-cache", MPV_FORMAT_FLAG), - ("demuxer-cache-duration", MPV_FORMAT_DOUBLE) + ("demuxer-cache-duration", MPV_FORMAT_DOUBLE), + ("current-ao", MPV_FORMAT_STRING) ] for (name, format) in properties { mpv_observe_property(handle, 0, name, format) @@ -552,6 +554,15 @@ final class MPVLayerRenderer { self.delegate?.renderer(self, didBecomeTracksReady: true) } } + case "current-ao": + // Audio output is now active - notify delegate + if let aoName = getStringProperty(handle: handle, name: name) { + print("[MPV] 🔊 Audio output selected: \(aoName)") + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didSelectAudioOutput: aoName) + } + } default: break } diff --git a/modules/mpv-player/ios/MPVNowPlayingManager.swift b/modules/mpv-player/ios/MPVNowPlayingManager.swift new file mode 100644 index 00000000..73dafdbc --- /dev/null +++ b/modules/mpv-player/ios/MPVNowPlayingManager.swift @@ -0,0 +1,188 @@ +import Foundation +import MediaPlayer +import UIKit +import AVFoundation + +/// Simple manager for Now Playing info and remote commands. +/// Stores all state internally and updates Now Playing when ready. +class MPVNowPlayingManager { + static let shared = MPVNowPlayingManager() + + // State + private var title: String? + private var artist: String? + private var albumTitle: String? + private var cachedArtwork: MPMediaItemArtwork? + private var duration: TimeInterval = 0 + private var position: TimeInterval = 0 + private var isPlaying: Bool = false + private var isCommandsSetup = false + + private var artworkTask: URLSessionDataTask? + + private init() {} + + // MARK: - Audio Session + + func activateAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + print("[NowPlaying] Audio session activated") + } catch { + print("[NowPlaying] Audio session error: \(error)") + } + } + + func deactivateAudioSession() { + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + print("[NowPlaying] Audio session deactivated") + } catch { + print("[NowPlaying] Deactivation error: \(error)") + } + } + + // MARK: - Remote Commands + + func setupRemoteCommands( + playHandler: @escaping () -> Void, + pauseHandler: @escaping () -> Void, + toggleHandler: @escaping () -> Void, + seekHandler: @escaping (TimeInterval) -> Void, + skipForward: @escaping (TimeInterval) -> Void, + skipBackward: @escaping (TimeInterval) -> Void + ) { + guard !isCommandsSetup else { return } + isCommandsSetup = true + + DispatchQueue.main.async { + UIApplication.shared.beginReceivingRemoteControlEvents() + } + + let cc = MPRemoteCommandCenter.shared() + + cc.playCommand.isEnabled = true + cc.playCommand.addTarget { _ in playHandler(); return .success } + + cc.pauseCommand.isEnabled = true + cc.pauseCommand.addTarget { _ in pauseHandler(); return .success } + + cc.togglePlayPauseCommand.isEnabled = true + cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success } + + cc.skipForwardCommand.isEnabled = true + cc.skipForwardCommand.preferredIntervals = [15] + cc.skipForwardCommand.addTarget { e in + if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) } + return .success + } + + cc.skipBackwardCommand.isEnabled = true + cc.skipBackwardCommand.preferredIntervals = [15] + cc.skipBackwardCommand.addTarget { e in + if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) } + return .success + } + + cc.changePlaybackPositionCommand.isEnabled = true + cc.changePlaybackPositionCommand.addTarget { e in + if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) } + return .success + } + + print("[NowPlaying] Remote commands ready") + } + + func cleanupRemoteCommands() { + guard isCommandsSetup else { return } + + let cc = MPRemoteCommandCenter.shared() + cc.playCommand.removeTarget(nil) + cc.pauseCommand.removeTarget(nil) + cc.togglePlayPauseCommand.removeTarget(nil) + cc.skipForwardCommand.removeTarget(nil) + cc.skipBackwardCommand.removeTarget(nil) + cc.changePlaybackPositionCommand.removeTarget(nil) + + DispatchQueue.main.async { + UIApplication.shared.endReceivingRemoteControlEvents() + } + + isCommandsSetup = false + print("[NowPlaying] Remote commands cleaned up") + } + + // MARK: - State Updates (call these whenever data changes) + + /// Set metadata (title, artist, artwork URL) + func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) { + self.title = title + self.artist = artist + self.albumTitle = albumTitle + + print("[NowPlaying] Metadata: \(title ?? "nil")") + + // Load artwork async + artworkTask?.cancel() + if let urlString = artworkUrl, let url = URL(string: urlString) { + artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + if let data = data, let image = UIImage(data: data) { + self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + print("[NowPlaying] Artwork loaded") + DispatchQueue.main.async { self?.refresh() } + } + } + artworkTask?.resume() + } + + refresh() + } + + /// Update playback state (position, duration, playing) + func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) { + self.position = position + self.duration = duration + self.isPlaying = isPlaying + refresh() + } + + /// Clear everything + func clear() { + artworkTask?.cancel() + title = nil + artist = nil + albumTitle = nil + cachedArtwork = nil + duration = 0 + position = 0 + isPlaying = false + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + print("[NowPlaying] Cleared") + } + + // MARK: - Private + + /// Refresh Now Playing info if we have enough data + private func refresh() { + guard duration > 0 else { + print("[NowPlaying] refresh skipped - duration is 0") + return + } + + var info: [String: Any] = [ + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0 + ] + + if let title { info[MPMediaItemPropertyTitle] = title } + if let artist { info[MPMediaItemPropertyArtist] = artist } + if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle } + if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)") + } +} diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index b60a3d40..feaf27f6 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -43,6 +43,21 @@ public class MpvPlayerModule: Module { view.loadVideo(config: config) } + // Now Playing metadata for iOS Control Center and Lock Screen + Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in + guard let metadata = metadata else { return } + // Convert Any values to String, filtering out nil/null values + var stringMetadata: [String: String] = [:] + for (key, value) in metadata { + if let stringValue = value as? String { + stringMetadata[key] = stringValue + } + } + if !stringMetadata.isEmpty { + view.setNowPlayingMetadata(stringMetadata) + } + } + // Async function to play video AsyncFunction("play") { (view: MpvPlayerView) in view.play() diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 89502a9a..35e0d19b 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -1,6 +1,7 @@ import AVFoundation import CoreMedia import ExpoModulesCore +import MediaPlayer import UIKit /// Configuration for loading a video @@ -41,7 +42,6 @@ class MpvPlayerView: ExpoView { private var renderer: MPVLayerRenderer? private var videoContainer: UIView! private var pipController: PiPController? - let onLoad = EventDispatcher() let onPlaybackStateChange = EventDispatcher() let onProgress = EventDispatcher() @@ -53,11 +53,14 @@ class MpvPlayerView: ExpoView { private var cachedDuration: Double = 0 private var intendedPlayState: Bool = false private var _isZoomedToFill: Bool = false + + // Reference to now playing manager + private let nowPlayingManager = MPVNowPlayingManager.shared required init(appContext: AppContext? = nil) { super.init(appContext: appContext) + setupNotifications() setupView() - // Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer } private func setupView() { @@ -109,6 +112,77 @@ class MpvPlayerView: ExpoView { CATransaction.commit() } + // MARK: - Audio Session & Notifications + + private func setupNotifications() { + // Handle audio session interruptions (e.g., incoming calls, other apps playing audio) + NotificationCenter.default.addObserver( + self, selector: #selector(handleAudioSessionInterruption), + name: AVAudioSession.interruptionNotification, object: nil) + } + + @objc func handleAudioSessionInterruption(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // Interruption began - pause the video + print("[MPV] Audio session interrupted - pausing video") + self.pause() + + case .ended: + // Interruption ended - check if we should resume + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + print("[MPV] Audio session interruption ended - can resume") + // Don't auto-resume - let user manually resume playback + } else { + print("[MPV] Audio session interruption ended - should not resume") + } + } + + @unknown default: + break + } + } + + private func setupRemoteCommands() { + nowPlayingManager.setupRemoteCommands( + playHandler: { [weak self] in self?.play() }, + pauseHandler: { [weak self] in self?.pause() }, + toggleHandler: { [weak self] in + guard let self else { return } + if self.intendedPlayState { self.pause() } else { self.play() } + }, + seekHandler: { [weak self] time in self?.seekTo(position: time) }, + skipForward: { [weak self] interval in self?.seekBy(offset: interval) }, + skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) } + ) + } + + // MARK: - Now Playing Info + + func setNowPlayingMetadata(_ metadata: [String: String]) { + print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")") + nowPlayingManager.setMetadata( + title: metadata["title"], + artist: metadata["artist"], + albumTitle: metadata["albumTitle"], + artworkUrl: metadata["artworkUri"] + ) + } + + private func clearNowPlayingInfo() { + nowPlayingManager.cleanupRemoteCommands() + nowPlayingManager.deactivateAudioSession() + nowPlayingManager.clear() + } + func loadVideo(config: VideoLoadConfig) { // Skip reload if same URL is already playing if currentURL == config.url { @@ -149,6 +223,7 @@ class MpvPlayerView: ExpoView { func play() { intendedPlayState = true + setupRemoteCommands() renderer?.play() pipController?.setPlaybackRate(1.0) pipController?.updatePlaybackState() @@ -162,10 +237,17 @@ class MpvPlayerView: ExpoView { } func seekTo(position: Double) { + // Update cached position and Now Playing immediately for smooth Control Center feedback + cachedPosition = position + syncNowPlaying(isPlaying: !isPaused()) renderer?.seek(to: position) } func seekBy(offset: Double) { + // Update cached position and Now Playing immediately for smooth Control Center feedback + let newPosition = max(0, min(cachedPosition + offset, cachedDuration)) + cachedPosition = newPosition + syncNowPlaying(isPlaying: !isPaused()) renderer?.seek(by: offset) } @@ -292,23 +374,32 @@ class MpvPlayerView: ExpoView { pipController?.stopPictureInPicture() renderer?.stop() displayLayer.removeFromSuperlayer() + clearNowPlayingInfo() + NotificationCenter.default.removeObserver(self) } } // MARK: - MPVLayerRendererDelegate extension MpvPlayerView: MPVLayerRendererDelegate { + + // MARK: - Single location for Now Playing updates + private func syncNowPlaying(isPlaying: Bool) { + print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)") + nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying) + } + func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { cachedPosition = position cachedDuration = duration DispatchQueue.main.async { [weak self] in guard let self else { return } - // Update PiP current time for progress bar + if self.pipController?.isPictureInPictureActive == true { self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) } - + self.onProgress([ "position": position, "duration": duration, @@ -321,12 +412,10 @@ extension MpvPlayerView: MPVLayerRendererDelegate { func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } - // Don't update intendedPlayState here - it's only set by user actions (play/pause) - // This prevents PiP UI flicker during seeking - // Sync timebase rate with actual playback state + print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)") self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) - + self.syncNowPlaying(isPlaying: !isPaused) self.onPlaybackStateChange([ "isPaused": isPaused, "isPlaying": !isPaused, @@ -358,6 +447,13 @@ extension MpvPlayerView: MPVLayerRendererDelegate { self.onTracksReady([:]) } } + + func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { + // Audio output is now active - this is the right time to activate audio session and set Now Playing + print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing") + nowPlayingManager.activateAudioSession() + syncNowPlaying(isPlaying: !isPaused()) + } } // MARK: - PiPControllerDelegate diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index 23f86093..8f42c8d9 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -25,6 +25,13 @@ export type OnErrorEventPayload = { export type OnTracksReadyEventPayload = Record; +export type NowPlayingMetadata = { + title?: string; + artist?: string; + albumTitle?: string; + artworkUri?: string; +}; + export type MpvPlayerModuleEvents = { onChange: (params: ChangeEventPayload) => void; }; @@ -48,6 +55,8 @@ export type VideoSource = { export type MpvPlayerViewProps = { source?: VideoSource; style?: StyleProp; + /** Metadata for iOS Control Center and Lock Screen now playing info */ + nowPlayingMetadata?: NowPlayingMetadata; onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; onPlaybackStateChange?: (event: { nativeEvent: OnPlaybackStateChangePayload;