mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Fix VLC resume audio lag
This commit is contained in:
parent
f0226c651d
commit
ed7a242a47
4 changed files with 409 additions and 4 deletions
|
|
@ -75,6 +75,26 @@ enum NativePlaybackAudioSessionPolicy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NativePlaybackResumePolicy {
|
||||||
|
static let freezeInterval: TimeInterval = 0.08
|
||||||
|
static let maximumFreezeDuration: TimeInterval = 1.2
|
||||||
|
static let maximumAllowedSilentAdvance: Int32 = 120
|
||||||
|
|
||||||
|
static func shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: TimeInterval,
|
||||||
|
hasObservedAudioOutput: Bool,
|
||||||
|
mediaAdvanceMilliseconds: Int32
|
||||||
|
) -> Bool {
|
||||||
|
guard !hasObservedAudioOutput else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard elapsedSinceResume < maximumFreezeDuration else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mediaAdvanceMilliseconds > maximumAllowedSilentAdvance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum NativePlaybackError: LocalizedError {
|
enum NativePlaybackError: LocalizedError {
|
||||||
case backendUnavailable
|
case backendUnavailable
|
||||||
case startupTimedOut
|
case startupTimedOut
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
private var pendingExternalSubtitleDisplayNames: [String] = []
|
private var pendingExternalSubtitleDisplayNames: [String] = []
|
||||||
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
|
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
|
||||||
private var didReportReadyForCurrentMedia = false
|
private var didReportReadyForCurrentMedia = false
|
||||||
|
private var pausedTimeMilliseconds: Int32?
|
||||||
|
private var resumeCorrectionGeneration = 0
|
||||||
|
private var resumeCorrectionStartDate: Date?
|
||||||
|
private var hasObservedResumeAudioOutput = false
|
||||||
private var lastToggleDate = Date.distantPast
|
private var lastToggleDate = Date.distantPast
|
||||||
private let minimumToggleInterval: TimeInterval = 0.35
|
private let minimumToggleInterval: TimeInterval = 0.35
|
||||||
|
|
||||||
|
|
@ -61,6 +65,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
pendingExternalSubtitleDisplayNames.removeAll()
|
pendingExternalSubtitleDisplayNames.removeAll()
|
||||||
externalSubtitleDisplayNamesByTrackID.removeAll()
|
externalSubtitleDisplayNamesByTrackID.removeAll()
|
||||||
didReportReadyForCurrentMedia = false
|
didReportReadyForCurrentMedia = false
|
||||||
|
resetResumeCorrectionState()
|
||||||
lastToggleDate = .distantPast
|
lastToggleDate = .distantPast
|
||||||
let media = VLCMedia(url: request.playbackURL)
|
let media = VLCMedia(url: request.playbackURL)
|
||||||
let headerValue = request.headers
|
let headerValue = request.headers
|
||||||
|
|
@ -94,6 +99,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
func play() {
|
func play() {
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
let toggleState = playbackToggleState(for: mediaPlayer.state)
|
let toggleState = playbackToggleState(for: mediaPlayer.state)
|
||||||
|
let isResumingFromPause = toggleState == .paused
|
||||||
if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {
|
if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {
|
||||||
prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")
|
prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +107,11 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
logPlaybackSnapshot(reason: "before-play")
|
logPlaybackSnapshot(reason: "before-play")
|
||||||
#endif
|
#endif
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
|
if isResumingFromPause {
|
||||||
|
beginResumeCorrection()
|
||||||
|
} else {
|
||||||
|
resetResumeCorrectionState()
|
||||||
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logPlaybackSnapshot(reason: "after-play-command")
|
logPlaybackSnapshot(reason: "after-play-command")
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -119,6 +130,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
#endif
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pausedTimeMilliseconds = mediaPlayer.time.intValue
|
||||||
|
resetResumeCorrectionRuntimeState()
|
||||||
mediaPlayer.pause()
|
mediaPlayer.pause()
|
||||||
keepAudioSessionWarmAfterPause()
|
keepAudioSessionWarmAfterPause()
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
@ -236,6 +249,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
logPlaybackSnapshot(reason: "before-stop")
|
logPlaybackSnapshot(reason: "before-stop")
|
||||||
#endif
|
#endif
|
||||||
didReportReadyForCurrentMedia = false
|
didReportReadyForCurrentMedia = false
|
||||||
|
resetResumeCorrectionState()
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
mediaPlayer.drawable = nil
|
mediaPlayer.drawable = nil
|
||||||
mediaPlayer.media = nil
|
mediaPlayer.media = nil
|
||||||
|
|
@ -344,8 +358,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
[
|
[
|
||||||
":network-caching=1000",
|
":network-caching=1000",
|
||||||
":file-caching=1000",
|
":file-caching=1000",
|
||||||
":live-caching=1000",
|
":live-caching=1000"
|
||||||
":clock-jitter=0"
|
|
||||||
].forEach { media.addOption($0) }
|
].forEach { media.addOption($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,6 +384,79 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func beginResumeCorrection() {
|
||||||
|
guard let pausedTimeMilliseconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetResumeCorrectionRuntimeState()
|
||||||
|
resumeCorrectionGeneration += 1
|
||||||
|
let generation = resumeCorrectionGeneration
|
||||||
|
resumeCorrectionStartDate = Date()
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioVLC] resume-correction begin pausedTimeMS=\(pausedTimeMilliseconds)")
|
||||||
|
#endif
|
||||||
|
scheduleResumeCorrectionTick(generation: generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleResumeCorrectionTick(generation: Int) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + NativePlaybackResumePolicy.freezeInterval) { [weak self] in
|
||||||
|
self?.performResumeCorrectionTick(generation: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performResumeCorrectionTick(generation: Int) {
|
||||||
|
guard generation == resumeCorrectionGeneration,
|
||||||
|
let pausedTimeMilliseconds,
|
||||||
|
let resumeCorrectionStartDate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = Date().timeIntervalSince(resumeCorrectionStartDate)
|
||||||
|
let currentTimeMilliseconds = mediaPlayer.time.intValue
|
||||||
|
let advance = max(0, currentTimeMilliseconds - pausedTimeMilliseconds)
|
||||||
|
let shouldHold = NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: elapsed,
|
||||||
|
hasObservedAudioOutput: hasObservedResumeAudioOutput,
|
||||||
|
mediaAdvanceMilliseconds: advance
|
||||||
|
)
|
||||||
|
|
||||||
|
guard shouldHold else {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioVLC] resume-correction release elapsed=\(String(format: "%.3f", elapsed)) audioObserved=\(hasObservedResumeAudioOutput) advanceMS=\(advance)")
|
||||||
|
#endif
|
||||||
|
resetResumeCorrectionRuntimeState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPlayer.time = VLCTime(int: pausedTimeMilliseconds)
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioVLC] resume-correction hold elapsed=\(String(format: "%.3f", elapsed)) advanceMS=\(advance) resetToMS=\(pausedTimeMilliseconds)")
|
||||||
|
#endif
|
||||||
|
scheduleResumeCorrectionTick(generation: generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteResumeAudioOutputIfNeeded(reason: String) {
|
||||||
|
guard resumeCorrectionStartDate != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasObservedResumeAudioOutput = true
|
||||||
|
#if DEBUG
|
||||||
|
let loudness = mediaPlayer.momentaryLoudness
|
||||||
|
print("[DreamioVLC] resume-correction audio-observed reason=\(reason) loudness=\(loudness?.loudnessValue ?? 0) date=\(loudness?.date ?? 0)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetResumeCorrectionState() {
|
||||||
|
pausedTimeMilliseconds = nil
|
||||||
|
resetResumeCorrectionRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetResumeCorrectionRuntimeState() {
|
||||||
|
resumeCorrectionGeneration += 1
|
||||||
|
resumeCorrectionStartDate = nil
|
||||||
|
hasObservedResumeAudioOutput = false
|
||||||
|
}
|
||||||
|
|
||||||
private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
|
private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
|
||||||
switch state {
|
switch state {
|
||||||
case .playing, .buffering:
|
case .playing, .buffering:
|
||||||
|
|
@ -404,7 +490,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private func logPlaybackSnapshot(reason: String) {
|
private func logPlaybackSnapshot(reason: String) {
|
||||||
let mediaLength = mediaPlayer.media?.length.intValue ?? 0
|
let mediaLength = mediaPlayer.media?.length.intValue ?? 0
|
||||||
print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) canPause=\(mediaPlayer.canPause) seekable=\(mediaPlayer.isSeekable) currentTime=\(String(format: "%.3f", currentTime)) duration=\(String(format: "%.3f", TimeInterval(max(0, mediaLength)) / 1000)) position=\(String(format: "%.4f", mediaPlayer.position)) audioDelay=\(mediaPlayer.currentAudioPlaybackDelay) readyReported=\(didReportReadyForCurrentMedia)")
|
print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) canPause=\(mediaPlayer.canPause) seekable=\(mediaPlayer.isSeekable) currentTime=\(String(format: "%.3f", currentTime)) duration=\(String(format: "%.3f", TimeInterval(max(0, mediaLength)) / 1000)) position=\(String(format: "%.4f", mediaPlayer.position)) audioDelay=\(mediaPlayer.currentAudioPlaybackDelay) pausedTimeMS=\(pausedTimeMilliseconds.map(String.init) ?? "nil") resumeActive=\(resumeCorrectionStartDate != nil) audioObserved=\(hasObservedResumeAudioOutput) readyReported=\(didReportReadyForCurrentMedia)")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
@ -567,6 +653,18 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
|
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||||
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||||
|
#if DEBUG
|
||||||
|
if resumeCorrectionStartDate != nil {
|
||||||
|
logPlaybackSnapshot(reason: "time-change-during-resume")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPlayerLoudnessChanged(_ aNotification: Notification) {
|
||||||
|
noteResumeAudioOutputIfNeeded(reason: "loudness-changed")
|
||||||
|
}
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
logPlaybackSnapshot(reason: "state-change")
|
logPlaybackSnapshot(reason: "state-change")
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ struct StreamResolverTests {
|
||||||
testSubtitleOptionMappingIncludesNone()
|
testSubtitleOptionMappingIncludesNone()
|
||||||
testNativePlaybackTogglePolicy()
|
testNativePlaybackTogglePolicy()
|
||||||
testNativePlaybackAudioSessionPolicy()
|
testNativePlaybackAudioSessionPolicy()
|
||||||
|
testNativePlaybackResumePolicy()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,6 +529,41 @@ struct StreamResolverTests {
|
||||||
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .unknown), false)
|
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .unknown), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func testNativePlaybackResumePolicy() {
|
||||||
|
assertEqual(
|
||||||
|
NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: 0.4,
|
||||||
|
hasObservedAudioOutput: false,
|
||||||
|
mediaAdvanceMilliseconds: 500
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: 0.4,
|
||||||
|
hasObservedAudioOutput: true,
|
||||||
|
mediaAdvanceMilliseconds: 500
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: 1.3,
|
||||||
|
hasObservedAudioOutput: false,
|
||||||
|
mediaAdvanceMilliseconds: 500
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(
|
||||||
|
elapsedSinceResume: 0.4,
|
||||||
|
hasObservedAudioOutput: false,
|
||||||
|
mediaAdvanceMilliseconds: 80
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
|
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
|
||||||
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
|
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue