Dreamio turn document

Audio Warm Resume

Dreamio now keeps the iOS playback audio session warm while native VLC playback is paused and explicitly primes it before resuming, so video no longer races ahead while audio wakes up.

2026-05-27 Beads issue dreamio-69r Native playback

Summary

Dreamio now keeps the iOS playback audio session warm while native VLC playback is paused and explicitly primes it before resuming, so video no longer races ahead while audio wakes up.

Changes Made

  • Added a native audio-session policy for deciding when playback should prime the audio route before starting.
  • Prepared AVAudioSession with .playback and .moviePlayback before initial VLC playback and before resuming from inactive states.
  • Kept the audio session active immediately after pause, including a short follow-up activation, so the route is less likely to go cold before the next play tap.
  • Added lightweight policy coverage to the existing Swift test harness.

Context

Pausing and resuming a native video could let the video frame clock restart immediately while the audio route took a moment to become audible. Earlier low-latency and audio-delay attempts did not address the route warm-up itself, so this change targets the iOS audio session around pause/resume.

Important Implementation Details

  • VLCNativePlaybackBackend.play(request:) primes the audio session before assigning and starting new media.
  • VLCNativePlaybackBackend.play() only primes on paused, stopped, ended, or error states, avoiding repeated session churn while already opening, buffering, or playing.
  • pause() no longer leaves the route entirely to VLC after the pause command; it explicitly keeps the app audio session warm and repeats once after 80 ms.
  • Debug logs include the audio-session preparation reason to help confirm whether a resume path warmed the session before VLC starts rendering again.

Relevant Diff Snippets

Dreamio/NativePlaybackBackend.swift ยท adds a testable audio-session resume policy

Dreamio/NativePlaybackBackend.swift
+11
62 unmodified lines
63
64
65
66
67
68
69
70
62 unmodified lines
}
}
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
case playbackFailed
62 unmodified lines
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
62 unmodified lines
}
}
}
enum NativePlaybackAudioSessionPolicy {
static func shouldPrepareBeforePlayback(from state: NativePlaybackToggleState) -> Bool {
switch state {
case .paused, .stopped, .ended, .error:
return true
case .opening, .buffering, .playing, .unknown:
return false
}
}
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
case playbackFailed

Dreamio/VLCNativePlaybackBackend.swift ยท prepares and keeps AVAudioSession active around pause/resume

Dreamio/VLCNativePlaybackBackend.swift
+29
1
2
3
4
67 unmodified lines
72
73
74
75
76
77
78
79
10 unmodified lines
90
91
92
93
94
95
96
97
15 unmodified lines
113
114
115
116
117
118
119
120
220 unmodified lines
341
342
343
344
345
346
347
348
import UIKit
#if canImport(MobileVLCKit)
import MobileVLCKit
67 unmodified lines
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
addConservativePlaybackOptions(to: media)
mediaPlayer.currentAudioPlaybackDelay = 0
mediaPlayer.media = media
#if DEBUG
10 unmodified lines
}
func play() {
#if canImport(MobileVLCKit)
#if DEBUG
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play()
15 unmodified lines
#endif
return
}
mediaPlayer.pause()
#if DEBUG
logPlaybackSnapshot(reason: "after-pause-command")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.logPlaybackSnapshot(reason: "pause-follow-up-250ms")
220 unmodified lines
":clock-jitter=0"
].forEach { media.addOption($0) }
}
private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
switch state {
case .playing, .buffering:
return true
1
2
3
4
5
67 unmodified lines
73
74
75
76
77
78
79
80
81
10 unmodified lines
92
93
94
95
96
97
98
99
100
101
102
103
15 unmodified lines
119
120
121
122
123
124
125
126
127
220 unmodified lines
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
import AVFoundation
import UIKit
#if canImport(MobileVLCKit)
import MobileVLCKit
67 unmodified lines
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
addConservativePlaybackOptions(to: media)
prepareAudioSessionForPlayback(reason: "initial-play")
mediaPlayer.currentAudioPlaybackDelay = 0
mediaPlayer.media = media
#if DEBUG
10 unmodified lines
}
func play() {
#if canImport(MobileVLCKit)
let toggleState = playbackToggleState(for: mediaPlayer.state)
if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {
prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")
}
#if DEBUG
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play()
15 unmodified lines
#endif
return
}
mediaPlayer.pause()
keepAudioSessionWarmAfterPause()
#if DEBUG
logPlaybackSnapshot(reason: "after-pause-command")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.logPlaybackSnapshot(reason: "pause-follow-up-250ms")
220 unmodified lines
":clock-jitter=0"
].forEach { media.addOption($0) }
}
private func prepareAudioSessionForPlayback(reason: String) {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback, options: [])
try session.setActive(true)
#if DEBUG
print("[DreamioVLC] audio-session prepared reason=\(reason) category=\(session.category.rawValue) mode=\(session.mode.rawValue)")
#endif
} catch {
#if DEBUG
print("[DreamioVLC] audio-session prepare failed reason=\(reason) error=\(error.localizedDescription)")
#endif
}
}
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 isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
switch state {
case .playing, .buffering:
return true

Tests/StreamResolverTests.swift ยท covers the audio-session resume policy

Tests/StreamResolverTests.swift
+12
23 unmodified lines
24
25
26
27
28
29
30
31
483 unmodified lines
515
516
517
518
519
520
521
522
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
print("StreamResolverTests passed")
}
private static func testClassifierPrefersObservedDirectFile() {
483 unmodified lines
assertEqual(NativePlaybackTogglePolicy.action(for: .opening), .waitForTransition)
assertEqual(NativePlaybackTogglePolicy.action(for: .unknown), .waitForTransition)
}
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)
}
23 unmodified lines
24
25
26
27
28
29
30
31
32
483 unmodified lines
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
print("StreamResolverTests passed")
}
private static func testClassifierPrefersObservedDirectFile() {
483 unmodified lines
assertEqual(NativePlaybackTogglePolicy.action(for: .opening), .waitForTransition)
assertEqual(NativePlaybackTogglePolicy.action(for: .unknown), .waitForTransition)
}
private static func testNativePlaybackAudioSessionPolicy() {
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .paused), true)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .stopped), true)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .ended), true)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .error), true)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .playing), false)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .buffering), false)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .opening), false)
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .unknown), false)
}
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)
}

