From e7ddd6d755a47e007be8c8e2a676f2047886e632 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Wed, 27 May 2026 02:15:07 -0400 Subject: [PATCH] Fix VLC resume audio sync --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 2 + Dreamio/NativePlaybackBackend.swift | 20 +- Dreamio/VLCNativePlaybackBackend.swift | 105 +++---- Tests/StreamResolverTests.swift | 40 +-- .../2026-05-27-fix-vlc-resume-audio-sync.html | 281 ++++++++++++++++++ 6 files changed, 331 insertions(+), 118 deletions(-) create mode 100644 docs/turns/2026-05-27-fix-vlc-resume-audio-sync.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 90714bb..952d1bb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -44,3 +44,4 @@ {"id":"int-3533f9f7","kind":"field_change","created_at":"2026-05-27T04:09:20.72451Z","actor":"dirtydishes","issue_id":"dreamio-ccn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented playback-first native startup and background parallel subtitle resolution."}} {"id":"int-c55d4a7b","kind":"field_change","created_at":"2026-05-27T04:20:08.84278Z","actor":"dirtydishes","issue_id":"dreamio-e2q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete."}} {"id":"int-bfb2d962","kind":"field_change","created_at":"2026-05-27T04:30:15.810274Z","actor":"dirtydishes","issue_id":"dreamio-69r","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build."}} +{"id":"int-b32c40f0","kind":"field_change","created_at":"2026-05-27T06:14:56.320425Z","actor":"dirtydishes","issue_id":"dreamio-0pi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7138835..438ca75 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,8 @@ {"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-yny","title":"Validate VLC resume audio sync on device","description":"Run manual real-device validation for MobileVLCKit direct-stream pause and resume after removing repeated seek-holding. Capture DEBUG resume-observation logs if audio still lags video.","acceptance_criteria":"Problematic direct-file streams are tested on a real iPhone or iPad; resume audio/video timing is recorded; DEBUG resume-observation logs are attached or summarized if lag persists.","status":"open","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-27T06:14:11Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-0pi","title":"Fix MobileVLCKit streaming resume audio sync","description":"Research MobileVLCKit streaming pause/resume behavior and adjust native VLC playback so audio does not lag behind video on every resume.","acceptance_criteria":"MobileVLCKit streaming resume behavior is researched; repeated resume seek-holding is removed or replaced; relevant tests pass; changes are documented.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T05:47:32Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:56Z","started_at":"2026-05-27T05:47:35Z","closed_at":"2026-05-27T06:14:56Z","close_reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-69r","title":"Fix audio lag after native video resume","description":"Audio takes a moment to resume after pausing and playing native video; previous attempts did not resolve the lag.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:26:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:30:16Z","started_at":"2026-05-27T04:26:56Z","closed_at":"2026-05-27T04:30:16Z","close_reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e2q","title":"Gate native player readiness on startup subtitle loading","description":"The native VLC player reports ready as soon as VLC enters buffering, which hides the loading overlay before startup subtitle candidates finish resolving and attaching. Keep startup loading active until the initial subtitle batch has completed or no candidates are queued.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:16:35Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:20:09Z","started_at":"2026-05-27T04:16:37Z","closed_at":"2026-05-27T04:20:09Z","close_reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-5cz","title":"Make VLC range cache non-blocking at startup","description":"Native playback startup currently bypasses Dreamio's local range cache after cache probing caused VLC startup timeouts. Reintroduce cache startup only when preparation is fast and safe, otherwise fall back to direct playback immediately, with focused tests and clear logs.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T00:36:56Z","created_by":"dirtydishes","updated_at":"2026-05-27T00:43:03Z","started_at":"2026-05-27T00:37:03Z","closed_at":"2026-05-27T00:43:03Z","close_reason":"Implemented bounded non-blocking range-cache startup for VLC, with direct fallback on timeout, skipped probes, or local server failures; added focused startup policy tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 5c681cc..047890c 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -75,23 +75,11 @@ enum NativePlaybackAudioSessionPolicy { } } -enum NativePlaybackResumePolicy { - static let freezeInterval: TimeInterval = 0.08 - static let maximumFreezeDuration: TimeInterval = 1.2 - static let maximumAllowedSilentAdvance: Int32 = 120 +enum NativePlaybackStreamingOptionsPolicy { + static let networkCachingMilliseconds = 1000 - static func shouldHoldVideoAtPausedTime( - elapsedSinceResume: TimeInterval, - hasObservedAudioOutput: Bool, - mediaAdvanceMilliseconds: Int32 - ) -> Bool { - guard !hasObservedAudioOutput else { - return false - } - guard elapsedSinceResume < maximumFreezeDuration else { - return false - } - return mediaAdvanceMilliseconds > maximumAllowedSilentAdvance + static func mediaOptions() -> [String] { + [":network-caching=\(networkCachingMilliseconds)"] } } diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index e71701f..f7be1c6 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -34,8 +34,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:] private var didReportReadyForCurrentMedia = false private var pausedTimeMilliseconds: Int32? - private var resumeCorrectionGeneration = 0 - private var resumeCorrectionStartDate: Date? + private var resumeObservationGeneration = 0 + private var resumeObservationStartDate: Date? private var hasObservedResumeAudioOutput = false private var lastToggleDate = Date.distantPast private let minimumToggleInterval: TimeInterval = 0.35 @@ -65,7 +65,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { pendingExternalSubtitleDisplayNames.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll() didReportReadyForCurrentMedia = false - resetResumeCorrectionState() + resetResumeObservationState() lastToggleDate = .distantPast let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers @@ -108,9 +108,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #endif mediaPlayer.play() if isResumingFromPause { - beginResumeCorrection() + beginResumeObservation() } else { - resetResumeCorrectionState() + resetResumeObservationState() } #if DEBUG logPlaybackSnapshot(reason: "after-play-command") @@ -131,9 +131,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return } pausedTimeMilliseconds = mediaPlayer.time.intValue - resetResumeCorrectionRuntimeState() + resetResumeObservationRuntimeState() mediaPlayer.pause() - keepAudioSessionWarmAfterPause() #if DEBUG logPlaybackSnapshot(reason: "after-pause-command") DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in @@ -249,7 +248,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { logPlaybackSnapshot(reason: "before-stop") #endif didReportReadyForCurrentMedia = false - resetResumeCorrectionState() + resetResumeObservationState() mediaPlayer.stop() mediaPlayer.drawable = nil mediaPlayer.media = nil @@ -355,11 +354,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #if canImport(MobileVLCKit) private func addConservativePlaybackOptions(to media: VLCMedia) { - [ - ":network-caching=1000", - ":file-caching=1000", - ":live-caching=1000" - ].forEach { media.addOption($0) } + NativePlaybackStreamingOptionsPolicy.mediaOptions().forEach { media.addOption($0) } } private func prepareAudioSessionForPlayback(reason: String) { @@ -377,83 +372,57 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } } - private func keepAudioSessionWarmAfterPause() { - prepareAudioSessionForPlayback(reason: "pause-keep-warm") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in - self?.prepareAudioSessionForPlayback(reason: "pause-keep-warm-follow-up") - } - } - - private func beginResumeCorrection() { + private func beginResumeObservation() { guard let pausedTimeMilliseconds else { return } - resetResumeCorrectionRuntimeState() - resumeCorrectionGeneration += 1 - let generation = resumeCorrectionGeneration - resumeCorrectionStartDate = Date() + resetResumeObservationRuntimeState() + resumeObservationGeneration += 1 + let generation = resumeObservationGeneration + resumeObservationStartDate = 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) + print("[DreamioVLC] resume-observation begin pausedTimeMS=\(pausedTimeMilliseconds)") + [0.25, 0.75, 1.5].forEach { delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.logResumeObservation(generation: generation, delay: delay) + } } +#endif } - private func performResumeCorrectionTick(generation: Int) { - guard generation == resumeCorrectionGeneration, +#if DEBUG + private func logResumeObservation(generation: Int, delay: TimeInterval) { + guard generation == resumeObservationGeneration, let pausedTimeMilliseconds, - let resumeCorrectionStartDate else { + let resumeObservationStartDate 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) + let elapsed = Date().timeIntervalSince(resumeObservationStartDate) + let advance = max(0, mediaPlayer.time.intValue - pausedTimeMilliseconds) + print("[DreamioVLC] resume-observation tick delay=\(String(format: "%.2f", delay)) elapsed=\(String(format: "%.3f", elapsed)) audioObserved=\(hasObservedResumeAudioOutput) advanceMS=\(advance)") + logPlaybackSnapshot(reason: "resume-observation-\(String(format: "%.2f", delay))") } +#endif private func noteResumeAudioOutputIfNeeded(reason: String) { - guard resumeCorrectionStartDate != nil else { + guard resumeObservationStartDate != 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)") + print("[DreamioVLC] resume-observation audio-observed reason=\(reason) loudness=\(loudness?.loudnessValue ?? 0) date=\(loudness?.date ?? 0)") #endif } - private func resetResumeCorrectionState() { + private func resetResumeObservationState() { pausedTimeMilliseconds = nil - resetResumeCorrectionRuntimeState() + resetResumeObservationRuntimeState() } - private func resetResumeCorrectionRuntimeState() { - resumeCorrectionGeneration += 1 - resumeCorrectionStartDate = nil + private func resetResumeObservationRuntimeState() { + resumeObservationGeneration += 1 + resumeObservationStartDate = nil hasObservedResumeAudioOutput = false } @@ -490,7 +459,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #if DEBUG private func logPlaybackSnapshot(reason: String) { 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) pausedTimeMS=\(pausedTimeMilliseconds.map(String.init) ?? "nil") resumeActive=\(resumeCorrectionStartDate != nil) audioObserved=\(hasObservedResumeAudioOutput) 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=\(resumeObservationStartDate != nil) audioObserved=\(hasObservedResumeAudioOutput) readyReported=\(didReportReadyForCurrentMedia)") } #endif @@ -655,7 +624,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification) { #if DEBUG - if resumeCorrectionStartDate != nil { + if resumeObservationStartDate != nil { logPlaybackSnapshot(reason: "time-change-during-resume") } #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index adf5a97..90302b2 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -26,7 +26,7 @@ struct StreamResolverTests { testSubtitleOptionMappingIncludesNone() testNativePlaybackTogglePolicy() testNativePlaybackAudioSessionPolicy() - testNativePlaybackResumePolicy() + testNativePlaybackStreamingOptionsPolicy() print("StreamResolverTests passed") } @@ -529,39 +529,11 @@ struct StreamResolverTests { 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 testNativePlaybackStreamingOptionsPolicy() { + assertEqual(NativePlaybackStreamingOptionsPolicy.networkCachingMilliseconds, 1000) + assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions(), [":network-caching=1000"]) + assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":file-caching=1000"), false) + assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false) } private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { diff --git a/docs/turns/2026-05-27-fix-vlc-resume-audio-sync.html b/docs/turns/2026-05-27-fix-vlc-resume-audio-sync.html new file mode 100644 index 0000000..575f83c --- /dev/null +++ b/docs/turns/2026-05-27-fix-vlc-resume-audio-sync.html @@ -0,0 +1,281 @@ + + + + + + Fix VLC resume audio sync + + + + + + +
+
+

Dreamio turn document

+

Fix VLC resume audio sync

+

Removed the repeated resume seek-hold loop and aligned MobileVLCKit streaming options with VLC-iOS so paused streams can resume without app-driven video rewinds fighting libVLC buffering.

+
2026-05-27Beads issue dreamio-0piMobileVLCKit streaming
+
+
+

Summary

The VLC native backend now trusts MobileVLCKit resume semantics instead of repeatedly resetting playback time after every resume. DEBUG logging still records resume timing, media advance, and loudness callbacks so device testing can confirm whether audio output catches up naturally.

+

Changes Made

  • Removed the old NativePlaybackResumePolicy seek-hold loop that rewound video every 80 ms until loudness changed.
  • Added NativePlaybackStreamingOptionsPolicy with one conservative :network-caching=1000 option.
  • Dropped always-on :file-caching=1000 and :live-caching=1000 for direct streaming playback.
  • Stopped re-preparing the audio session after pause; resume still prepares it before calling play().
  • Updated focused Swift tests to cover the streaming options policy.
+

Context

Research checked MobileVLCKit and VLC-iOS sources. VLCKit documents play() as resuming at the paused position. VLC-iOS uses per-media options and defaults network caching to roughly 999 ms, without adding file and live caching to every open network stream. This made the repeated app-level time reset the riskiest part of the previous workaround.

+

Important Implementation Details

  • Resume is now an observation path, not a correction path. It logs what happens but does not seek during resume.
  • The paused millisecond value is preserved only for DEBUG comparison against later media advance.
  • The streaming option policy is isolated in NativePlaybackBackend.swift so future HLS, live, RTSP, or direct-file profiles can be tested deliberately.
  • Manual device testing remains essential because simulator builds cannot validate MobileVLCKit audio output timing.
+

Relevant Diff Snippets

+

Dreamio/NativePlaybackBackend.swift ยท streaming option policy

Dreamio/NativePlaybackBackend.swift
-16+4
74 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
74 unmodified lines
}
}
+
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
}
}
+
74 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
74 unmodified lines
}
}
+
enum NativePlaybackStreamingOptionsPolicy {
static let networkCachingMilliseconds = 1000
+
static func mediaOptions() -> [String] {
[":network-caching=\(networkCachingMilliseconds)"]
}
}
+
+

