mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 21:43:41 +00:00
9e05909659
fixes #691 add `--initial-audio-sync=<yes|no>` to MPV settings. Might fix #690 but needs to be toggled by the user. We leave the standard settings. Also added links to the icons on macOS.
580 lines
18 KiB
Swift
580 lines
18 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
|
|
|
|
// CACHING //
|
|
|
|
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
|
|
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
|
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
|
|
|
|
// PLAYBACK //
|
|
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
|
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
|
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
|
|
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
|
|
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
|
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
|
|
|
// GPU //
|
|
|
|
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
|
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
|
|
|
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
|
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
|
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "0"))
|
|
|
|
#if !os(macOS)
|
|
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
|
#endif
|
|
|
|
// We set this to ordered since we use OpenGL and Apple's implementation is ancient.
|
|
checkError(mpv_set_option_string(mpv, "dither", "ordered"))
|
|
|
|
// DEMUXER //
|
|
|
|
// We request to test for lavf first and skip probing other demuxer.
|
|
checkError(mpv_set_option_string(mpv, "demuxer", "lavf"))
|
|
checkError(mpv_set_option_string(mpv, "audio-demuxer", "lavf"))
|
|
checkError(mpv_set_option_string(mpv, "sub-demuxer", "lavf"))
|
|
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
|
checkError(mpv_set_option_string(mpv, "demuxer-lavf-probe-info", Defaults[.mpvDemuxerLavfProbeInfo]))
|
|
|
|
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, ¶ms) < 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")
|
|
|
|
// needed since mpvkit 0.38.0
|
|
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
|
|
args.append("-1")
|
|
|
|
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")
|
|
}
|
|
|
|
func setSubToAuto() {
|
|
setString("sub", "auto")
|
|
}
|
|
|
|
func setSubToNo() {
|
|
setString("sub", "no")
|
|
}
|
|
|
|
func setSubFontSize(scaleSize: String) {
|
|
setString("sub-scale", scaleSize)
|
|
}
|
|
|
|
func setSubFontColor(color: String) {
|
|
setString("sub-color", color)
|
|
}
|
|
|
|
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()
|
|
}
|