import AVFoundation import CoreMedia import ExpoModulesCore import UIKit /// Configuration for loading a video struct VideoLoadConfig { let url: URL var headers: [String: String]? var externalSubtitles: [String]? var startPosition: Double? var autoplay: Bool /// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default) var initialSubtitleId: Int? /// MPV audio track ID to select on start (1-based, nil to use default) var initialAudioId: Int? init( url: URL, headers: [String: String]? = nil, externalSubtitles: [String]? = nil, startPosition: Double? = nil, autoplay: Bool = true, initialSubtitleId: Int? = nil, initialAudioId: Int? = nil ) { self.url = url self.headers = headers self.externalSubtitles = externalSubtitles self.startPosition = startPosition self.autoplay = autoplay self.initialSubtitleId = initialSubtitleId self.initialAudioId = initialAudioId } } // This view will be used as a native component. Make sure to inherit from `ExpoView` // to apply the proper styling (e.g. border radius and shadows). class MpvPlayerView: ExpoView { private let displayLayer = AVSampleBufferDisplayLayer() private var renderer: MPVMetalRenderer? private var videoContainer: UIView! private var pipController: PiPController? let onLoad = EventDispatcher() let onPlaybackStateChange = EventDispatcher() let onProgress = EventDispatcher() let onError = EventDispatcher() let onTracksReady = EventDispatcher() private var currentURL: URL? private var cachedPosition: Double = 0 private var cachedDuration: Double = 0 private var intendedPlayState: Bool = false required init(appContext: AppContext? = nil) { super.init(appContext: appContext) setupView() } private func setupView() { clipsToBounds = true backgroundColor = .black videoContainer = UIView() videoContainer.translatesAutoresizingMaskIntoConstraints = false videoContainer.backgroundColor = .black videoContainer.clipsToBounds = true addSubview(videoContainer) displayLayer.frame = bounds displayLayer.videoGravity = .resizeAspect if #available(iOS 17.0, *) { displayLayer.wantsExtendedDynamicRangeContent = true } displayLayer.backgroundColor = UIColor.black.cgColor videoContainer.layer.addSublayer(displayLayer) NSLayoutConstraint.activate([ videoContainer.topAnchor.constraint(equalTo: topAnchor), videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor), videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor), videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor) ]) do { renderer = try MPVMetalRenderer(displayLayer: displayLayer) renderer?.delegate = self try renderer?.start() } catch MPVMetalRenderer.RendererError.metalNotSupported { onError(["error": "Metal is not supported on this device"]) } catch { onError(["error": "Failed to start renderer: \(error.localizedDescription)"]) } // Setup PiP pipController = PiPController(sampleBufferDisplayLayer: displayLayer) pipController?.delegate = self } override func layoutSubviews() { super.layoutSubviews() CATransaction.begin() CATransaction.setDisableActions(true) displayLayer.frame = videoContainer.bounds displayLayer.isHidden = false displayLayer.opacity = 1.0 CATransaction.commit() } func loadVideo(config: VideoLoadConfig) { // Skip reload if same URL is already playing if currentURL == config.url { return } currentURL = config.url let preset = PlayerPreset( id: .sdrRec709, title: "Default", summary: "Default playback preset", stream: nil, commands: [] ) // Pass everything to the renderer renderer?.load( url: config.url, with: preset, headers: config.headers, startPosition: config.startPosition, externalSubtitles: config.externalSubtitles, initialSubtitleId: config.initialSubtitleId, initialAudioId: config.initialAudioId ) if config.autoplay { play() } onLoad(["url": config.url.absoluteString]) } // Convenience method for simple loads func loadVideo(url: URL, headers: [String: String]? = nil) { loadVideo(config: VideoLoadConfig(url: url, headers: headers)) } func play() { intendedPlayState = true renderer?.play() pipController?.updatePlaybackState() } func pause() { intendedPlayState = false renderer?.pausePlayback() pipController?.updatePlaybackState() } func seekTo(position: Double) { renderer?.seek(to: position) } func seekBy(offset: Double) { renderer?.seek(by: offset) } func setSpeed(speed: Double) { renderer?.setSpeed(speed) } func getSpeed() -> Double { return renderer?.getSpeed() ?? 1.0 } func isPaused() -> Bool { return renderer?.isPausedState ?? true } func getCurrentPosition() -> Double { return cachedPosition } func getDuration() -> Double { return cachedDuration } // MARK: - Picture in Picture func startPictureInPicture() { pipController?.startPictureInPicture() } func stopPictureInPicture() { pipController?.stopPictureInPicture() } func isPictureInPictureSupported() -> Bool { return pipController?.isPictureInPictureSupported ?? false } func isPictureInPictureActive() -> Bool { return pipController?.isPictureInPictureActive ?? false } // MARK: - Subtitle Controls func getSubtitleTracks() -> [[String: Any]] { return renderer?.getSubtitleTracks() ?? [] } func setSubtitleTrack(_ trackId: Int) { renderer?.setSubtitleTrack(trackId) } func disableSubtitles() { renderer?.disableSubtitles() } func getCurrentSubtitleTrack() -> Int { return renderer?.getCurrentSubtitleTrack() ?? 0 } func addSubtitleFile(url: String, select: Bool = true) { renderer?.addSubtitleFile(url: url, select: select) } // MARK: - Audio Track Controls func getAudioTracks() -> [[String: Any]] { return renderer?.getAudioTracks() ?? [] } func setAudioTrack(_ trackId: Int) { renderer?.setAudioTrack(trackId) } func getCurrentAudioTrack() -> Int { return renderer?.getCurrentAudioTrack() ?? 0 } // MARK: - Subtitle Positioning func setSubtitlePosition(_ position: Int) { renderer?.setSubtitlePosition(position) } func setSubtitleScale(_ scale: Double) { renderer?.setSubtitleScale(scale) } func setSubtitleMarginY(_ margin: Int) { renderer?.setSubtitleMarginY(margin) } func setSubtitleAlignX(_ alignment: String) { renderer?.setSubtitleAlignX(alignment) } func setSubtitleAlignY(_ alignment: String) { renderer?.setSubtitleAlignY(alignment) } func setSubtitleFontSize(_ size: Int) { renderer?.setSubtitleFontSize(size) } deinit { pipController?.stopPictureInPicture() renderer?.stop() displayLayer.controlTimebase = nil displayLayer.removeFromSuperlayer() } } // MARK: - MPVMetalRendererDelegate extension MpvPlayerView: MPVMetalRendererDelegate { func renderer(_: MPVMetalRenderer, didUpdatePosition position: Double, duration: Double) { cachedPosition = position cachedDuration = duration DispatchQueue.main.async { [weak self] in guard let self else { return } if self.pipController?.isPictureInPictureActive == true { self.pipController?.updatePlaybackState() } self.onProgress([ "position": position, "duration": duration, "progress": duration > 0 ? position / duration : 0, ]) } } func renderer(_: MPVMetalRenderer, didChangePause isPaused: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onPlaybackStateChange([ "isPaused": isPaused, "isPlaying": !isPaused, ]) } } func renderer(_: MPVMetalRenderer, didChangeLoading isLoading: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onPlaybackStateChange([ "isLoading": isLoading, ]) } } func renderer(_: MPVMetalRenderer, didBecomeReadyToSeek: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onPlaybackStateChange([ "isReadyToSeek": didBecomeReadyToSeek, ]) } } func renderer(_: MPVMetalRenderer, didBecomeTracksReady: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.onTracksReady([:]) } } } // MARK: - PiPControllerDelegate extension MpvPlayerView: PiPControllerDelegate { func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) { renderer?.syncTimebase() pipController?.updatePlaybackState() } func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) { pipController?.updatePlaybackState() } func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { renderer?.syncTimebase() } func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) { renderer?.syncTimebase() pipController?.updatePlaybackState() } func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { completionHandler(true) } func pipControllerPlay(_ controller: PiPController) { play() } func pipControllerPause(_ controller: PiPController) { pause() } func pipController(_ controller: PiPController, skipByInterval interval: CMTime) { let seconds = CMTimeGetSeconds(interval) let target = max(0, cachedPosition + seconds) seekTo(position: target) } func pipControllerIsPlaying(_ controller: PiPController) -> Bool { return intendedPlayState } func pipControllerDuration(_ controller: PiPController) -> Double { return getDuration() } }