MPV: improved A/V sync

- use displays refresh rate
- execute needs drawing with higher priority
- run create() with higher priority
- determine the number of threads used for rendering
- enable VSYNC and change video-sync  to display-resample
- iOS/tvOS: set new display refresh rate on change
- run setSize with higher priority
- add more options to MPVClient
- get refresh rate updates
- sync refresh rate to fps
- update CADisplayLink to current refresh rate
- update refresh rate on macOS
- Add experimental feature to sync display  with content fps

Signed-off-by: Toni Förster <toni.foerster@gmail.com>
This commit is contained in:
Toni Förster 2024-09-07 22:22:09 +02:00
parent b0264aaabe
commit 5e85fd294c
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
8 changed files with 250 additions and 34 deletions

View File

@ -13,6 +13,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
"mpvDeinterlace": Defaults[.mpvDeinterlace], "mpvDeinterlace": Defaults[.mpvDeinterlace],
"mpvHWdec": Defaults[.mpvHWdec], "mpvHWdec": Defaults[.mpvHWdec],
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo], "mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync], "mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
"showCacheStatus": Defaults[.showCacheStatus], "showCacheStatus": Defaults[.showCacheStatus],
"feedCacheSize": Defaults[.feedCacheSize] "feedCacheSize": Defaults[.feedCacheSize]

View File

@ -41,6 +41,10 @@ struct AdvancedSettingsGroupImporter {
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
} }
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
}
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool { if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
} }

View File

