yattee/Model/Player/Backends/MPVClient.swift
Toni Förster b54044cbc5
HLS: set target bitrate / AVPlayer: higher resolution
HLS: try matching the set resolution. This works okay with AVPlayer. With MPV it is hit and miss, most of the time MPV targets the highest available bitrate, instead of the set bitrate.

AVPlayer now supports higher resolution up to 1080p60.
2024-05-13 07:54:24 +02:00

530 lines
16 KiB
Swift

import CoreMedia
import Defaults
import Foundation
import Logging
import MPVKit
#if !os(macOS)
import Siesta
import UIKit
#endif
final class MPVClient: ObservableObject {
static var logFile: URL {
YatteeApp.logsDirectory.appendingPathComponent("yattee-\(YatteeApp.build)-mpv-log.txt")
}
private var logger = Logger(label: "mpv-client")
var mpv: OpaquePointer!
var mpvGL: OpaquePointer!
var queue: DispatchQueue!
#if os(macOS)
var layer: VideoLayer!
var link: CVDisplayLink!
#else
var glView: MPVOGLView!
#endif
var backend: MPVBackend!
var seeking = false
func create(frame: CGRect? = nil) {
#if !os(macOS)
if let frame {
glView = MPVOGLView(frame: frame)
}
#endif
mpv = mpv_create()
if mpv == nil {
print("failed creating context\n")
exit(1)
}
if Defaults[.mpvEnableLogging] {
checkError(mpv_set_option_string(
mpv,
"log-file",
Self.logFile.absoluteString.replacingOccurrences(of: "file://", with: "")
))
checkError(mpv_request_log_messages(mpv, "debug"))
} else {
#if DEBUG
checkError(mpv_request_log_messages(mpv, "debug"))
#else
checkError(mpv_request_log_messages(mpv, "no"))
#endif
}
#if os(macOS)
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
checkError(mpv_initialize(mpv))
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
var initParams = mpv_opengl_init_params(
get_proc_address: getProcAddress,
get_proc_address_ctx: nil
)
queue = DispatchQueue(label: "mpv")
withUnsafeMutablePointer(to: &initParams) { initParams in
var params = [
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
mpv_render_param()
]
if mpv_render_context_create(&mpvGL, mpv, &params) < 0 {
print("failed to initialize mpv GL context")
exit(1)
}
#if os(macOS)
mpv_render_context_set_update_callback(
mpvGL,
glUpdate,
UnsafeMutableRawPointer(Unmanaged.passUnretained(layer).toOpaque())
)
#else
glView.mpvGL = UnsafeMutableRawPointer(mpvGL)
mpv_render_context_set_update_callback(
mpvGL,
glUpdate(_:),
UnsafeMutableRawPointer(Unmanaged.passUnretained(glView).toOpaque())
)
#endif
}
mpv_set_wakeup_callback(mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, "core-idle", MPV_FORMAT_FLAG)
}
func readEvents() {
queue?.async { [self] in
while self.mpv != nil {
let event = mpv_wait_event(self.mpv, 0)
if event!.pointee.event_id == MPV_EVENT_NONE {
break
}
backend?.handle(event)
}
}
}
func loadFile(
_ url: URL,
audio: URL? = nil,
bitrate: Int? = nil,
kind: Stream.Kind,
sub: URL? = nil,
time: CMTime? = nil,
forceSeekable: Bool = false,
completionHandler: ((Int32) -> Void)? = nil
) {
var args = [url.absoluteString]
var options = [String]()
args.append("replace")
if let time, time.seconds > 0 {
options.append("start=\(Int(time.seconds))")
}
if let audioURL = audio?.absoluteString {
options.append("audio-files-append=\"\(audioURL)\"")
}
if let subURL = sub?.absoluteString {
options.append("sub-files-append=\"\(subURL)\"")
}
if forceSeekable {
options.append("force-seekable=yes")
// this is needed for peertube?
// options.append("stream-lavf-o=seekable=0")
}
if !options.isEmpty {
args.append(options.joined(separator: ","))
}
if kind == .hls, bitrate != 0 {
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
}
command("loadfile", args: args, returnValueCallback: completionHandler)
}
func play() {
setFlagAsync("pause", false)
}
func pause() {
setFlagAsync("pause", true)
}
func togglePlay() {
command("cycle", args: ["pause"])
}
func stop() {
command("stop")
}
var currentTime: CMTime {
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("time-pos"))
}
var frameDropCount: Int {
mpv.isNil ? 0 : getInt("frame-drop-count")
}
var outputFps: Double {
mpv.isNil ? 0.0 : getDouble("estimated-vf-fps")
}
var hwDecoder: String {
mpv.isNil ? "unknown" : (getString("hwdec-current") ?? "unknown")
}
var bufferingState: Double {
mpv.isNil ? 0.0 : getDouble("cache-buffering-state")
}
var cacheDuration: Double {
mpv.isNil ? 0.0 : getDouble("demuxer-cache-duration")
}
var videoFormat: String {
stringOrUnknown("video-format")
}
var videoCodec: String {
stringOrUnknown("video-codec")
}
var currentVo: String {
stringOrUnknown("current-vo")
}
var width: String {
stringOrUnknown("width")
}
var height: String {
stringOrUnknown("height")
}
var videoBitrate: Double {
mpv.isNil ? 0.0 : getDouble("video-bitrate")
}
var audioFormat: String {
stringOrUnknown("audio-params/format")
}
var audioCodec: String {
stringOrUnknown("audio-codec")
}
var currentAo: String {
stringOrUnknown("current-ao")
}
var audioChannels: String {
stringOrUnknown("audio-params/channels")
}
var audioSampleRate: String {
stringOrUnknown("audio-params/samplerate")
}
var aspectRatio: Double {
guard !mpv.isNil else { return VideoPlayerView.defaultAspectRatio }
let aspect = getDouble("video-params/aspect")
return aspect.isZero ? VideoPlayerView.defaultAspectRatio : aspect
}
var dh: Double {
let defaultDh = 500.0
guard !mpv.isNil else { return defaultDh }
let dh = getDouble("video-params/dh")
return dh.isZero ? defaultDh : dh
}
var duration: CMTime {
CMTime.secondsInDefaultTimescale(mpv.isNil ? -1 : getDouble("duration"))
}
var pausedForCache: Bool {
mpv.isNil ? false : getFlag("paused-for-cache")
}
var eofReached: Bool {
mpv.isNil ? false : getFlag("eof-reached")
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else {
logger.warning("ignoring seek, another in progress")
return
}
seeking = true
command("seek", args: [String(time.seconds)]) { [weak self] _ in
self?.seeking = false
completionHandler?(true)
}
}
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else {
logger.warning("ignoring seek, another in progress")
return
}
seeking = true
command("seek", args: [String(time.seconds), "absolute"]) { [weak self] _ in
self?.seeking = false
completionHandler?(true)
}
}
func setSize(_ width: Double, _ height: Double) {
let roundedWidth = width.rounded()
let roundedHeight = height.rounded()
guard width > 0, height > 0 else {
return
}
logger.info("setting player size to \(roundedWidth),\(roundedHeight)")
#if !os(macOS)
guard roundedWidth <= UIScreen.main.bounds.width, roundedHeight <= UIScreen.main.bounds.height else {
logger.info("requested size is greater than screen size, ignoring")
logger.info("width: \(roundedWidth) <= \(UIScreen.main.bounds.width)")
logger.info("height: \(roundedHeight) <= \(UIScreen.main.bounds.height)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let model = self.backend.model
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
var insets = 0.0
#if os(iOS)
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeAreaModel.shared.safeArea.bottom : 0
#endif
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0)
UIView.animate(withDuration: 0.2, animations: {
self.glView?.frame = CGRect(x: 0, y: offsetY, width: roundedWidth, height: height)
}) { completion in
if completion {
self.logger.info("setting player size to \(roundedWidth),\(roundedHeight) FINISHED")
self.glView?.queue.async {
self.glView.display()
}
self.backend?.controls.objectWillChange.send()
}
}
}
#endif
}
func setNeedsDrawing(_ needsDrawing: Bool) {
logger.info("needs drawing: \(needsDrawing)")
#if !os(macOS)
glView?.needsDrawing = needsDrawing
#endif
}
func command(
_ command: String,
args: [String?] = [],
checkForErrors: Bool = true,
returnValueCallback: ((Int32) -> Void)? = nil
) {
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!))
}
}
logger.info("\(command) -- \(args)")
let returnValue = mpv_command(mpv, &cargs)
if checkForErrors {
checkError(returnValue)
}
if let cb = returnValueCallback {
cb(returnValue)
}
}
func addVideoTrack(_ url: URL) {
command("video-add", args: [url.absoluteString])
}
func addSubTrack(_ url: URL) {
command("sub-add", args: [url.absoluteString])
}
func removeSubs() {
command("sub-remove")
}
func setVideoToAuto() {
setString("video", "1")
}
func setVideoToNo() {
setString("video", "no")
}
var tracksCount: Int {
Int(getString("track-list/count") ?? "-1") ?? -1
}
private func getFlag(_ name: String) -> Bool {
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
private func setFlagAsync(_ name: String, _ flag: Bool) {
guard mpv != nil else { return }
var data: Int = flag ? 1 : 0
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)
}
func setDoubleAsync(_ name: String, _ value: Double) {
guard mpv != nil else { return }
var data = value
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_DOUBLE, &data)
}
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 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)
}
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 setString(_ name: String, _ value: String) {
guard mpv != nil else { return }
mpv_set_property_string(mpv, name, value)
}
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
if !args.isEmpty, args.last == nil {
fatalError("Command do not need a nil suffix")
}
var strArgs = args
strArgs.insert(command, at: 0)
strArgs.append(nil)
return strArgs
}
private func checkError(_ status: CInt) {
if status < 0 {
logger.error(.init(stringLiteral: "MPV API error: \(String(cString: mpv_error_string(status)))\n"))
}
}
private func stringOrUnknown(_ name: String) -> String {
mpv.isNil ? "unknown" : (getString(name) ?? "unknown")
}
private var machine: String {
var systeminfo = utsname()
uname(&systeminfo)
return withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
let data = Data(bufPtr)
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
return String(data: data[0 ... lastIndex], encoding: .isoLatin1)!
}
return String(data: data, encoding: .isoLatin1)!
}
}
}
#if os(macOS)
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengl" as CFString)
return CFBundleGetFunctionPointerForName(identifier, symbolName)
}
func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
let videoLayer = unsafeBitCast(ctx, to: VideoLayer.self)
videoLayer.client?.queue?.async {
if !videoLayer.isAsynchronous {
videoLayer.display()
}
}
}
#else
func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
return CFBundleGetFunctionPointerForName(identifier, symbolName)
}
private func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
let glView = unsafeBitCast(ctx, to: MPVOGLView.self)
guard glView.needsDrawing else {
return
}
glView.queue.async {
glView.display()
}
}
#endif
private func wakeUp(_ context: UnsafeMutableRawPointer?) {
let client = unsafeBitCast(context, to: MPVClient.self)
client.readEvents()
}