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.
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
NativePlaybackResumePolicyseek-hold loop that rewound video every 80 ms until loudness changed. - Added
NativePlaybackStreamingOptionsPolicywith one conservative:network-caching=1000option. - Dropped always-on
:file-caching=1000and:live-caching=1000for 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.swiftso 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
74 unmodified lines757677787980818283848586878889909192939495969774 unmodified lines}}enum NativePlaybackResumePolicy {static let freezeInterval: TimeInterval = 0.08static let maximumFreezeDuration: TimeInterval = 1.2static let maximumAllowedSilentAdvance: Int32 = 120static 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 lines757677787980818283848574 unmodified lines}}enum NativePlaybackStreamingOptionsPolicy {static let networkCachingMilliseconds = 1000static func mediaOptions() -> [String] {[":network-caching=\(networkCachingMilliseconds)"]}}
Dreamio/VLCNativePlaybackBackend.swift ยท remove resume seek-holding
33 unmodified lines343536373839404123 unmodified lines6566676869707136 unmodified lines10810911011111211311411511614 unmodified lines131132133134135136137138139109 unmodified lines24925025125225325425599 unmodified lines35535635735835936036136236336436511 unmodified lines37737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845930 unmodified lines490491492493494495496158 unmodified lines65565665765865966066133 unmodified linesprivate var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]private var didReportReadyForCurrentMedia = falseprivate var pausedTimeMilliseconds: Int32?private var resumeCorrectionGeneration = 0private var resumeCorrectionStartDate: Date?private var hasObservedResumeAudioOutput = falseprivate var lastToggleDate = Date.distantPastprivate let minimumToggleInterval: TimeInterval = 0.3523 unmodified linespendingExternalSubtitleDisplayNames.removeAll()externalSubtitleDisplayNamesByTrackID.removeAll()didReportReadyForCurrentMedia = falseresetResumeCorrectionState()lastToggleDate = .distantPastlet media = VLCMedia(url: request.playbackURL)let headerValue = request.headers36 unmodified lines#endifmediaPlayer.play()if isResumingFromPause {beginResumeCorrection()} else {resetResumeCorrectionState()}#if DEBUGlogPlaybackSnapshot(reason: "after-play-command")14 unmodified linesreturn}pausedTimeMilliseconds = mediaPlayer.time.intValueresetResumeCorrectionRuntimeState()mediaPlayer.pause()keepAudioSessionWarmAfterPause()#if DEBUGlogPlaybackSnapshot(reason: "after-pause-command")DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in109 unmodified lineslogPlaybackSnapshot(reason: "before-stop")#endifdidReportReadyForCurrentMedia = falseresetResumeCorrectionState()mediaPlayer.stop()mediaPlayer.drawable = nilmediaPlayer.media = nil99 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] inself?.prepareAudioSessionForPlayback(reason: "pause-keep-warm-follow-up")}}private func beginResumeCorrection() {guard let pausedTimeMilliseconds else {return}resetResumeCorrectionRuntimeState()resumeCorrectionGeneration += 1let generation = resumeCorrectionGenerationresumeCorrectionStartDate = Date()#if DEBUGprint("[DreamioVLC] resume-correction begin pausedTimeMS=\(pausedTimeMilliseconds)")#endifscheduleResumeCorrectionTick(generation: generation)}private func scheduleResumeCorrectionTick(generation: Int) {DispatchQueue.main.asyncAfter(deadline: .now() + NativePlaybackResumePolicy.freezeInterval) { [weak self] inself?.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.intValuelet advance = max(0, currentTimeMilliseconds - pausedTimeMilliseconds)let shouldHold = NativePlaybackResumePolicy.shouldHoldVideoAtPausedTime(elapsedSinceResume: elapsed,hasObservedAudioOutput: hasObservedResumeAudioOutput,mediaAdvanceMilliseconds: advance)guard shouldHold else {#if DEBUGprint("[DreamioVLC] resume-correction release elapsed=\(String(format: "%.3f", elapsed)) audioObserved=\(hasObservedResumeAudioOutput) advanceMS=\(advance)")#endifresetResumeCorrectionRuntimeState()return}mediaPlayer.time = VLCTime(int: pausedTimeMilliseconds)#if DEBUGprint("[DreamioVLC] resume-correction hold elapsed=\(String(format: "%.3f", elapsed)) advanceMS=\(advance) resetToMS=\(pausedTimeMilliseconds)")#endifscheduleResumeCorrectionTick(generation: generation)}private func noteResumeAudioOutputIfNeeded(reason: String) {guard resumeCorrectionStartDate != nil else {return}hasObservedResumeAudioOutput = true#if DEBUGlet loudness = mediaPlayer.momentaryLoudnessprint("[DreamioVLC] resume-correction audio-observed reason=\(reason) loudness=\(loudness?.loudnessValue ?? 0) date=\(loudness?.date ?? 0)")#endif}private func resetResumeCorrectionState() {pausedTimeMilliseconds = nilresetResumeCorrectionRuntimeState()}private func resetResumeCorrectionRuntimeState() {resumeCorrectionGeneration += 1resumeCorrectionStartDate = nilhasObservedResumeAudioOutput = false}30 unmodified lines#if DEBUGprivate func logPlaybackSnapshot(reason: String) {let mediaLength = mediaPlayer.media?.length.intValue ?? 0print("[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)")}#endif158 unmodified linesextension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerTimeChanged(_ aNotification: Notification) {#if DEBUGif resumeCorrectionStartDate != nil {logPlaybackSnapshot(reason: "time-change-during-resume")}#endif33 unmodified lines343536373839404123 unmodified lines6566676869707136 unmodified lines10810911011111211311411511614 unmodified lines131132133134135136137138109 unmodified lines24824925025125225325499 unmodified lines35435535635735835936011 unmodified lines37237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742830 unmodified lines459460461462463464465158 unmodified lines62462562662762862963033 unmodified linesprivate var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]private var didReportReadyForCurrentMedia = falseprivate var pausedTimeMilliseconds: Int32?private var resumeObservationGeneration = 0private var resumeObservationStartDate: Date?private var hasObservedResumeAudioOutput = falseprivate var lastToggleDate = Date.distantPastprivate let minimumToggleInterval: TimeInterval = 0.3523 unmodified linespendingExternalSubtitleDisplayNames.removeAll()externalSubtitleDisplayNamesByTrackID.removeAll()didReportReadyForCurrentMedia = falseresetResumeObservationState()lastToggleDate = .distantPastlet media = VLCMedia(url: request.playbackURL)let headerValue = request.headers36 unmodified lines#endifmediaPlayer.play()if isResumingFromPause {beginResumeObservation()} else {resetResumeObservationState()}#if DEBUGlogPlaybackSnapshot(reason: "after-play-command")14 unmodified linesreturn}pausedTimeMilliseconds = mediaPlayer.time.intValueresetResumeObservationRuntimeState()mediaPlayer.pause()#if DEBUGlogPlaybackSnapshot(reason: "after-pause-command")DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in109 unmodified lineslogPlaybackSnapshot(reason: "before-stop")#endifdidReportReadyForCurrentMedia = falseresetResumeObservationState()mediaPlayer.stop()mediaPlayer.drawable = nilmediaPlayer.media = nil99 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 += 1let generation = resumeObservationGenerationresumeObservationStartDate = Date()#if DEBUGprint("[DreamioVLC] resume-observation begin pausedTimeMS=\(pausedTimeMilliseconds)")[0.25, 0.75, 1.5].forEach { delay inDispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] inself?.logResumeObservation(generation: generation, delay: delay)}}#endif}#if DEBUGprivate 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))")}#endifprivate func noteResumeAudioOutputIfNeeded(reason: String) {guard resumeObservationStartDate != nil else {return}hasObservedResumeAudioOutput = true#if DEBUGlet loudness = mediaPlayer.momentaryLoudnessprint("[DreamioVLC] resume-observation audio-observed reason=\(reason) loudness=\(loudness?.loudnessValue ?? 0) date=\(loudness?.date ?? 0)")#endif}private func resetResumeObservationState() {pausedTimeMilliseconds = nilresetResumeObservationRuntimeState()}private func resetResumeObservationRuntimeState() {resumeObservationGeneration += 1resumeObservationStartDate = nilhasObservedResumeAudioOutput = false}30 unmodified lines#if DEBUGprivate func logPlaybackSnapshot(reason: String) {let mediaLength = mediaPlayer.media?.length.intValue ?? 0print("[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)")}#endif158 unmodified linesextension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerTimeChanged(_ aNotification: Notification) {#if DEBUGif resumeObservationStartDate != nil {logPlaybackSnapshot(reason: "time-change-during-resume")}#endif
Tests/StreamResolverTests.swift ยท policy coverage
25 unmodified lines26272829303132496 unmodified lines52953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656725 unmodified linestestSubtitleOptionMappingIncludesNone()testNativePlaybackTogglePolicy()testNativePlaybackAudioSessionPolicy()testNativePlaybackResumePolicy()print("StreamResolverTests passed")}496 unmodified linesassertEqual(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 lines26272829303132496 unmodified lines52953053153253353453553653753853925 unmodified linestestSubtitleOptionMappingIncludesNone()testNativePlaybackTogglePolicy()testNativePlaybackAudioSessionPolicy()testNativePlaybackStreamingOptionsPolicy()print("StreamResolverTests passed")}496 unmodified linesassertEqual(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-observationticks and test a protocol-specific cache profile rather than reintroducing repeated seeks.