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.
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
AVAudioSessionwith.playbackand.moviePlaybackbefore 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
62 unmodified lines636465666768697062 unmodified lines}}}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOutcase playbackFailed62 unmodified lines6364656667686970717273747576777879808162 unmodified lines}}}enum NativePlaybackAudioSessionPolicy {static func shouldPrepareBeforePlayback(from state: NativePlaybackToggleState) -> Bool {switch state {case .paused, .stopped, .ended, .error:return truecase .opening, .buffering, .playing, .unknown:return false}}}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOutcase playbackFailed
Dreamio/VLCNativePlaybackBackend.swift ยท prepares and keeps AVAudioSession active around pause/resume
123467 unmodified lines727374757677787910 unmodified lines909192939495969715 unmodified lines113114115116117118119120220 unmodified lines341342343344345346347348import UIKit#if canImport(MobileVLCKit)import MobileVLCKit67 unmodified linesif !headerValue.isEmpty {media.addOption(":http-header=\(headerValue)")}addConservativePlaybackOptions(to: media)mediaPlayer.currentAudioPlaybackDelay = 0mediaPlayer.media = media#if DEBUG10 unmodified lines}func play() {#if canImport(MobileVLCKit)#if DEBUGlogPlaybackSnapshot(reason: "before-play")#endifmediaPlayer.play()15 unmodified lines#endifreturn}mediaPlayer.pause()#if DEBUGlogPlaybackSnapshot(reason: "after-pause-command")DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] inself?.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 true1234567 unmodified lines73747576777879808110 unmodified lines929394959697989910010110210315 unmodified lines119120121122123124125126127220 unmodified lines348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377import AVFoundationimport UIKit#if canImport(MobileVLCKit)import MobileVLCKit67 unmodified linesif !headerValue.isEmpty {media.addOption(":http-header=\(headerValue)")}addConservativePlaybackOptions(to: media)prepareAudioSessionForPlayback(reason: "initial-play")mediaPlayer.currentAudioPlaybackDelay = 0mediaPlayer.media = media#if DEBUG10 unmodified lines}func play() {#if canImport(MobileVLCKit)let toggleState = playbackToggleState(for: mediaPlayer.state)if NativePlaybackAudioSessionPolicy.shouldPrepareBeforePlayback(from: toggleState) {prepareAudioSessionForPlayback(reason: "resume-from-\(toggleState)")}#if DEBUGlogPlaybackSnapshot(reason: "before-play")#endifmediaPlayer.play()15 unmodified lines#endifreturn}mediaPlayer.pause()keepAudioSessionWarmAfterPause()#if DEBUGlogPlaybackSnapshot(reason: "after-pause-command")DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] inself?.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 DEBUGprint("[DreamioVLC] audio-session prepared reason=\(reason) category=\(session.category.rawValue) mode=\(session.mode.rawValue)")#endif} catch {#if DEBUGprint("[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] inself?.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
23 unmodified lines2425262728293031483 unmodified lines51551651751851952052152223 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testNativePlaybackTogglePolicy()print("StreamResolverTests passed")}private static func testClassifierPrefersObservedDirectFile() {483 unmodified linesassertEqual(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 lines242526272829303132483 unmodified lines51651751851952052152252352452552652752852953053153253353423 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testNativePlaybackTogglePolicy()testNativePlaybackAudioSessionPolicy()print("StreamResolverTests passed")}private static func testClassifierPrefersObservedDirectFile() {483 unmodified linesassertEqual(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.
Validation
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator buildsucceeded.- 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.