mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local> Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com> Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
committed by
GitHub
parent
df2f44e086
commit
f1575ca48b
726
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
726
modules/mpv-player/ios/MPVLayerRenderer.swift
Normal file
@@ -0,0 +1,726 @@
|
||||
import UIKit
|
||||
import MPVKit
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import AVFoundation
|
||||
|
||||
protocol MPVLayerRendererDelegate: AnyObject {
|
||||
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
|
||||
}
|
||||
|
||||
/// MPV player using vo_avfoundation for video output.
|
||||
/// This renders video directly to AVSampleBufferDisplayLayer for PiP support.
|
||||
final class MPVLayerRenderer {
|
||||
enum RendererError: Error {
|
||||
case mpvCreationFailed
|
||||
case mpvInitialization(Int32)
|
||||
}
|
||||
|
||||
private let displayLayer: AVSampleBufferDisplayLayer
|
||||
private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated)
|
||||
private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent)
|
||||
|
||||
private var mpv: OpaquePointer?
|
||||
|
||||
private var currentPreset: PlayerPreset?
|
||||
private var currentURL: URL?
|
||||
private var currentHeaders: [String: String]?
|
||||
private var pendingExternalSubtitles: [String] = []
|
||||
private var initialSubtitleId: Int?
|
||||
private var initialAudioId: Int?
|
||||
|
||||
private var isRunning = false
|
||||
private var isStopping = false
|
||||
|
||||
weak var delegate: MPVLayerRendererDelegate?
|
||||
|
||||
// Thread-safe state for playback
|
||||
private var _cachedDuration: Double = 0
|
||||
private var _cachedPosition: Double = 0
|
||||
private var _isPaused: Bool = true
|
||||
private var _playbackSpeed: Double = 1.0
|
||||
private var _isLoading: Bool = false
|
||||
private var _isReadyToSeek: Bool = false
|
||||
|
||||
// Thread-safe accessors
|
||||
private var cachedDuration: Double {
|
||||
get { stateQueue.sync { _cachedDuration } }
|
||||
set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } }
|
||||
}
|
||||
private var cachedPosition: Double {
|
||||
get { stateQueue.sync { _cachedPosition } }
|
||||
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
|
||||
}
|
||||
private var isPaused: Bool {
|
||||
get { stateQueue.sync { _isPaused } }
|
||||
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
|
||||
}
|
||||
private var playbackSpeed: Double {
|
||||
get { stateQueue.sync { _playbackSpeed } }
|
||||
set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } }
|
||||
}
|
||||
private var isLoading: Bool {
|
||||
get { stateQueue.sync { _isLoading } }
|
||||
set { stateQueue.async(flags: .barrier) { self._isLoading = newValue } }
|
||||
}
|
||||
private var isReadyToSeek: Bool {
|
||||
get { stateQueue.sync { _isReadyToSeek } }
|
||||
set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } }
|
||||
}
|
||||
|
||||
var isPausedState: Bool {
|
||||
return isPaused
|
||||
}
|
||||
|
||||
init(displayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.displayLayer = displayLayer
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
guard !isRunning else { return }
|
||||
guard let handle = mpv_create() else {
|
||||
throw RendererError.mpvCreationFailed
|
||||
}
|
||||
mpv = handle
|
||||
|
||||
// Logging - only warnings and errors in release, verbose in debug
|
||||
#if DEBUG
|
||||
checkError(mpv_request_log_messages(handle, "warn"))
|
||||
#else
|
||||
checkError(mpv_request_log_messages(handle, "no"))
|
||||
#endif
|
||||
|
||||
// Detect if running on simulator
|
||||
#if targetEnvironment(simulator)
|
||||
let isSimulator = true
|
||||
#else
|
||||
let isSimulator = false
|
||||
#endif
|
||||
|
||||
// Pass the AVSampleBufferDisplayLayer to mpv via --wid
|
||||
// The vo_avfoundation driver expects this
|
||||
let layerPtrInt = Int(bitPattern: Unmanaged.passUnretained(displayLayer).toOpaque())
|
||||
var displayLayerPtr = Int64(layerPtrInt)
|
||||
checkError(mpv_set_option(handle, "wid", MPV_FORMAT_INT64, &displayLayerPtr))
|
||||
|
||||
// Use AVFoundation video output - required for PiP support
|
||||
checkError(mpv_set_option_string(handle, "vo", "avfoundation"))
|
||||
|
||||
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||
// This is better for PiP as subtitles are baked into the video
|
||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||
|
||||
// Hardware decoding with VideoToolbox
|
||||
// On simulator, use software decoding since VideoToolbox is not available
|
||||
// On device, use VideoToolbox with software fallback enabled
|
||||
let hwdecValue = isSimulator ? "no" : "videotoolbox"
|
||||
checkError(mpv_set_option_string(handle, "hwdec", hwdecValue))
|
||||
checkError(mpv_set_option_string(handle, "hwdec-codecs", "all"))
|
||||
checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "yes"))
|
||||
|
||||
// Subtitle and audio settings
|
||||
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
|
||||
|
||||
// Initialize mpv
|
||||
let initStatus = mpv_initialize(handle)
|
||||
guard initStatus >= 0 else {
|
||||
throw RendererError.mpvInitialization(initStatus)
|
||||
}
|
||||
|
||||
// Observe properties
|
||||
observeProperties()
|
||||
|
||||
// Setup wakeup callback
|
||||
mpv_set_wakeup_callback(handle, { ctx in
|
||||
guard let ctx = ctx else { return }
|
||||
let instance = Unmanaged<MPVLayerRenderer>.fromOpaque(ctx).takeUnretainedValue()
|
||||
instance.processEvents()
|
||||
}, Unmanaged.passUnretained(self).toOpaque())
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if isStopping { return }
|
||||
if !isRunning, mpv == nil { return }
|
||||
isRunning = false
|
||||
isStopping = true
|
||||
|
||||
queue.sync { [weak self] in
|
||||
guard let self, let handle = self.mpv else { return }
|
||||
|
||||
mpv_set_wakeup_callback(handle, nil, nil)
|
||||
mpv_terminate_destroy(handle)
|
||||
self.mpv = nil
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if #available(iOS 18.0, *) {
|
||||
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||
} else {
|
||||
self.displayLayer.flushAndRemoveImage()
|
||||
}
|
||||
}
|
||||
|
||||
isStopping = false
|
||||
}
|
||||
|
||||
func load(
|
||||
url: URL,
|
||||
with preset: PlayerPreset,
|
||||
headers: [String: String]? = nil,
|
||||
startPosition: Double? = nil,
|
||||
externalSubtitles: [String]? = nil,
|
||||
initialSubtitleId: Int? = nil,
|
||||
initialAudioId: Int? = nil
|
||||
) {
|
||||
currentPreset = preset
|
||||
currentURL = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?? []
|
||||
self.initialSubtitleId = initialSubtitleId
|
||||
self.initialAudioId = initialAudioId
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isLoading = true
|
||||
self.isReadyToSeek = false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangeLoading: true)
|
||||
}
|
||||
|
||||
guard let handle = self.mpv else { return }
|
||||
|
||||
self.apply(commands: preset.commands, on: handle)
|
||||
// Stop previous playback before loading new file
|
||||
self.command(handle, ["stop"])
|
||||
self.updateHTTPHeaders(headers)
|
||||
// Set start position
|
||||
if let startPos = startPosition, startPos > 0 {
|
||||
self.setProperty(name: "start", value: String(format: "%.2f", startPos))
|
||||
} else {
|
||||
self.setProperty(name: "start", value: "0")
|
||||
}
|
||||
// Set initial audio track if specified
|
||||
if let audioId = self.initialAudioId, audioId > 0 {
|
||||
self.setAudioTrack(audioId)
|
||||
}
|
||||
// Set initial subtitle track if no external subs
|
||||
if self.pendingExternalSubtitles.isEmpty {
|
||||
if let subId = self.initialSubtitleId {
|
||||
self.setSubtitleTrack(subId)
|
||||
} else {
|
||||
self.disableSubtitles()
|
||||
}
|
||||
} else {
|
||||
self.disableSubtitles()
|
||||
}
|
||||
let target = url.isFileURL ? url.path : url.absoluteString
|
||||
self.command(handle, ["loadfile", target, "replace"])
|
||||
}
|
||||
}
|
||||
|
||||
func reloadCurrentItem() {
|
||||
guard let url = currentURL, let preset = currentPreset else { return }
|
||||
load(url: url, with: preset, headers: currentHeaders)
|
||||
}
|
||||
|
||||
func applyPreset(_ preset: PlayerPreset) {
|
||||
currentPreset = preset
|
||||
guard let handle = mpv else { return }
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.apply(commands: preset.commands, on: handle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Property Helpers
|
||||
|
||||
private func setOption(name: String, value: String) {
|
||||
guard let handle = mpv else { return }
|
||||
checkError(mpv_set_option_string(handle, name, value))
|
||||
}
|
||||
|
||||
private func setProperty(name: String, value: String) {
|
||||
guard let handle = mpv else { return }
|
||||
let status = mpv_set_property_string(handle, name, value)
|
||||
if status < 0 {
|
||||
Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn")
|
||||
}
|
||||
}
|
||||
|
||||
private func clearProperty(name: String) {
|
||||
guard let handle = mpv else { return }
|
||||
let status = mpv_set_property(handle, name, MPV_FORMAT_NONE, nil)
|
||||
if status < 0 {
|
||||
Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHTTPHeaders(_ headers: [String: String]?) {
|
||||
guard let headers, !headers.isEmpty else {
|
||||
clearProperty(name: "http-header-fields")
|
||||
return
|
||||
}
|
||||
|
||||
let headerString = headers
|
||||
.map { key, value in "\(key): \(value)" }
|
||||
.joined(separator: "\r\n")
|
||||
setProperty(name: "http-header-fields", value: headerString)
|
||||
}
|
||||
|
||||
private func observeProperties() {
|
||||
guard let handle = mpv else { return }
|
||||
let properties: [(String, mpv_format)] = [
|
||||
("duration", MPV_FORMAT_DOUBLE),
|
||||
("time-pos", MPV_FORMAT_DOUBLE),
|
||||
("pause", MPV_FORMAT_FLAG),
|
||||
("track-list/count", MPV_FORMAT_INT64),
|
||||
("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
]
|
||||
for (name, format) in properties {
|
||||
mpv_observe_property(handle, 0, name, format)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(commands: [[String]], on handle: OpaquePointer) {
|
||||
for command in commands {
|
||||
guard !command.isEmpty else { continue }
|
||||
self.command(handle, command)
|
||||
}
|
||||
}
|
||||
|
||||
private func command(_ handle: OpaquePointer, _ args: [String]) {
|
||||
guard !args.isEmpty else { return }
|
||||
_ = withCStringArray(args) { pointer in
|
||||
mpv_command_async(handle, 0, pointer)
|
||||
}
|
||||
}
|
||||
|
||||
private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 {
|
||||
guard !args.isEmpty else { return -1 }
|
||||
return withCStringArray(args) { pointer in
|
||||
mpv_command(handle, pointer)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
Logger.shared.log("MPV API error: \(String(cString: mpv_error_string(status)))", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Handling
|
||||
|
||||
private func processEvents() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
while self.mpv != nil && !self.isStopping {
|
||||
guard let handle = self.mpv,
|
||||
let eventPointer = mpv_wait_event(handle, 0) else { return }
|
||||
let event = eventPointer.pointee
|
||||
if event.event_id == MPV_EVENT_NONE { break }
|
||||
self.handleEvent(event)
|
||||
if event.event_id == MPV_EVENT_SHUTDOWN { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ event: mpv_event) {
|
||||
switch event.event_id {
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
// Add external subtitles now that the file is loaded
|
||||
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
|
||||
if hadExternalSubs, let handle = mpv {
|
||||
for subUrl in pendingExternalSubtitles {
|
||||
command(handle, ["sub-add", subUrl])
|
||||
}
|
||||
pendingExternalSubtitles = []
|
||||
// Set subtitle after external subs are added
|
||||
if let subId = initialSubtitleId {
|
||||
setSubtitleTrack(subId)
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
}
|
||||
if !isReadyToSeek {
|
||||
isReadyToSeek = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didBecomeReadyToSeek: true)
|
||||
}
|
||||
}
|
||||
// Notify loading ended
|
||||
if isLoading {
|
||||
isLoading = false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangeLoading: false)
|
||||
}
|
||||
}
|
||||
|
||||
case MPV_EVENT_SEEK:
|
||||
// Seek started - show loading indicator
|
||||
if !isLoading {
|
||||
isLoading = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangeLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
// Video playback has started/restarted (including after seek)
|
||||
if isLoading {
|
||||
isLoading = false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangeLoading: false)
|
||||
}
|
||||
}
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name {
|
||||
let name = String(cString: property)
|
||||
refreshProperty(named: name, event: event)
|
||||
}
|
||||
|
||||
case MPV_EVENT_SHUTDOWN:
|
||||
Logger.shared.log("mpv shutdown", type: "Warn")
|
||||
|
||||
case MPV_EVENT_LOG_MESSAGE:
|
||||
if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) {
|
||||
let component = String(cString: logMessagePointer.pointee.prefix)
|
||||
let text = String(cString: logMessagePointer.pointee.text)
|
||||
let lower = text.lowercased()
|
||||
if lower.contains("error") {
|
||||
Logger.shared.log("mpv[\(component)] \(text)", type: "Error")
|
||||
} else if lower.contains("warn") || lower.contains("warning") {
|
||||
Logger.shared.log("mpv[\(component)] \(text)", type: "Warn")
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshProperty(named name: String, event: mpv_event) {
|
||||
guard let handle = mpv else { return }
|
||||
switch name {
|
||||
case "duration":
|
||||
var value = Double(0)
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||
if status >= 0 {
|
||||
cachedDuration = value
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||
}
|
||||
}
|
||||
case "time-pos":
|
||||
var value = Double(0)
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||
if status >= 0 {
|
||||
cachedPosition = value
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||
}
|
||||
}
|
||||
case "pause":
|
||||
var flag: Int32 = 0
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||
if status >= 0 {
|
||||
let newPaused = flag != 0
|
||||
if newPaused != isPaused {
|
||||
isPaused = newPaused
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangePause: self.isPaused)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "paused-for-cache":
|
||||
var flag: Int32 = 0
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||
if status >= 0 {
|
||||
let buffering = flag != 0
|
||||
if buffering != isLoading {
|
||||
isLoading = buffering
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didChangeLoading: buffering)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "track-list/count":
|
||||
var trackCount: Int64 = 0
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount)
|
||||
if status >= 0 && trackCount > 0 {
|
||||
Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func getStringProperty(handle: OpaquePointer, name: String) -> String? {
|
||||
var result: String?
|
||||
if let cString = mpv_get_property_string(handle, name) {
|
||||
result = String(cString: cString)
|
||||
mpv_free(cString)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func getProperty<T>(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 {
|
||||
return withUnsafeMutablePointer(to: &value) { mutablePointer in
|
||||
return mpv_get_property(handle, name, format, mutablePointer)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func withCStringArray<R>(_ args: [String], body: (UnsafeMutablePointer<UnsafePointer<CChar>?>?) -> R) -> R {
|
||||
var cStrings = [UnsafeMutablePointer<CChar>?]()
|
||||
cStrings.reserveCapacity(args.count + 1)
|
||||
for s in args {
|
||||
cStrings.append(strdup(s))
|
||||
}
|
||||
cStrings.append(nil)
|
||||
defer {
|
||||
for ptr in cStrings where ptr != nil {
|
||||
free(ptr)
|
||||
}
|
||||
}
|
||||
|
||||
return cStrings.withUnsafeMutableBufferPointer { buffer in
|
||||
return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer<CChar>?.self, capacity: buffer.count) { rebound in
|
||||
return body(UnsafeMutablePointer(mutating: rebound))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
func play() {
|
||||
setProperty(name: "pause", value: "no")
|
||||
}
|
||||
|
||||
func pausePlayback() {
|
||||
setProperty(name: "pause", value: "yes")
|
||||
}
|
||||
|
||||
func togglePause() {
|
||||
if isPaused { play() } else { pausePlayback() }
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
guard let handle = mpv else { return }
|
||||
let clamped = max(0, seconds)
|
||||
cachedPosition = clamped
|
||||
commandSync(handle, ["seek", String(clamped), "absolute"])
|
||||
}
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
guard let handle = mpv else { return }
|
||||
let newPosition = max(0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
commandSync(handle, ["seek", String(seconds), "relative"])
|
||||
}
|
||||
|
||||
/// Sync timebase - no-op for vo_avfoundation (mpv handles timing)
|
||||
func syncTimebase() {
|
||||
// vo_avfoundation manages its own timebase
|
||||
}
|
||||
|
||||
func setSpeed(_ speed: Double) {
|
||||
playbackSpeed = speed
|
||||
setProperty(name: "speed", value: String(speed))
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
guard let handle = mpv else { return 1.0 }
|
||||
var speed: Double = 1.0
|
||||
getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed)
|
||||
return speed
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
guard let handle = mpv else {
|
||||
Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn")
|
||||
return []
|
||||
}
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
var trackCount: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
|
||||
|
||||
for i in 0..<trackCount {
|
||||
guard let trackType = getStringProperty(handle: handle, name: "track-list/\(i)/type"),
|
||||
trackType == "sub" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
|
||||
track["title"] = title
|
||||
}
|
||||
|
||||
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||
track["lang"] = lang
|
||||
}
|
||||
|
||||
var selected: Int32 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||
track["selected"] = selected != 0
|
||||
|
||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
Logger.shared.log("getSubtitleTracks: returning \(tracks.count) subtitle tracks", type: "Info")
|
||||
return tracks
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackId: Int) {
|
||||
Logger.shared.log("setSubtitleTrack: setting sid to \(trackId)", type: "Info")
|
||||
guard mpv != nil else {
|
||||
Logger.shared.log("setSubtitleTrack: mpv handle is nil!", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
if trackId < 0 {
|
||||
setProperty(name: "sid", value: "no")
|
||||
} else {
|
||||
setProperty(name: "sid", value: String(trackId))
|
||||
}
|
||||
}
|
||||
|
||||
func disableSubtitles() {
|
||||
setProperty(name: "sid", value: "no")
|
||||
}
|
||||
|
||||
func getCurrentSubtitleTrack() -> Int {
|
||||
guard let handle = mpv else { return 0 }
|
||||
var sid: Int64 = 0
|
||||
getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid)
|
||||
return Int(sid)
|
||||
}
|
||||
|
||||
func addSubtitleFile(url: String, select: Bool = true) {
|
||||
guard let handle = mpv else { return }
|
||||
let flag = select ? "select" : "cached"
|
||||
commandSync(handle, ["sub-add", url, flag])
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
func setSubtitlePosition(_ position: Int) {
|
||||
setProperty(name: "sub-pos", value: String(position))
|
||||
}
|
||||
|
||||
func setSubtitleScale(_ scale: Double) {
|
||||
setProperty(name: "sub-scale", value: String(scale))
|
||||
}
|
||||
|
||||
func setSubtitleMarginY(_ margin: Int) {
|
||||
setProperty(name: "sub-margin-y", value: String(margin))
|
||||
}
|
||||
|
||||
func setSubtitleAlignX(_ alignment: String) {
|
||||
setProperty(name: "sub-align-x", value: alignment)
|
||||
}
|
||||
|
||||
func setSubtitleAlignY(_ alignment: String) {
|
||||
setProperty(name: "sub-align-y", value: alignment)
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ size: Int) {
|
||||
setProperty(name: "sub-font-size", value: String(size))
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
guard let handle = mpv else {
|
||||
Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn")
|
||||
return []
|
||||
}
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
var trackCount: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
|
||||
|
||||
for i in 0..<trackCount {
|
||||
guard let trackType = getStringProperty(handle: handle, name: "track-list/\(i)/type"),
|
||||
trackType == "audio" else { continue }
|
||||
|
||||
var trackId: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
||||
|
||||
var track: [String: Any] = ["id": Int(trackId)]
|
||||
|
||||
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
|
||||
track["title"] = title
|
||||
}
|
||||
|
||||
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
|
||||
track["lang"] = lang
|
||||
}
|
||||
|
||||
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||
track["codec"] = codec
|
||||
}
|
||||
|
||||
var channels: Int64 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/audio-channels", format: MPV_FORMAT_INT64, value: &channels)
|
||||
if channels > 0 {
|
||||
track["channels"] = Int(channels)
|
||||
}
|
||||
|
||||
var selected: Int32 = 0
|
||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||
track["selected"] = selected != 0
|
||||
|
||||
Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
||||
tracks.append(track)
|
||||
}
|
||||
|
||||
Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info")
|
||||
return tracks
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
guard mpv != nil else {
|
||||
Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn")
|
||||
return
|
||||
}
|
||||
Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info")
|
||||
setProperty(name: "aid", value: String(trackId))
|
||||
}
|
||||
|
||||
func getCurrentAudioTrack() -> Int {
|
||||
guard let handle = mpv else { return 0 }
|
||||
var aid: Int64 = 0
|
||||
getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid)
|
||||
return Int(aid)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,16 +13,21 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit', '~> 0.40.0'
|
||||
s.dependency 'MPVKit-GPL'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
# Strip debug symbols to avoid DWARF errors from MPVKit
|
||||
'VALID_ARCHS' => 'arm64',
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
s.user_target_xcconfig = {
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
|
||||
@@ -164,6 +164,15 @@ public class MpvPlayerModule: Module {
|
||||
return view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video scaling functions
|
||||
AsyncFunction("setZoomedToFill") { (view: MpvPlayerView, zoomed: Bool) in
|
||||
view.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
AsyncFunction("isZoomedToFill") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ struct VideoLoadConfig {
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class MpvPlayerView: ExpoView {
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var renderer: MPVSoftwareRenderer?
|
||||
private var renderer: MPVLayerRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
|
||||
@@ -52,6 +52,7 @@ class MpvPlayerView: ExpoView {
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
|
||||
private var _isZoomedToFill: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
@@ -83,7 +84,7 @@ class MpvPlayerView: ExpoView {
|
||||
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
renderer = MPVSoftwareRenderer(displayLayer: displayLayer)
|
||||
renderer = MPVLayerRenderer(displayLayer: displayLayer)
|
||||
renderer?.delegate = self
|
||||
|
||||
// Setup PiP
|
||||
@@ -148,12 +149,14 @@ class MpvPlayerView: ExpoView {
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
@@ -267,6 +270,17 @@ class MpvPlayerView: ExpoView {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
func setZoomedToFill(_ zoomed: Bool) {
|
||||
_isZoomedToFill = zoomed
|
||||
displayLayer.videoGravity = zoomed ? .resizeAspectFill : .resizeAspect
|
||||
}
|
||||
|
||||
func isZoomedToFill() -> Bool {
|
||||
return _isZoomedToFill
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
@@ -274,18 +288,18 @@ class MpvPlayerView: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPVSoftwareRendererDelegate
|
||||
// MARK: - MPVLayerRendererDelegate
|
||||
|
||||
extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
func renderer(_: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||
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 }
|
||||
// Only update PiP state when PiP is active
|
||||
// Update PiP current time for progress bar
|
||||
if self.pipController?.isPictureInPictureActive == true {
|
||||
self.pipController?.updatePlaybackState()
|
||||
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
|
||||
}
|
||||
|
||||
self.onProgress([
|
||||
@@ -296,21 +310,23 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) {
|
||||
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,
|
||||
])
|
||||
// Note: Don't call updatePlaybackState() here to avoid flicker
|
||||
// PiP queries pipControllerIsPlaying when it needs the state
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) {
|
||||
func renderer(_: MPVLayerRenderer, didChangeLoading isLoading: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
@@ -319,7 +335,7 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) {
|
||||
func renderer(_: MPVLayerRenderer, didBecomeReadyToSeek: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
@@ -328,7 +344,7 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVSoftwareRenderer, didBecomeTracksReady: Bool) {
|
||||
func renderer(_: MPVLayerRenderer, didBecomeTracksReady: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onTracksReady([:])
|
||||
@@ -343,12 +359,14 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP will start")
|
||||
// Sync timebase before PiP starts for smooth transition
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
// Set current time for PiP progress bar
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
pipController?.updatePlaybackState()
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -371,12 +389,16 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
print("PiP play requested")
|
||||
play()
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
}
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
print("PiP pause requested")
|
||||
pause()
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
||||
@@ -394,4 +416,8 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||
return getDuration()
|
||||
}
|
||||
|
||||
func pipControllerCurrentPosition(_ controller: PiPController) -> Double {
|
||||
return getCurrentPosition()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ protocol PiPControllerDelegate: AnyObject {
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double
|
||||
func pipControllerCurrentPosition(_ controller: PiPController) -> Double
|
||||
}
|
||||
|
||||
final class PiPController: NSObject {
|
||||
@@ -20,6 +21,13 @@ final class PiPController: NSObject {
|
||||
|
||||
weak var delegate: PiPControllerDelegate?
|
||||
|
||||
// Timebase for PiP progress tracking
|
||||
private var timebase: CMTimebase?
|
||||
|
||||
// Track current time for PiP progress
|
||||
private var currentTime: CMTime = .zero
|
||||
private var currentDuration: Double = 0
|
||||
|
||||
var isPictureInPictureSupported: Bool {
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
@@ -35,9 +43,29 @@ final class PiPController: NSObject {
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
||||
super.init()
|
||||
setupTimebase()
|
||||
setupPictureInPicture()
|
||||
}
|
||||
|
||||
private func setupTimebase() {
|
||||
// Create a timebase for tracking playback time
|
||||
var newTimebase: CMTimebase?
|
||||
let status = CMTimebaseCreateWithSourceClock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sourceClock: CMClockGetHostTimeClock(),
|
||||
timebaseOut: &newTimebase
|
||||
)
|
||||
|
||||
if status == noErr, let tb = newTimebase {
|
||||
timebase = tb
|
||||
CMTimebaseSetTime(tb, time: .zero)
|
||||
CMTimebaseSetRate(tb, rate: 0) // Start paused
|
||||
|
||||
// Set the control timebase on the display layer
|
||||
sampleBufferDisplayLayer?.controlTimebase = tb
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPictureInPicture() {
|
||||
guard isPictureInPictureSupported,
|
||||
let displayLayer = sampleBufferDisplayLayer else {
|
||||
@@ -81,6 +109,9 @@ final class PiPController: NSObject {
|
||||
}
|
||||
|
||||
func updatePlaybackState() {
|
||||
// Only invalidate when PiP is active to avoid "no context menu visible" warnings
|
||||
guard isPictureInPictureActive else { return }
|
||||
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
@@ -89,6 +120,36 @@ final class PiPController: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current playback time for PiP progress display
|
||||
func setCurrentTime(_ time: CMTime) {
|
||||
currentTime = time
|
||||
|
||||
// Update the timebase to reflect current position
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetTime(tb, time: time)
|
||||
}
|
||||
|
||||
// Only invalidate when PiP is active to avoid unnecessary updates
|
||||
if isPictureInPictureActive {
|
||||
updatePlaybackState()
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the current playback time from seconds
|
||||
func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) {
|
||||
guard seconds >= 0 else { return }
|
||||
currentDuration = duration
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 1000)
|
||||
setCurrentTime(time)
|
||||
}
|
||||
|
||||
/// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused)
|
||||
func setPlaybackRate(_ rate: Float) {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
Reference in New Issue
Block a user