Diffs are generated with @pierre/diffs/ssr at documentation time. Each file diff stays inside its own shell so the page remains readable as a static HTML artifact.

Expected Impact for End-Users

After pausing and pressing play, audio should be ready before VLC restarts visible playback, reducing the perceived gap where the video moves but audio has not yet caught up.

New Changes as of 2026-05-27 00:46

Summary of changes

Follow-up work now treats the recurring resume lag as a VLC resume-clock issue rather than only an iOS audio-session warm-up issue. The resume path captures the paused timestamp, removes the aggressive :clock-jitter=0 streaming option, and briefly holds VLC at the paused media time if video advances before MobileVLCKit reports audio loudness.

Why this change was made

The lag happened on every resume, which means the previous audio-session-only mitigation was not sufficient. MobileVLCKit exposes time and loudness callbacks, and its headers note that loudness timestamps can be affected by audio output buffering. The new path uses those signals to prevent silent video advancement during resume.

Code diffs

Dreamio/NativePlaybackBackend.swift ยท adds testable resume-hold policy

Dreamio/NativePlaybackBackend.swift
+20
74 unmodified lines
75
76
77
78
79
80
74 unmodified lines
}
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
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
98
99
100
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
}
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut

Dreamio/VLCNativePlaybackBackend.swift ยท removes clock-jitter override and holds video until audio output is observed