Dreamio/VLCNativePlaybackBackend.swift ยท remove resume seek-holding

Dreamio/VLCNativePlaybackBackend.swift
-68+37
33 unmodified lines
34
35
36
37
38
39
40
41
23 unmodified lines
65
66
67
68
69
70
71
36 unmodified lines
108
109
110
111
112
113
114
115
116
14 unmodified lines
131
132
133
134
135
136
137
138
139
109 unmodified lines
249
250
251
252
253
254
255
99 unmodified lines
355
356
357
358
359
360
361
362
363
364
365
11 unmodified lines
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
30 unmodified lines
490
491
492
493
494
495
496
158 unmodified lines
655
656
657
658
659
660
661
33 unmodified lines
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
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 let minimumToggleInterval: TimeInterval = 0.35
23 unmodified lines
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
resetResumeCorrectionState()
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
36 unmodified lines
#endif
mediaPlayer.play()
if isResumingFromPause {
beginResumeCorrection()
} else {
resetResumeCorrectionState()
}
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
14 unmodified lines
return
}
pausedTimeMilliseconds = mediaPlayer.time.intValue
resetResumeCorrectionRuntimeState()
mediaPlayer.pause()
keepAudioSessionWarmAfterPause()
#if DEBUG
logPlaybackSnapshot(reason: "after-pause-command")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
109 unmodified lines
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
resetResumeCorrectionState()
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
99 unmodified lines
+
#if canImport(MobileVLCKit)
private func addConservativePlaybackOptions(to media: VLCMedia) {
[
":network-caching=1000",
":file-caching=1000",
":live-caching=1000"
].forEach { media.addOption($0) }
}
+
private func prepareAudioSessionForPlayback(reason: String) {
11 unmodified lines
}
}
+
private func keepAudioSessionWarmAfterPause() {
prepareAudioSessionForPlayback(reason: "pause-keep-warm")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
self?.prepareAudioSessionForPlayback(reason: "pause-keep-warm-follow-up")
}
}
+
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
}
+
30 unmodified lines
#if DEBUG
private func logPlaybackSnapshot(reason: String) {
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) pausedTimeMS=\(pausedTimeMilliseconds.map(String.init) ?? "nil") resumeActive=\(resumeCorrectionStartDate != nil) audioObserved=\(hasObservedResumeAudioOutput) readyReported=\(didReportReadyForCurrentMedia)")
}
#endif
+
158 unmodified lines
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
#if DEBUG
if resumeCorrectionStartDate != nil {
logPlaybackSnapshot(reason: "time-change-during-resume")
}
#endif
33 unmodified lines
34
35
36
37
38
39
40
41
23 unmodified lines
65
66
67
68
69
70
71
36 unmodified lines
108
109
110
111
112
113
114
115
116
14 unmodified lines
131
132
133
134
135
136
137
138
109 unmodified lines
248
249
250
251
252
253
254
99 unmodified lines
354
355
356
357
358
359
360
11 unmodified lines
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
30 unmodified lines
459
460
461
462
463
464
465
158 unmodified lines
624
625
626
627
628
629
630
33 unmodified lines
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
private var didReportReadyForCurrentMedia = false
private var pausedTimeMilliseconds: Int32?
private var resumeObservationGeneration = 0
private var resumeObservationStartDate: Date?
private var hasObservedResumeAudioOutput = false
private var lastToggleDate = Date.distantPast
private let minimumToggleInterval: TimeInterval = 0.35
23 unmodified lines
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
resetResumeObservationState()
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
36 unmodified lines
#endif
mediaPlayer.play()
if isResumingFromPause {
beginResumeObservation()
} else {
resetResumeObservationState()
}
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
14 unmodified lines
return
}
pausedTimeMilliseconds = mediaPlayer.time.intValue
resetResumeObservationRuntimeState()
mediaPlayer.pause()
#if DEBUG
logPlaybackSnapshot(reason: "after-pause-command")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
109 unmodified lines
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
resetResumeObservationState()
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
99 unmodified lines
+
#if canImport(MobileVLCKit)
private func addConservativePlaybackOptions(to media: VLCMedia) {
NativePlaybackStreamingOptionsPolicy.mediaOptions().forEach { media.addOption($0) }
}
+
private func prepareAudioSessionForPlayback(reason: String) {
11 unmodified lines
}
}
+
private func beginResumeObservation() {
guard let pausedTimeMilliseconds else {
return
}
resetResumeObservationRuntimeState()
resumeObservationGeneration += 1
let generation = resumeObservationGeneration
resumeObservationStartDate = Date()
#if DEBUG
print("[DreamioVLC] resume-observation begin pausedTimeMS=\(pausedTimeMilliseconds)")
[0.25, 0.75, 1.5].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.logResumeObservation(generation: generation, delay: delay)
}
}
#endif
}
+
#if DEBUG
private func logResumeObservation(generation: Int, delay: TimeInterval) {
guard generation == resumeObservationGeneration,
let pausedTimeMilliseconds,
let resumeObservationStartDate else {
return
}
let elapsed = Date().timeIntervalSince(resumeObservationStartDate)
let advance = max(0, mediaPlayer.time.intValue - pausedTimeMilliseconds)
print("[DreamioVLC] resume-observation tick delay=\(String(format: "%.2f", delay)) elapsed=\(String(format: "%.3f", elapsed)) audioObserved=\(hasObservedResumeAudioOutput) advanceMS=\(advance)")
logPlaybackSnapshot(reason: "resume-observation-\(String(format: "%.2f", delay))")
}
#endif
+
private func noteResumeAudioOutputIfNeeded(reason: String) {
guard resumeObservationStartDate != nil else {
return
}
hasObservedResumeAudioOutput = true
#if DEBUG
let loudness = mediaPlayer.momentaryLoudness
print("[DreamioVLC] resume-observation audio-observed reason=\(reason) loudness=\(loudness?.loudnessValue ?? 0) date=\(loudness?.date ?? 0)")
#endif
}
+
private func resetResumeObservationState() {
pausedTimeMilliseconds = nil
resetResumeObservationRuntimeState()
}
+
private func resetResumeObservationRuntimeState() {
resumeObservationGeneration += 1
resumeObservationStartDate = nil
hasObservedResumeAudioOutput = false
}
+
30 unmodified lines
#if DEBUG
private func logPlaybackSnapshot(reason: String) {
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) pausedTimeMS=\(pausedTimeMilliseconds.map(String.init) ?? "nil") resumeActive=\(resumeObservationStartDate != nil) audioObserved=\(hasObservedResumeAudioOutput) readyReported=\(didReportReadyForCurrentMedia)")
}
#endif
+
158 unmodified lines
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
#if DEBUG
if resumeObservationStartDate != nil {
logPlaybackSnapshot(reason: "time-change-during-resume")
}
#endif
+

