mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
441 lines
11 KiB
Swift
441 lines
11 KiB
Swift
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: MPVLayerRenderer?
|
|
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
|
|
private var _isZoomedToFill: Bool = false
|
|
|
|
required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
setupView()
|
|
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
|
}
|
|
|
|
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)
|
|
])
|
|
|
|
renderer = MPVLayerRenderer(displayLayer: displayLayer)
|
|
renderer?.delegate = self
|
|
|
|
// Setup PiP
|
|
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
|
pipController?.delegate = self
|
|
|
|
do {
|
|
try renderer?.start()
|
|
} catch {
|
|
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
|
|
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 - it handles start position and external subs
|
|
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?.setPlaybackRate(1.0)
|
|
pipController?.updatePlaybackState()
|
|
}
|
|
|
|
func pause() {
|
|
intendedPlayState = false
|
|
renderer?.pausePlayback()
|
|
pipController?.setPlaybackRate(0.0)
|
|
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() {
|
|
print("🎬 MpvPlayerView: startPictureInPicture called")
|
|
print("🎬 Duration: \(getDuration()), IsPlaying: \(!isPaused())")
|
|
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)
|
|
}
|
|
|
|
// MARK: - Video Scaling
|
|
|
|
func setZoomedToFill(_ zoomed: Bool) {
|
|
_isZoomedToFill = zoomed
|
|
displayLayer.videoGravity = zoomed ? .resizeAspectFill : .resizeAspect
|
|
}
|
|
|
|
func isZoomedToFill() -> Bool {
|
|
return _isZoomedToFill
|
|
}
|
|
|
|
// MARK: - Technical Info
|
|
|
|
func getTechnicalInfo() -> [String: Any] {
|
|
return renderer?.getTechnicalInfo() ?? [:]
|
|
}
|
|
|
|
deinit {
|
|
pipController?.stopPictureInPicture()
|
|
renderer?.stop()
|
|
displayLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
// MARK: - MPVLayerRendererDelegate
|
|
|
|
extension MpvPlayerView: MPVLayerRendererDelegate {
|
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: 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,
|
|
"progress": duration > 0 ? position / duration : 0,
|
|
])
|
|
}
|
|
}
|
|
|
|
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
|
|
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
|
|
|
|
self.onPlaybackStateChange([
|
|
"isPaused": isPaused,
|
|
"isPlaying": !isPaused,
|
|
])
|
|
}
|
|
}
|
|
|
|
func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.onPlaybackStateChange([
|
|
"isLoading": isLoading,
|
|
])
|
|
}
|
|
}
|
|
|
|
func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.onPlaybackStateChange([
|
|
"isReadyToSeek": didBecomeReadyToSeek,
|
|
])
|
|
}
|
|
}
|
|
|
|
func renderer(_: MPVLayerRenderer, 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) {
|
|
print("PiP will start")
|
|
// Sync timebase before PiP starts for smooth transition
|
|
renderer?.syncTimebase()
|
|
// Set current time for PiP progress bar
|
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
|
|
|
// Reset to fit for PiP (zoomed video doesn't display correctly in PiP)
|
|
if _isZoomedToFill {
|
|
displayLayer.videoGravity = .resizeAspect
|
|
}
|
|
}
|
|
|
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
|
print("PiP did start: \(didStartPictureInPicture)")
|
|
// Ensure current time is synced when PiP starts
|
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
|
}
|
|
|
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
|
print("PiP will stop")
|
|
// Sync timebase before returning from PiP
|
|
renderer?.syncTimebase()
|
|
}
|
|
|
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
|
print("PiP did stop")
|
|
// Ensure timebase is synced after PiP ends
|
|
renderer?.syncTimebase()
|
|
pipController?.updatePlaybackState()
|
|
|
|
// Restore the user's zoom preference
|
|
if _isZoomedToFill {
|
|
displayLayer.videoGravity = .resizeAspectFill
|
|
}
|
|
}
|
|
|
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
|
print("PiP restore user interface")
|
|
completionHandler(true)
|
|
}
|
|
|
|
func pipControllerPlay(_ controller: PiPController) {
|
|
print("PiP play requested")
|
|
intendedPlayState = true
|
|
renderer?.play()
|
|
pipController?.setPlaybackRate(1.0)
|
|
}
|
|
|
|
func pipControllerPause(_ controller: PiPController) {
|
|
print("PiP pause requested")
|
|
intendedPlayState = false
|
|
renderer?.pausePlayback()
|
|
pipController?.setPlaybackRate(0.0)
|
|
}
|
|
|
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
|
let seconds = CMTimeGetSeconds(interval)
|
|
print("PiP skip by interval: \(seconds)")
|
|
let target = max(0, cachedPosition + seconds)
|
|
seekTo(position: target)
|
|
}
|
|
|
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
|
// Use intended state to ignore transient pauses during seeking
|
|
return intendedPlayState
|
|
}
|
|
|
|
func pipControllerDuration(_ controller: PiPController) -> Double {
|
|
return getDuration()
|
|
}
|
|
|
|
func pipControllerCurrentPosition(_ controller: PiPController) -> Double {
|
|
return getCurrentPosition()
|
|
}
|
|
}
|