NuvioStreaming/iosApp/iosApp/Player/MPVPlayerBridge.swift

652 lines
25 KiB
Swift

import Foundation
import UIKit
import Libmpv
import ComposeApp
// MARK: - Player Bridge Implementation (Kotlin protocol conformance)
final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
private var playerVC: MPVPlayerViewController?
func createPlayerViewController() -> UIViewController {
let vc = MPVPlayerViewController()
self.playerVC = vc
return vc
}
func loadFile(url: String) { playerVC?.loadFile(url) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) {
playerVC?.loadFile(
videoUrl,
audioUrl: audioUrl,
requestHeaders: parseRequestHeaders(headersJson)
)
}
func play() { playerVC?.playPlayback() }
func pause() { playerVC?.pausePlayback() }
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
func retry() { playerVC?.retryPlayback() }
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
// Audio tracks
func getAudioTrackCount() -> Int32 { Int32(playerVC?.audioTracks.count ?? 0) }
func getAudioTrackIndex(at: Int32) -> Int32 {
guard let t = playerVC?.audioTracks, Int(at) < t.count else { return 0 }
return Int32(t[Int(at)].index)
}
func getAudioTrackId(at: Int32) -> String {
guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "0" }
return "\(t[Int(at)].id)"
}
func getAudioTrackLabel(at: Int32) -> String {
guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "" }
return t[Int(at)].title
}
func getAudioTrackLang(at: Int32) -> String {
guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "" }
return t[Int(at)].lang
}
func isAudioTrackSelected(at: Int32) -> Bool {
guard let t = playerVC?.audioTracks, Int(at) < t.count else { return false }
return t[Int(at)].selected
}
// Subtitle tracks
func getSubtitleTrackCount() -> Int32 { Int32(playerVC?.subtitleTracks.count ?? 0) }
func getSubtitleTrackIndex(at: Int32) -> Int32 {
guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return 0 }
return Int32(t[Int(at)].index)
}
func getSubtitleTrackId(at: Int32) -> String {
guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "0" }
return "\(t[Int(at)].id)"
}
func getSubtitleTrackLabel(at: Int32) -> String {
guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "" }
return t[Int(at)].title
}
func getSubtitleTrackLang(at: Int32) -> String {
guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "" }
return t[Int(at)].lang
}
func isSubtitleTrackSelected(at: Int32) -> Bool {
guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return false }
return t[Int(at)].selected
}
func selectAudioTrack(trackId: Int32) { playerVC?.selectAudio(Int(trackId)) }
func selectSubtitleTrack(trackId: Int32) { playerVC?.selectSubtitle(Int(trackId)) }
func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) }
func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() }
func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) }
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int32) {
playerVC?.applySubtitleStyle(
textColor: textColor,
outlineSize: outlineSize,
fontSize: fontSize,
subPos: Int(subPos)
)
}
// State - refreshes position from mpv on each call (polled from Kotlin every 250ms)
func getIsLoading() -> Bool { playerVC?.refreshPlaybackState(); return playerVC?.isPlayerLoading ?? true }
func getIsPlaying() -> Bool { return playerVC?.isPlayerPlaying ?? false }
func getIsEnded() -> Bool { return playerVC?.isPlayerEnded ?? false }
func getDurationMs() -> Int64 { return playerVC?.durationMs ?? 0 }
func getPositionMs() -> Int64 { return playerVC?.positionMs ?? 0 }
func getBufferedMs() -> Int64 { return playerVC?.bufferedMs ?? 0 }
func getPlaybackSpeed() -> Float { playerVC?.currentSpeed ?? 1.0 }
func getErrorMessage() -> String { playerVC?.currentErrorMessage ?? "" }
func destroy() {
playerVC?.destroyPlayer()
playerVC = nil
}
private func parseRequestHeaders(_ headersJson: String?) -> [String: String] {
guard
let headersJson,
!headersJson.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let data = headersJson.data(using: .utf8),
let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return [:]
}
var headers: [String: String] = [:]
headers.reserveCapacity(raw.count)
raw.forEach { key, value in
guard let headerValue = value as? String else { return }
headers[key] = headerValue
}
return headers
}
}
// MARK: - Track Info
struct TrackInfo {
let index: Int
let id: Int
let type: String
let title: String
let lang: String
let selected: Bool
}
// MARK: - MPV Player View Controller
final class MPVPlayerViewController: UIViewController {
private let errorStateLock = NSLock()
private var metalLayer = MetalLayer()
private var mpv: OpaquePointer?
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = []
private var activeRequestHeaders: [String: String] = [:]
// Cached track lists
var audioTracks: [TrackInfo] = []
var subtitleTracks: [TrackInfo] = []
// State (polled from Kotlin every 250ms)
var isPlayerLoading: Bool = true
var isPlayerPlaying: Bool = false
var isPlayerEnded: Bool = false
var durationMs: Int64 = 0
var positionMs: Int64 = 0
var bufferedMs: Int64 = 0
var currentSpeed: Float = 1.0
var currentErrorMessage: String {
errorStateLock.lock()
defer { errorStateLock.unlock() }
return _currentErrorMessage ?? ""
}
private var _currentErrorMessage: String?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
metalLayer.frame = view.bounds
metalLayer.contentsScale = UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer)
setupMpv()
setupNotifications()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
metalLayer.frame = view.bounds
}
// MARK: - MPV Setup
private func setupMpv() {
mpv = mpv_create()
guard mpv != nil else {
print("[MPV] Failed to create mpv instance")
return
}
checkError(mpv_request_log_messages(mpv, "warn"))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &metalLayer))
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "auto"))
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no"))
checkError(mpv_set_option_string(mpv, "vulkan-async-transfer", "no"))
checkError(mpv_set_option_string(mpv, "vulkan-disable-interop", "yes"))
checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
checkError(mpv_set_option_string(mpv, "tone-mapping", "auto"))
checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no"))
checkError(mpv_initialize(mpv))
// Observe properties
mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "paused-for-cache", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "core-idle", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "eof-reached", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "seeking", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "track-list/count", MPV_FORMAT_INT64)
mpv_set_wakeup_callback(mpv, { ctx in
let vc = unsafeBitCast(ctx, to: MPVPlayerViewController.self)
vc.readEvents()
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
}
private func setupNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(enterBackground),
name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(enterForeground),
name: UIApplication.willEnterForegroundNotification, object: nil)
}
@objc private func enterBackground() {
guard mpv != nil else { return }
pausePlayback()
checkError(mpv_set_option_string(mpv, "vid", "no"))
}
@objc private func enterForeground() {
guard mpv != nil else { return }
checkError(mpv_set_option_string(mpv, "vid", "auto"))
playPlayback()
}
// MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
guard mpv != nil else { return }
clearPlaybackError()
let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders)
activeRequestHeaders = sanitizedHeaders
applyRequestHeaders(sanitizedHeaders)
isPlayerLoading = true
isPlayerEnded = false
command("loadfile", args: [urlString, "replace"])
if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
}
}
}
func playPlayback() {
guard mpv != nil else { return }
setFlag("pause", false)
}
func pausePlayback() {
guard mpv != nil else { return }
setFlag("pause", true)
}
func seekToMs(_ ms: Int64) {
guard mpv != nil else { return }
let seconds = Double(ms) / 1000.0
command("seek", args: [String(format: "%.3f", seconds), "absolute"])
}
func seekByMs(_ ms: Int64) {
guard mpv != nil else { return }
let seconds = Double(ms) / 1000.0
command("seek", args: [String(format: "%.3f", seconds), "relative"])
}
func retryPlayback() {
guard mpv != nil else { return }
if let path = getString("path") {
clearPlaybackError()
applyRequestHeaders(activeRequestHeaders)
let pos = getDouble("time-pos")
command("loadfile", args: [path, "replace"])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.command("seek", args: [String(format: "%.3f", pos), "absolute"])
}
}
}
func setSpeed(_ speed: Float) {
guard mpv != nil else { return }
var s = Double(speed)
mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s)
}
func setResize(_ mode: Int) {
guard mpv != nil else { return }
switch mode {
case 1: // Fill
checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
case 2: // Zoom
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big"))
default: // Fit
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
}
}
// MARK: - Track selection
func selectAudio(_ trackId: Int) {
guard mpv != nil else { return }
var id = Int64(trackId)
mpv_set_property(mpv, "aid", MPV_FORMAT_INT64, &id)
}
func selectSubtitle(_ trackId: Int) {
guard mpv != nil else { return }
if trackId < 0 {
checkError(mpv_set_option_string(mpv, "sid", "no"))
} else {
var id = Int64(trackId)
mpv_set_property(mpv, "sid", MPV_FORMAT_INT64, &id)
}
}
func addSubtitleUrl(_ url: String) {
guard mpv != nil else { return }
command("sub-add", args: [url, "select"])
}
func removeExternalSubtitles() {
guard mpv != nil else { return }
let count = getInt("track-list/count")
for i in stride(from: count - 1, through: 0, by: -1) {
let type = getString("track-list/\(i)/type") ?? ""
let external = getFlag("track-list/\(i)/external")
if type == "sub" && external {
let id = getInt("track-list/\(i)/id")
command("sub-remove", args: ["\(id)"], checkForErrors: false)
}
}
checkError(mpv_set_option_string(mpv, "sid", "no"))
}
func removeExternalSubtitlesAndSelect(_ trackId: Int) {
guard mpv != nil else { return }
let count = getInt("track-list/count")
for i in stride(from: count - 1, through: 0, by: -1) {
let type = getString("track-list/\(i)/type") ?? ""
let external = getFlag("track-list/\(i)/external")
if type == "sub" && external {
let id = getInt("track-list/\(i)/id")
command("sub-remove", args: ["\(id)"], checkForErrors: false)
}
}
if trackId >= 0 {
selectSubtitle(trackId)
} else {
checkError(mpv_set_option_string(mpv, "sid", "no"))
}
}
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) {
guard mpv != nil else { return }
checkError(mpv_set_property_string(mpv, "sub-ass-override", "force"))
checkError(mpv_set_property_string(mpv, "sub-color", textColor))
checkError(mpv_set_property_string(mpv, "sub-outline-color", "#000000"))
var outline = Double(outlineSize)
checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline))
var size = Double(fontSize)
checkError(mpv_set_property(mpv, "sub-font-size", MPV_FORMAT_DOUBLE, &size))
var position = Int64(subPos)
checkError(mpv_set_property(mpv, "sub-pos", MPV_FORMAT_INT64, &position))
}
func destroyPlayer() {
NotificationCenter.default.removeObserver(self)
clearPlaybackError()
guard let ctx = mpv else { return }
mpv = nil // nil first so event loop stops reading
mpv_terminate_destroy(ctx)
}
// MARK: - State Update
/// Lightweight state refresh called by Kotlin polling (every 250ms).
/// Only reads cheap scalar properties; does NOT re-enumerate tracks.
func refreshPlaybackState() {
guard mpv != nil else { return }
let duration = getDouble("duration")
let position = getDouble("time-pos")
let cached = getDouble("demuxer-cache-time")
let speed = getDouble("speed")
let paused = getFlag("pause")
let eofReached = getFlag("eof-reached")
let idle = getFlag("core-idle")
let seeking = getFlag("seeking")
let bufferingCache = getFlag("paused-for-cache")
isPlayerLoading = (idle && !paused && !eofReached) || seeking || bufferingCache
isPlayerPlaying = !paused && !idle && !eofReached
isPlayerEnded = eofReached
durationMs = Int64(duration * 1000)
positionMs = Int64(max(position, 0) * 1000)
bufferedMs = Int64(max(position + cached, 0) * 1000)
currentSpeed = Float(speed > 0 ? speed : 1.0)
}
/// Full state + track refresh called from MPV event loop on property changes.
func updateState() {
refreshPlaybackState()
refreshTracks()
}
private func refreshTracks() {
guard mpv != nil else { return }
var audio = [TrackInfo]()
var subs = [TrackInfo]()
let count = getInt("track-list/count")
var audioIdx = 0
var subIdx = 0
for i in 0..<count {
let type = getString("track-list/\(i)/type") ?? ""
let id = getInt("track-list/\(i)/id")
let title = getString("track-list/\(i)/title") ?? ""
let lang = getString("track-list/\(i)/lang") ?? ""
let selected = getFlag("track-list/\(i)/selected")
if type == "audio" {
audio.append(TrackInfo(index: audioIdx, id: id, type: type, title: title, lang: lang, selected: selected))
audioIdx += 1
} else if type == "sub" {
subs.append(TrackInfo(index: subIdx, id: id, type: type, title: title, lang: lang, selected: selected))
subIdx += 1
}
}
audioTracks = audio
subtitleTracks = subs
}
private func clearPlaybackError() {
errorStateLock.lock()
recentPlaybackLogs.removeAll(keepingCapacity: true)
_currentErrorMessage = nil
errorStateLock.unlock()
}
private func appendPlaybackLog(prefix: String, level: String, text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard level == "warn" || level == "error" || level == "fatal" else { return }
let formatted = "[\(prefix)] \(trimmed)"
errorStateLock.lock()
recentPlaybackLogs.append(formatted)
if recentPlaybackLogs.count > 4 {
recentPlaybackLogs.removeFirst(recentPlaybackLogs.count - 4)
}
errorStateLock.unlock()
}
private func setPlaybackError(_ fallback: String) {
let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
errorStateLock.lock()
var parts = recentPlaybackLogs.suffix(3)
if !trimmedFallback.isEmpty && !parts.contains(trimmedFallback) {
parts.append(trimmedFallback)
}
_currentErrorMessage = parts.isEmpty ? "Unable to play this stream." : parts.joined(separator: "\n")
errorStateLock.unlock()
}
// MARK: - Event Loop
private func readEvents() {
eventQueue.async { [weak self] in
guard let self, let mpv = self.mpv else { return }
while true {
let event = mpv_wait_event(mpv, 0)
guard let eventPtr = event else { break }
if eventPtr.pointee.event_id == MPV_EVENT_NONE { break }
switch eventPtr.pointee.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
DispatchQueue.main.async { self.updateState() }
case MPV_EVENT_FILE_LOADED:
DispatchQueue.main.async {
self.clearPlaybackError()
self.isPlayerLoading = false
self.updateState()
}
case MPV_EVENT_END_FILE:
if let data = eventPtr.pointee.data {
let endFile = UnsafePointer<mpv_event_end_file>(OpaquePointer(data)).pointee
if endFile.reason == MPV_END_FILE_REASON_ERROR {
let errorText = String(cString: mpv_error_string(endFile.error))
self.setPlaybackError("[mpv] \(errorText)")
print("[MPV] End file error: \(errorText)")
}
}
case MPV_EVENT_SHUTDOWN:
return
case MPV_EVENT_LOG_MESSAGE:
if let msg = UnsafeMutablePointer<mpv_event_log_message>(OpaquePointer(eventPtr.pointee.data)) {
let prefix = String(cString: msg.pointee.prefix!)
let level = String(cString: msg.pointee.level!)
let text = String(cString: msg.pointee.text!)
self.appendPlaybackLog(prefix: prefix, level: level, text: text)
print("[MPV][\(prefix)] \(level): \(text)", terminator: "")
}
default:
break
}
}
}
}
// MARK: - MPV Helpers
private func command(_ command: String, args: [String?] = [], checkForErrors: Bool = true) {
guard mpv != nil else { return }
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
defer { for ptr in cargs where ptr != nil { free(UnsafeMutablePointer(mutating: ptr!)) } }
let ret = mpv_command(mpv, &cargs)
if checkForErrors { checkError(ret) }
}
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
var strArgs = args
strArgs.insert(command, at: 0)
strArgs.append(nil)
return strArgs
}
private func getDouble(_ name: String) -> Double {
guard mpv != nil else { return 0.0 }
var data = Double()
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
return data
}
private func getString(_ name: String) -> String? {
guard mpv != nil else { return nil }
let cstr = mpv_get_property_string(mpv, name)
let str: String? = cstr == nil ? nil : String(cString: cstr!)
mpv_free(cstr)
return str
}
private func getFlag(_ name: String) -> Bool {
guard mpv != nil else { return false }
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
private func setFlag(_ name: String, _ flag: Bool) {
guard mpv != nil else { return }
var data: Int = flag ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
}
private func getInt(_ name: String) -> Int {
guard mpv != nil else { return 0 }
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_INT64, &data)
return Int(data)
}
private func checkError(_ status: CInt) {
if status < 0 {
print("[MPV] API error: \(String(cString: mpv_error_string(status)))")
}
}
private func sanitizeRequestHeaders(_ headers: [String: String]) -> [String: String] {
guard !headers.isEmpty else { return [:] }
var sanitized: [String: String] = [:]
sanitized.reserveCapacity(headers.count)
headers.forEach { rawKey, rawValue in
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !value.isEmpty else { return }
guard key.caseInsensitiveCompare("Range") != .orderedSame else { return }
sanitized[key] = value
}
return sanitized
}
private func applyRequestHeaders(_ headers: [String: String]) {
guard mpv != nil else { return }
if headers.isEmpty {
checkError(mpv_set_property_string(mpv, "http-header-fields", ""))
return
}
let serialized = headers
.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
.map { key, value in
let escapedValue = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: ",", with: "\\,")
return "\(key): \(escapedValue)"
}
.joined(separator: ",")
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
}
}
// MARK: - Bridge Creator (implements Kotlin protocol)
final class MPVPlayerBridgeCreator: NSObject, NuvioPlayerBridgeCreator {
func createBridge() -> any NuvioPlayerBridge {
return MPVPlayerBridgeImpl()
}
}
// MARK: - Registration (called from Swift app startup)
enum NuvioPlayerRegistration {
static func register() {
NuvioPlayerBridgeFactory.shared.registerFactory(creator: MPVPlayerBridgeCreator())
}
}