Tests/StreamResolverTests.swift ยท policy coverage

Tests/StreamResolverTests.swift
-34+6
25 unmodified lines
26
27
28
29
30
31
32
496 unmodified lines
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
25 unmodified lines
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackResumePolicy()
print("StreamResolverTests passed")
}
+
496 unmodified lines
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) {
25 unmodified lines
26
27
28
29
30
31
32
496 unmodified lines
529
530
531
532
533
534
535
536
537
538
539
25 unmodified lines
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackStreamingOptionsPolicy()
print("StreamResolverTests passed")
}
+
496 unmodified lines
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .unknown), false)
}
+
private static func testNativePlaybackStreamingOptionsPolicy() {
assertEqual(NativePlaybackStreamingOptionsPolicy.networkCachingMilliseconds, 1000)
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions(), [":network-caching=1000"])
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":file-caching=1000"), false)
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false)
}
+
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
+

Diffs were rendered with @pierre/diffs/ssr. Each rendered file diff is contained in its own shell.

+

Expected Impact for End-Users

Every resume should have less opportunity for video to race ahead because Dreamio no longer forces repeated seeks while libVLC is restoring stream buffers and audio output. The user-visible expectation is smoother resume behavior for direct-file streams in the native player.

+

Validation

  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator swiftc -target arm64-apple-ios18.0-simulator Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/NativePlaybackBackend.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests-ios.
  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
  • Not run: on-device streaming playback validation, which is required to confirm real MobileVLCKit audio and video timing.
+

Issues, Limitations, and Mitigations

  • This change removes the highest-risk workaround but does not prove the underlying MobileVLCKit stream behavior is fixed until a real device plays and resumes the problematic streams.
  • DEBUG resume-observation logs remain available to compare audio-observed timing against media advance during manual validation.
  • The app still uses one network cache value for all direct streams; protocol-specific profiles can be added if device logs show one stream family needs different treatment.
+

Follow-up Work

  • Validate on a real iPhone or iPad with the exact streams that previously showed video-first resume.
  • If audio still lags, capture DEBUG logs around resume-observation ticks and test a protocol-specific cache profile rather than reintroducing repeated seeks.
+
+
+ +