@ -11,6 +11,7 @@ import SwiftUI
final class MPVBackend: PlayerBackend { final class MPVBackend: PlayerBackend {
static var timeUpdateInterval = 0.5 static var timeUpdateInterval = 0.5
static var networkStateUpdateInterval = 0.1 static var networkStateUpdateInterval = 0.1
static var refreshRateUpdateInterval = 0.5
private var logger = Logger(label: "mpv-backend") private var logger = Logger(label: "mpv-backend")
@ -89,6 +90,7 @@ final class MPVBackend: PlayerBackend {
private var clientTimer: Repeater! private var clientTimer: Repeater!
private var networkStateTimer: Repeater! private var networkStateTimer: Repeater!
private var refreshRateTimer: Repeater!
private var onFileLoaded: (() -> Void)? private var onFileLoaded: (() -> Void)?
@ -184,21 +186,24 @@ final class MPVBackend: PlayerBackend {
} }
init() { init() {
// swiftlint:disable shorthand_optional_binding
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else { guard let self, self.model.activeBackend == .mpv else {
return return
} }
self.getTimeUpdates() self.getTimeUpdates()
} }
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else { guard let self, self.model.activeBackend == .mpv else {
return return
} }
self.updateNetworkState() self.updateNetworkState()
} }
// swiftlint:enable shorthand_optional_binding
refreshRateTimer = .init(interval: .seconds(Self.refreshRateUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self, self.model.activeBackend == .mpv else { return }
self.checkAndUpdateRefreshRate()
}
} }
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
@ -343,8 +348,17 @@ final class MPVBackend: PlayerBackend {
startClientUpdates() startClientUpdates()
} }
func startRefreshRateUpdates() {
refreshRateTimer.start()
}
func stopRefreshRateUpdates() {
refreshRateTimer.pause()
}
func play() { func play() {
startClientUpdates() startClientUpdates()
startRefreshRateUpdates()
if controls.presentingControls { if controls.presentingControls {
startControlsUpdates() startControlsUpdates()
@ -372,6 +386,7 @@ final class MPVBackend: PlayerBackend {
func pause() { func pause() {
stopClientUpdates() stopClientUpdates()
stopRefreshRateUpdates()
client?.pause() client?.pause()
isPaused = true isPaused = true
@ -391,6 +406,8 @@ final class MPVBackend: PlayerBackend {
} }
func stop() { func stop() {
stopClientUpdates()
stopRefreshRateUpdates()
client?.stop() client?.stop()
isPlaying = false isPlaying = false
isPaused = false isPaused = false
@ -472,6 +489,52 @@ final class MPVBackend: PlayerBackend {
} }
} }
private func checkAndUpdateRefreshRate() {
guard let screenRefreshRate = client?.getScreenRefreshRate() else {
logger.warning("Failed to get screen refresh rate.")
return
}
let contentFps = client?.currentContainerFps ?? screenRefreshRate
guard Defaults[.mpvSetRefreshToContentFPS] else {
// If the current refresh rate doesn't match the screen refresh rate, reset it
if client?.currentRefreshRate != screenRefreshRate {
client?.updateRefreshRate(to: screenRefreshRate)
client?.currentRefreshRate = screenRefreshRate
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
#endif
logger.info("Reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
}
return
}
// Adjust the refresh rate to match the content if it differs
if screenRefreshRate != contentFps {
client?.updateRefreshRate(to: contentFps)
client?.currentRefreshRate = contentFps
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: contentFps)
#endif
logger.info("Adjusted screen refresh rate to match content: \(contentFps) Hz")
} else if client?.currentRefreshRate != screenRefreshRate {
// Ensure the refresh rate is set back to the screen's rate if no adjustment is needed
client?.updateRefreshRate(to: screenRefreshRate)
client?.currentRefreshRate = screenRefreshRate
#if !os(macOS)
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
#endif
logger.info("Checked and reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
}
}
#if !os(macOS)
private func notifyViewToUpdateDisplayLink(with refreshRate: Int) {
NotificationCenter.default.post(name: .updateDisplayLinkFrameRate, object: nil, userInfo: ["refreshRate": refreshRate])
}
#endif
func handle(_ event: UnsafePointer<mpv_event>!) { func handle(_ event: UnsafePointer<mpv_event>!) {
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))")) logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))

View File

@ -6,6 +6,8 @@ import Logging
#if !os(macOS) #if !os(macOS)
import Siesta import Siesta
import UIKit import UIKit
#else
import AppKit
#endif #endif
final class MPVClient: ObservableObject { final class MPVClient: ObservableObject {
@ -29,6 +31,7 @@ final class MPVClient: ObservableObject {
var backend: MPVBackend! var backend: MPVBackend!
var seeking = false var seeking = false
var currentRefreshRate = 60
func create(frame: CGRect? = nil) { func create(frame: CGRect? = nil) {
#if !os(macOS) #if !os(macOS)
@ -76,6 +79,27 @@ final class MPVClient: ObservableObject {
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent)) checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no")) checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
// Enable VSYNC needed for `video-sync`
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "1"))
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
checkError(mpv_set_option_string(mpv, "interpolation", "yes"))
checkError(mpv_set_option_string(mpv, "tscale", "mitchell"))
checkError(mpv_set_option_string(mpv, "tscale-window", "blackman"))
checkError(mpv_set_option_string(mpv, "vd-lavc-framedrop", "nonref"))
checkError(mpv_set_option_string(mpv, "display-fps-override", "\(String(getScreenRefreshRate()))"))
// CPU //
// Determine number of threads based on system core count
let numberOfCores = ProcessInfo.processInfo.processorCount
let threads = numberOfCores * 2
// Log the number of cores and threads
logger.info("Number of CPU cores: \(numberOfCores)")
// Set the number of threads dynamically
checkError(mpv_set_option_string(mpv, "vd-lavc-threads", "\(threads)"))
// GPU // // GPU //
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec])) checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
@ -83,7 +107,6 @@ final class MPVClient: ObservableObject {
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs. // 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, "gpu-api", "opengl"))
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "0"))
#if !os(macOS) #if !os(macOS)
checkError(mpv_set_option_string(mpv, "opengl-es", "yes")) checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
@ -114,7 +137,7 @@ final class MPVClient: ObservableObject {
get_proc_address_ctx: nil get_proc_address_ctx: nil
) )
queue = DispatchQueue(label: "mpv") queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
withUnsafeMutablePointer(to: &initParams) { initParams in withUnsafeMutablePointer(to: &initParams) { initParams in
var params = [ var params = [
@ -320,6 +343,17 @@ final class MPVClient: ObservableObject {
mpv.isNil ? false : getFlag("eof-reached") mpv.isNil ? false : getFlag("eof-reached")
} }
var currentContainerFps: Int {
guard !mpv.isNil else { return 30 }
let fps = getDouble("container-fps")
return Int(fps.rounded())
}
func logCurrentFps() {
let fps = currentContainerFps
logger.info("Current container FPS: \(fps)")
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) { func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
guard !seeking else { guard !seeking else {
logger.warning("ignoring seek, another in progress") logger.warning("ignoring seek, another in progress")
@ -363,7 +397,7 @@ final class MPVClient: ObservableObject {
return return
} }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
guard let self else { return } guard let self else { return }
let model = self.backend.model let model = self.backend.model
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
@ -442,6 +476,45 @@ final class MPVClient: ObservableObject {
} }
} }
func updateRefreshRate(to refreshRate: Int) {
setString("display-fps-override", "\(String(refreshRate))")
logger.info("Updated refresh rate during playback to: \(refreshRate) Hz")
}
// Retrieve the screen's current refresh rate dynamically.
func getScreenRefreshRate() -> Int {
var refreshRate = 60 // Default to 60 Hz in case of failure
#if os(macOS)
// macOS implementation using NSScreen
if let screen = NSScreen.main,
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
let mode = CGDisplayCopyDisplayMode(displayID),
mode.refreshRate > 0
{
refreshRate = Int(mode.refreshRate)
print("Screen refresh rate: \(refreshRate) Hz")
} else {
print("Failed to get refresh rate from NSScreen.")
}
#else
// iOS implementation using UIScreen with a failover
let mainScreen = UIScreen.main
refreshRate = mainScreen.maximumFramesPerSecond
// Failover: if maximumFramesPerSecond is 0 or an unexpected value
if refreshRate <= 0 {
refreshRate = 60 // Fallback to 60 Hz
print("Failed to get refresh rate from UIScreen, falling back to 60 Hz.")
} else {
print("Screen refresh rate: \(refreshRate) Hz")
}
#endif
currentRefreshRate = refreshRate
return refreshRate
}
func addVideoTrack(_ url: URL) { func addVideoTrack(_ url: URL) {
command("video-add", args: [url.absoluteString]) command("video-add", args: [url.absoluteString])
} }

View File

@ -532,8 +532,8 @@ final class PlayerModel: ObservableObject {
} }
private func handlePresentationChange() { private func handlePresentationChange() {
#if !os(iOS) #if os(macOS)
// TODO: Check whether this is neede on tvOS and macOS // TODO: Check whether this is needed on macOS
backend.setNeedsDrawing(presentingPlayer) backend.setNeedsDrawing(presentingPlayer)
#endif #endif
@ -1007,23 +1007,21 @@ final class PlayerModel: ObservableObject {
} }
#else #else
func handleEnterForeground() { func handleEnterForeground() {
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
guard let self = self else { return }
if !self.musicMode, self.activeBackend == .mpv {
self.mpvBackend.addVideoTrackFromStream()
self.mpvBackend.setVideoToAuto()
self.mpvBackend.controls.resetTimer()
} else if !self.musicMode, self.activeBackend == .appleAVPlayer {
self.avPlayerBackend.bindPlayerToLayer()
}
}
#if os(iOS) #if os(iOS)
OrientationTracker.shared.startDeviceOrientationTracking() OrientationTracker.shared.startDeviceOrientationTracking()
#endif #endif
#if os(tvOS)
// TODO: Not sure if this is realy needed on tvOS, maybe it can be removed.
setNeedsDrawing(presentingPlayer)
#endif
if !musicMode, activeBackend == .mpv {
mpvBackend.addVideoTrackFromStream()
mpvBackend.setVideoToAuto()
mpvBackend.controls.resetTimer()
} else if !musicMode, activeBackend == .appleAVPlayer {
avPlayerBackend.bindPlayerToLayer()
}
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else { guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
return return
} }

View File

@ -368,6 +368,7 @@ extension Defaults.Keys {
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe") static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no") static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true) static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
static let mpvSetRefreshToContentFPS = Key<Bool>("mpvSetRefreshToContentFPS", default: false)
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false) static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
static let feedCacheSize = Key<String>("feedCacheSize", default: "50") static let feedCacheSize = Key<String>("feedCacheSize", default: "50")

View File

@ -6,9 +6,10 @@ import OpenGLES
final class MPVOGLView: GLKView { final class MPVOGLView: GLKView {
private var logger = Logger(label: "stream.yattee.mpv.oglview") private var logger = Logger(label: "stream.yattee.mpv.oglview")
private var defaultFBO: GLint? private var defaultFBO: GLint?
private var displayLink: CADisplayLink?
var mpvGL: UnsafeMutableRawPointer? var mpvGL: UnsafeMutableRawPointer?
var queue = DispatchQueue(label: "stream.yattee.opengl") var queue = DispatchQueue(label: "stream.yattee.opengl", qos: .userInteractive)
var needsDrawing = true var needsDrawing = true
override init(frame: CGRect) { override init(frame: CGRect) {
@ -29,6 +30,69 @@ final class MPVOGLView: GLKView {
enableSetNeedsDisplay = false enableSetNeedsDisplay = false
fillBlack() fillBlack()
setupDisplayLink()
setupNotifications()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupDisplayLink()
setupNotifications()
}
private func setupDisplayLink() {
displayLink = CADisplayLink(target: self, selector: #selector(updateFrame))
displayLink?.add(to: .main, forMode: .common)
}
// Set up observers to detect display changes and custom refresh rate updates.
private func setupNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(updateDisplayLinkFromNotification(_:)), name: .updateDisplayLinkFrameRate, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.didConnectNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.didDisconnectNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(screenDidChange), name: UIScreen.modeDidChangeNotification, object: nil)
}
@objc private func screenDidChange(_: Notification) {
// Update the display link refresh rate when the screen configuration changes
updateDisplayLinkFrameRate()
}
// Update the display link frame rate from the notification.
@objc private func updateDisplayLinkFromNotification(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let refreshRate = userInfo["refreshRate"] as? Int else { return }
displayLink?.preferredFramesPerSecond = refreshRate
logger.info("Updated CADisplayLink frame rate to: \(refreshRate) from backend notification.")
}
// Update the display link's preferred frame rate based on the current screen refresh rate.
private func updateDisplayLinkFrameRate() {
guard let displayLink else { return }
let refreshRate = getScreenRefreshRate()
displayLink.preferredFramesPerSecond = refreshRate
logger.info("Updated CADisplayLink preferred frames per second to: \(refreshRate)")
}
// Retrieve the screen's current refresh rate dynamically.
private func getScreenRefreshRate() -> Int {
// Use the main screen's maximumFramesPerSecond property
let refreshRate = UIScreen.main.maximumFramesPerSecond
logger.info("Screen refresh rate: \(refreshRate) Hz")
return refreshRate
}
@objc private func updateFrame() {
// Trigger the drawing process if needed
if needsDrawing {
setNeedsDisplay()
}
}
deinit {
// Invalidate the display link and remove observers to avoid memory leaks
displayLink?.invalidate()
NotificationCenter.default.removeObserver(self)
} }
func fillBlack() { func fillBlack() {
@ -37,35 +101,40 @@ final class MPVOGLView: GLKView {
} }
override func draw(_: CGRect) { override func draw(_: CGRect) {
guard needsDrawing, let mpvGL else { guard needsDrawing, let mpvGL else { return }
return
}
// Bind the default framebuffer
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!) glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
// Get the current viewport dimensions
var dims: [GLint] = [0, 0, 0, 0] var dims: [GLint] = [0, 0, 0, 0]
glGetIntegerv(GLenum(GL_VIEWPORT), &dims) glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
// Set up the OpenGL FBO data
var data = mpv_opengl_fbo( var data = mpv_opengl_fbo(
fbo: Int32(defaultFBO!), fbo: Int32(defaultFBO!),
w: Int32(dims[2]), w: Int32(dims[2]),
h: Int32(dims[3]), h: Int32(dims[3]),
internal_format: 0 internal_format: 0
) )
// Flip Y coordinate for proper rendering
var flip: CInt = 1 var flip: CInt = 1
withUnsafeMutablePointer(to: &flip) { flip in
withUnsafeMutablePointer(to: &data) { data in // Render with the provided OpenGL FBO parameters
withUnsafeMutablePointer(to: &flip) { flipPtr in
withUnsafeMutablePointer(to: &data) { dataPtr in
var params = [ var params = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data), mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: dataPtr),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip), mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
mpv_render_param() mpv_render_param()
] ]
mpv_render_context_render(OpaquePointer(mpvGL), &params) mpv_render_context_render(OpaquePointer(mpvGL), &params)
} }
} }
} }
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder) extension Notification.Name {
} static let updateDisplayLinkFrameRate = Notification.Name("updateDisplayLinkFrameRate")
} }

View File

@ -11,6 +11,7 @@ struct AdvancedSettings: View {
@Default(.mpvHWdec) private var mpvHWdec @Default(.mpvHWdec) private var mpvHWdec
@Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo @Default(.mpvDemuxerLavfProbeInfo) private var mpvDemuxerLavfProbeInfo
@Default(.mpvInitialAudioSync) private var mpvInitialAudioSync @Default(.mpvInitialAudioSync) private var mpvInitialAudioSync
@Default(.mpvSetRefreshToContentFPS) private var mpvSetRefreshToContentFPS
@Default(.showCacheStatus) private var showCacheStatus @Default(.showCacheStatus) private var showCacheStatus
@Default(.feedCacheSize) private var feedCacheSize @Default(.feedCacheSize) private var feedCacheSize
@Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu @Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu
@ -245,6 +246,12 @@ struct AdvancedSettings: View {
#endif #endif
} }
Toggle(isOn: $mpvSetRefreshToContentFPS) {
HStack {
Text("Sync refresh rate with content FPS EXPERIMENTAL")
}
}
if mpvEnableLogging { if mpvEnableLogging {
logButton logButton
} }