Dreamio/VLCNativePlaybackBackend.swift
-3+101
32 unmodified lines
33
34
35
36
37
38
22 unmodified lines
61
62
63
64
65
66
27 unmodified lines
94
95
96
97
98
99
1 unmodified line
101
102
103
104
105
106
12 unmodified lines
119
120
121
122
123
124
111 unmodified lines
236
237
238
239
240
241
102 unmodified lines
344
345
346
347
348
349
350
351
19 unmodified lines
371
372
373
374
375
376
27 unmodified lines
404
405
406
407
408
409
410
156 unmodified lines
567
568
569
570
571
572
32 unmodified lines
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
private var didReportReadyForCurrentMedia = false
private var lastToggleDate = Date.distantPast
private let minimumToggleInterval: TimeInterval = 0.35
22 unmodified lines
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
27 unmodified lines
func play() {
#if canImport(MobileVLCKit)
let toggleState = playbackToggleState(for: mediaPlayer.state)
if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {
prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")
}
1 unmodified line
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play()
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
#endif
12 unmodified lines
#endif
return
}
mediaPlayer.pause()
keepAudioSessionWarmAfterPause()
#if DEBUG
111 unmodified lines
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
102 unmodified lines
[
":network-caching=1000",
":file-caching=1000",
":live-caching=1000",
":clock-jitter=0"
].forEach { media.addOption($0) }
}
19 unmodified lines
}
}
private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {
switch state {
case .playing, .buffering:
27 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) readyReported=\(didReportReadyForCurrentMedia)")
}
#endif
156 unmodified lines
#if canImport(MobileVLCKit)
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG
logPlaybackSnapshot(reason: "state-change")
32 unmodified lines
33
34
35
36
37
38
39
40
41
42
22 unmodified lines
65
66
67
68
69
70
71
27 unmodified lines
99
100
101
102
103
104
105
1 unmodified line
107
108
109
110
111
112
113
114
115
116
117
12 unmodified lines
130
131
132
133
134
135
136
137
111 unmodified lines
249
250
251
252
253
254
255
102 unmodified lines
358
359
360
361
362
363
364
19 unmodified lines
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
460
461
462
27 unmodified lines
490
491
492
493
494
495
496
156 unmodified lines
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
32 unmodified lines
private var pendingExternalSubtitleDisplayNames: [String] = []
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
22 unmodified lines
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
resetResumeCorrectionState()
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
27 unmodified lines
func play() {
#if canImport(MobileVLCKit)
let toggleState = playbackToggleState(for: mediaPlayer.state)
let isResumingFromPause = toggleState == .paused
if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {
prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")
}
1 unmodified line
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play()
if isResumingFromPause {
beginResumeCorrection()
} else {
resetResumeCorrectionState()
}
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
#endif
12 unmodified lines
#endif
return
}
pausedTimeMilliseconds = mediaPlayer.time.intValue
resetResumeCorrectionRuntimeState()
mediaPlayer.pause()
keepAudioSessionWarmAfterPause()
#if DEBUG
111 unmodified lines
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
resetResumeCorrectionState()
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
102 unmodified lines
[
":network-caching=1000",
":file-caching=1000",
":live-caching=1000"
].forEach { media.addOption($0) }
}
19 unmodified lines
}
}
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 {
switch state {
case .playing, .buffering:
27 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
156 unmodified lines
#if canImport(MobileVLCKit)
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) {
#if DEBUG
logPlaybackSnapshot(reason: "state-change")

Tests/StreamResolverTests.swift ยท covers resume-hold policy decisions

Tests/StreamResolverTests.swift
+36
25 unmodified lines
26
27
28
29
30
31
496 unmodified lines
528
529
530
531
532
533
25 unmodified lines
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
print("StreamResolverTests passed")
}
496 unmodified lines
assertEqual(NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: .unknown), false)
}
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)
}
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
568
569
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) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}

Related issues or PRs

No Beads issue was provided for this follow-up. Manual device validation remains the key confirmation step for the original timing-sensitive symptom.

Validation

  • DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build succeeded for the original change.
  • xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build succeeded for the follow-up resume correction.
  • The build still reports the existing MobileVLCKit run-script output warning and AppIntents metadata warning.

Issues, Limitations, and Mitigations

  • This is a native route-warmth fix; it still needs device playback confirmation because the original symptom is timing-sensitive and hardware/audio-route dependent.
  • If a specific Bluetooth or AirPlay route still wakes slowly, further mitigation may need route-specific handling or a brief visual resume hold.

Follow-up Work

  • Manually validate pause/resume on device speakers, Bluetooth, and AirPlay if available.
  • If lag remains, add measured resume diagnostics for state-change time, first audible audio route availability, and video clock movement.