mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Commit
This commit is contained in:
@@ -59,9 +59,73 @@ final class MPVSoftwareRenderer {
|
||||
private let inFlightLock = NSLock()
|
||||
|
||||
weak var delegate: MPVSoftwareRendererDelegate?
|
||||
private var cachedDuration: Double = 0
|
||||
private var cachedPosition: Double = 0
|
||||
private var isPaused: Bool = true
|
||||
|
||||
// Thread-safe state for playback (uses existing stateQueue to prevent races causing stutter)
|
||||
private var _cachedDuration: Double = 0
|
||||
private var _cachedPosition: Double = 0
|
||||
private var _isPaused: Bool = true
|
||||
private var _playbackSpeed: Double = 1.0
|
||||
private var _isSeeking: Bool = false
|
||||
private var _positionUpdateTime: CFTimeInterval = 0 // Host time when position was last updated
|
||||
private var _lastPTS: Double = 0 // Last presentation timestamp (ensures monotonic increase)
|
||||
|
||||
// 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 isSeeking: Bool {
|
||||
get { stateQueue.sync { _isSeeking } }
|
||||
set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } }
|
||||
}
|
||||
private var positionUpdateTime: CFTimeInterval {
|
||||
get { stateQueue.sync { _positionUpdateTime } }
|
||||
set { stateQueue.async(flags: .barrier) { self._positionUpdateTime = newValue } }
|
||||
}
|
||||
private var lastPTS: Double {
|
||||
get { stateQueue.sync { _lastPTS } }
|
||||
set { stateQueue.async(flags: .barrier) { self._lastPTS = newValue } }
|
||||
}
|
||||
|
||||
/// Get next monotonically increasing PTS based on video position
|
||||
/// This ensures frames always have increasing timestamps (prevents stutter from drops)
|
||||
private func nextMonotonicPTS() -> Double {
|
||||
let currentPos = interpolatedPosition()
|
||||
let last = lastPTS
|
||||
|
||||
// Ensure PTS always increases (by at least 1ms) to prevent frame drops
|
||||
let pts = max(currentPos, last + 0.001)
|
||||
lastPTS = pts
|
||||
return pts
|
||||
}
|
||||
|
||||
/// Calculate smooth interpolated position based on last known position + elapsed time
|
||||
private func interpolatedPosition() -> Double {
|
||||
let basePosition = cachedPosition
|
||||
let lastUpdate = positionUpdateTime
|
||||
let paused = isPaused
|
||||
let speed = playbackSpeed
|
||||
|
||||
guard !paused, lastUpdate > 0 else {
|
||||
return basePosition
|
||||
}
|
||||
|
||||
let elapsed = CACurrentMediaTime() - lastUpdate
|
||||
return basePosition + (elapsed * speed)
|
||||
}
|
||||
|
||||
private var isLoading: Bool = false
|
||||
private var isRenderScheduled = false
|
||||
private var lastRenderTime: CFTimeInterval = 0
|
||||
@@ -669,7 +733,9 @@ final class MPVSoftwareRenderer {
|
||||
return
|
||||
}
|
||||
|
||||
let presentationTime = CMClockGetTime(CMClockGetHostTimeClock())
|
||||
// Use interpolated position for smooth PTS (prevents jitter from discrete time-pos updates)
|
||||
// Use monotonically increasing video position for smooth PTS + working PiP progress
|
||||
let presentationTime = CMTime(seconds: nextMonotonicPTS(), preferredTimescale: 1000)
|
||||
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: presentationTime, decodeTimeStamp: .invalid)
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
@@ -739,7 +805,8 @@ final class MPVSoftwareRenderer {
|
||||
if self.displayLayer.controlTimebase == nil {
|
||||
var timebase: CMTimebase?
|
||||
if CMTimebaseCreateWithSourceClock(allocator: kCFAllocatorDefault, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &timebase) == noErr, let timebase {
|
||||
CMTimebaseSetRate(timebase, rate: 1.0)
|
||||
// Set rate based on current pause state and playback speed
|
||||
CMTimebaseSetRate(timebase, rate: self.isPaused ? 0 : self.playbackSpeed)
|
||||
CMTimebaseSetTime(timebase, time: presentationTime)
|
||||
self.displayLayer.controlTimebase = timebase
|
||||
} else {
|
||||
@@ -940,10 +1007,13 @@ final class MPVSoftwareRenderer {
|
||||
delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration)
|
||||
}
|
||||
case "time-pos":
|
||||
// Skip updates while seeking to prevent race condition
|
||||
guard !isSeeking else { return }
|
||||
var value = Double(0)
|
||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
||||
if status >= 0 {
|
||||
cachedPosition = value
|
||||
positionUpdateTime = CACurrentMediaTime() // Record when we got this update
|
||||
delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration)
|
||||
}
|
||||
case "pause":
|
||||
@@ -953,6 +1023,13 @@ final class MPVSoftwareRenderer {
|
||||
let newPaused = flag != 0
|
||||
if newPaused != isPaused {
|
||||
isPaused = newPaused
|
||||
// Update timebase rate - use playbackSpeed when playing, 0 when paused
|
||||
let speed = self.playbackSpeed
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let timebase = self?.displayLayer.controlTimebase {
|
||||
CMTimebaseSetRate(timebase, rate: newPaused ? 0 : speed)
|
||||
}
|
||||
}
|
||||
delegate?.renderer(self, didChangePause: isPaused)
|
||||
}
|
||||
}
|
||||
@@ -1028,18 +1105,101 @@ final class MPVSoftwareRenderer {
|
||||
func seek(to seconds: Double) {
|
||||
guard let handle = mpv else { return }
|
||||
let clamped = max(0, seconds)
|
||||
let wasPaused = isPaused
|
||||
// Prevent time-pos updates from overwriting during seek
|
||||
isSeeking = true
|
||||
// Update cached position BEFORE seek so new frames get correct timestamp
|
||||
cachedPosition = clamped
|
||||
positionUpdateTime = CACurrentMediaTime() // Reset interpolation base
|
||||
lastPTS = clamped // Reset monotonic PTS to new position
|
||||
// Update timebase to match new position (sets rate to 1 for frame display)
|
||||
syncTimebase(to: clamped)
|
||||
// Sync seek for accurate positioning
|
||||
commandSync(handle, ["seek", String(clamped), "absolute"])
|
||||
isSeeking = false
|
||||
// Restore paused rate after seek completes
|
||||
if wasPaused {
|
||||
restoreTimebaseRate()
|
||||
}
|
||||
}
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
guard let handle = mpv else { return }
|
||||
let wasPaused = isPaused
|
||||
// Prevent time-pos updates from overwriting during seek
|
||||
isSeeking = true
|
||||
// Update cached position BEFORE seek
|
||||
let newPosition = max(0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
positionUpdateTime = CACurrentMediaTime() // Reset interpolation base
|
||||
lastPTS = newPosition // Reset monotonic PTS to new position
|
||||
// Update timebase to match new position (sets rate to 1 for frame display)
|
||||
syncTimebase(to: newPosition)
|
||||
// Sync seek for accurate positioning
|
||||
commandSync(handle, ["seek", String(seconds), "relative"])
|
||||
isSeeking = false
|
||||
// Restore paused rate after seek completes
|
||||
if wasPaused {
|
||||
restoreTimebaseRate()
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreTimebaseRate() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||
guard let self = self, self.isPaused else { return }
|
||||
if let timebase = self.displayLayer.controlTimebase {
|
||||
CMTimebaseSetRate(timebase, rate: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncTimebase(to position: Double) {
|
||||
let speed = playbackSpeed
|
||||
let doWork = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Flush old frames to avoid "old frames with new clock" mismatches
|
||||
if #available(iOS 17.0, *) {
|
||||
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil)
|
||||
} else {
|
||||
self.displayLayer.flush()
|
||||
}
|
||||
if let timebase = self.displayLayer.controlTimebase {
|
||||
// Update timebase to new position
|
||||
CMTimebaseSetTime(timebase, time: CMTime(seconds: position, preferredTimescale: 1000))
|
||||
// Set rate to playback speed during seek to ensure frame displays
|
||||
// restoreTimebaseRate() will set it back to 0 if paused
|
||||
CMTimebaseSetRate(timebase, rate: speed)
|
||||
}
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
doWork()
|
||||
} else {
|
||||
DispatchQueue.main.sync { doWork() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync timebase with current position without flushing (for smooth PiP transitions)
|
||||
func syncTimebase() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let timebase = self.displayLayer.controlTimebase {
|
||||
CMTimebaseSetTime(timebase, time: CMTime(seconds: self.cachedPosition, preferredTimescale: 1000))
|
||||
CMTimebaseSetRate(timebase, rate: self.isPaused ? 0 : self.playbackSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setSpeed(_ speed: Double) {
|
||||
playbackSpeed = speed
|
||||
setProperty(name: "speed", value: String(speed))
|
||||
// Sync timebase rate with playback speed
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self,
|
||||
let timebase = self.displayLayer.controlTimebase else { return }
|
||||
let rate = self.isPaused ? 0.0 : speed
|
||||
CMTimebaseSetRate(timebase, rate: rate)
|
||||
}
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
|
||||
@@ -51,6 +51,7 @@ class MpvPlayerView: ExpoView {
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
@@ -145,11 +146,15 @@ class MpvPlayerView: ExpoView {
|
||||
}
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
@@ -294,12 +299,14 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
func renderer(_: MPVSoftwareRenderer, 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
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
])
|
||||
// Update PiP state when playback changes
|
||||
self.pipController?.updatePlaybackState()
|
||||
// Note: Don't call updatePlaybackState() here to avoid flicker
|
||||
// PiP queries pipControllerIsPlaying when it needs the state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,24 +341,27 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
|
||||
extension MpvPlayerView: PiPControllerDelegate {
|
||||
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) {
|
||||
print("PiP will start")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.updatePlaybackState()
|
||||
}
|
||||
// Sync timebase before PiP starts for smooth transition
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.updatePlaybackState()
|
||||
}
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
print("PiP will stop")
|
||||
// Sync timebase before returning from PiP
|
||||
renderer?.syncTimebase()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
||||
print("PiP did stop")
|
||||
// Ensure timebase is synced after PiP ends
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
@@ -377,7 +387,8 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
}
|
||||
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
||||
return !isPaused()
|
||||
// Use intended state to ignore transient pauses during seeking
|
||||
return intendedPlayState
|
||||
}
|
||||
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||
|
||||
Reference in New Issue
Block a user