Dreamio turn document
Fix VLC Playback Sync
VLC native playback now uses state-aware play and pause commands, reports readiness once per media item, and separates fast progress refreshes from slower track-menu work.
Summary
This change hardens native VLC playback against pause lag and UI churn. It adds debug snapshots around playback commands and state changes, gates rapid toggles, uses VLC state rather than only isPlaying, and applies conservative caching options for streamed media.
Changes Made
- Added a pure toggle policy that maps playback states to play, pause, or wait actions.
- Instrumented VLC play, pause, toggle, stop, state changes, buffering, current time, position, and audio delay in DEBUG builds.
- Updated pause handling to require
canPauseplus an active VLC state before sendingpause(). - Added a ready-once flag so
onReadyis only emitted once for eachplay(request:). - Reduced repeated main-thread work by splitting progress updates from audio and subtitle menu refreshes.
- Added conservative stream caching and clock jitter options, while leaving
:clock-synchro=0out until device testing proves it helps.
Context
The reported failure looked like either VLC draining audio after the video paused, or the UI flipping state from stale isPlaying. The new logs are designed to distinguish those cases by showing command timing, VLC state, play status, media time, position, buffering transitions, and currentAudioPlaybackDelay.
Important Implementation Details
.playingand.bufferingare pauseable;.paused,.stopped,.ended, and.errorare playable;.openingand unknown states wait for VLC to finish transitioning.- Rapid toggles are ignored for 350 ms to avoid sending contradictory play and pause commands while MobileVLCKit is transitioning.
- Audio delay is reset on new streams and after audio track selection, with debug snapshots available before considering a user-facing delay control.
- Readiness still hides startup UI and starts progress polling, but repeated buffering and playing transitions now only update state.
Relevant Diff Snippets
Dreamio/NativePlaybackBackend.swift ยท state-aware toggle policy
33 unmodified lines34353637383933 unmodified linesfunc stop()}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut33 unmodified lines34353637383940414243444546474849505152535455565758596061626364656667686933 unmodified linesfunc stop()}enum NativePlaybackToggleState {case openingcase bufferingcase playingcase pausedcase stoppedcase endedcase errorcase unknown}enum NativePlaybackToggleAction {case playcase pausecase waitForTransition}enum NativePlaybackTogglePolicy {static func action(for state: NativePlaybackToggleState) -> NativePlaybackToggleAction {switch state {case .playing, .buffering:return .pausecase .paused, .stopped, .ended, .error:return .playcase .opening, .unknown:return .waitForTransition}}}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut
Dreamio/VLCNativePlaybackBackend.swift ยท VLC command hardening and instrumentation
30 unmodified lines31323334353619 unmodified lines5657585960615 unmodified lines6768697071727374757677781 unmodified line80818283848586878889909192939495969721 unmodified lines11912012112212312412512638 unmodified lines16516616716816917098 unmodified lines269270271272273274155 unmodified lines4304314324334344354364374384394404414424434444454468 unmodified lines45545645745845946030 unmodified linesprivate var hasPendingExternalSubtitleSelection = falseprivate var pendingExternalSubtitleDisplayNames: [String] = []private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]override init() {super.init()19 unmodified lineshasPendingExternalSubtitleSelection = falsependingExternalSubtitleDisplayNames.removeAll()externalSubtitleDisplayNamesByTrackID.removeAll()let media = VLCMedia(url: request.playbackURL)let headerValue = request.headers.map { "\($0.key): \($0.value)" }5 unmodified linesif !headerValue.isEmpty {media.addOption(":http-header=\(headerValue)")}mediaPlayer.media = media#if DEBUGprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endifmediaPlayer.play()#elseonFailure?(NativePlaybackError.backendUnavailable)#endif1 unmodified linefunc play() {#if canImport(MobileVLCKit)mediaPlayer.play()#endif}func pause() {#if canImport(MobileVLCKit)mediaPlayer.pause()#endif}func togglePlayPause() {isPlaying ? pause() : play()}func seek(to position: Float) {21 unmodified lineslogAudioTracks(reason: "before-select-\(id)")#endifmediaPlayer.currentAudioTrackIndex = id#if DEBUGlogAudioTracks(reason: "after-select-\(id)")#endifonAudioTracksChange?()#endif38 unmodified linesfunc stop() {#if canImport(MobileVLCKit)mediaPlayer.stop()mediaPlayer.drawable = nilmediaPlayer.media = nil98 unmodified lines}#if canImport(MobileVLCKit)private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {var attachedCount = 0var duplicateCount = 0155 unmodified linesextension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerStateChanged(_ aNotification: Notification) {#if DEBUGprint("[DreamioVLC] state=\(stateName(mediaPlayer.state))")#endifswitch mediaPlayer.state {case .buffering, .playing:reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))onReady?()onStateChange?()onAudioTracksChange?()case .error:onFailure?(NativePlaybackError.playbackFailed)case .paused, .stopped, .ended:onStateChange?()case .esAdded:selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")8 unmodified lines}}private func stateName(_ state: VLCMediaPlayerState) -> String {switch state {case .opening:30 unmodified lines31323334353637383919 unmodified lines59606162636465665 unmodified lines7273747576777879808182838485868788891 unmodified line91929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415521 unmodified lines17717817918018118218318418518638 unmodified lines22522622722822923023123223323498 unmodified lines333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384155 unmodified lines5405415425435445455465475485495505515525535545555565575585595608 unmodified lines56957057157257357457557657757857958058158258358458558658758858959030 unmodified linesprivate var hasPendingExternalSubtitleSelection = falseprivate var pendingExternalSubtitleDisplayNames: [String] = []private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]private var didReportReadyForCurrentMedia = falseprivate var lastToggleDate = Date.distantPastprivate let minimumToggleInterval: TimeInterval = 0.35override init() {super.init()19 unmodified lineshasPendingExternalSubtitleSelection = falsependingExternalSubtitleDisplayNames.removeAll()externalSubtitleDisplayNamesByTrackID.removeAll()didReportReadyForCurrentMedia = falselastToggleDate = .distantPastlet media = VLCMedia(url: request.playbackURL)let headerValue = request.headers.map { "\($0.key): \($0.value)" }5 unmodified linesif !headerValue.isEmpty {media.addOption(":http-header=\(headerValue)")}addConservativePlaybackOptions(to: media)mediaPlayer.currentAudioPlaybackDelay = 0mediaPlayer.media = media#if DEBUGprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) options=conservative-low-latency")logPlaybackSnapshot(reason: "before-initial-play")#endifmediaPlayer.play()#if DEBUGlogPlaybackSnapshot(reason: "after-initial-play-command")#endif#elseonFailure?(NativePlaybackError.backendUnavailable)#endif1 unmodified linefunc play() {#if canImport(MobileVLCKit)#if DEBUGlogPlaybackSnapshot(reason: "before-play")#endifmediaPlayer.play()#if DEBUGlogPlaybackSnapshot(reason: "after-play-command")#endif#endif}func pause() {#if canImport(MobileVLCKit)let state = mediaPlayer.state#if DEBUGlogPlaybackSnapshot(reason: "before-pause canPause=\(mediaPlayer.canPause) pauseable=\(isPauseableState(state))")#endifguard mediaPlayer.canPause, isPauseableState(state) else {#if DEBUGprint("[DreamioVLC] pause skipped state=\(stateName(state)) canPause=\(mediaPlayer.canPause)")#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")}#endif#endif}func togglePlayPause() {#if canImport(MobileVLCKit)let now = Date()guard now.timeIntervalSince(lastToggleDate) >= minimumToggleInterval else {#if DEBUGprint("[DreamioVLC] toggle skipped rapid-repeat elapsed=\(String(format: "%.3f", now.timeIntervalSince(lastToggleDate)))")#endifreturn}lastToggleDate = nowlet state = playbackToggleState(for: mediaPlayer.state)let action = NativePlaybackTogglePolicy.action(for: state)#if DEBUGlogPlaybackSnapshot(reason: "toggle action=\(action) mappedState=\(state)")#endifswitch action {case .play:play()case .pause:pause()case .waitForTransition:break}#elseisPlaying ? pause() : play()#endif}func seek(to position: Float) {21 unmodified lineslogAudioTracks(reason: "before-select-\(id)")#endifmediaPlayer.currentAudioTrackIndex = idmediaPlayer.currentAudioPlaybackDelay = 0#if DEBUGlogAudioTracks(reason: "after-select-\(id)")logPlaybackSnapshot(reason: "after-audio-select-\(id)")#endifonAudioTracksChange?()#endif38 unmodified linesfunc stop() {#if canImport(MobileVLCKit)#if DEBUGlogPlaybackSnapshot(reason: "before-stop")#endifdidReportReadyForCurrentMedia = falsemediaPlayer.stop()mediaPlayer.drawable = nilmediaPlayer.media = nil98 unmodified lines}#if canImport(MobileVLCKit)private func addConservativePlaybackOptions(to media: VLCMedia) {[":network-caching=1000",":file-caching=1000",":live-caching=1000",":clock-jitter=0"].forEach { media.addOption($0) }}private func isPauseableState(_ state: VLCMediaPlayerState) -> Bool {switch state {case .playing, .buffering:return truedefault:return false}}private func playbackToggleState(for state: VLCMediaPlayerState) -> NativePlaybackToggleState {switch state {case .opening:return .openingcase .buffering:return .bufferingcase .playing:return .playingcase .paused:return .pausedcase .stopped:return .stoppedcase .ended:return .endedcase .error:return .errordefault:return .unknown}}#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) readyReported=\(didReportReadyForCurrentMedia)")}#endifprivate func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {var attachedCount = 0var duplicateCount = 0155 unmodified linesextension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerStateChanged(_ aNotification: Notification) {#if DEBUGlogPlaybackSnapshot(reason: "state-change")#endifswitch mediaPlayer.state {case .buffering, .playing:reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))reportReadyIfNeeded()onStateChange?()case .error:onFailure?(NativePlaybackError.playbackFailed)case .paused, .stopped, .ended:#if DEBUGDispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] inself?.logPlaybackSnapshot(reason: "inactive-state-follow-up-250ms")}#endifonStateChange?()case .esAdded:selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")8 unmodified lines}}private func reportReadyIfNeeded() {guard !didReportReadyForCurrentMedia else {#if DEBUGprint("[DreamioVLC] ready skipped already-reported state=\(stateName(mediaPlayer.state))")#endifreturn}didReportReadyForCurrentMedia = true#if DEBUGprint("[DreamioVLC] ready reported state=\(stateName(mediaPlayer.state))")#endifonReady?()onAudioTracksChange?()onSubtitleTracksChange?()}private func stateName(_ state: VLCMediaPlayerState) -> String {switch state {case .opening:
Dreamio/NativePlayerViewController.swift ยท progress/menu refresh split
287 unmodified lines288289290291292293294295296297298299300301302303304341 unmodified lines646647648649650651652653654655656657658659660661662663664665666667668669670671287 unmodified lines}backend.onStateChange = { [weak self] inDispatchQueue.main.async {self?.refreshControls()}}backend.onSubtitleTracksChange = { [weak self] inDispatchQueue.main.async {self?.refreshControls()}}backend.onAudioTracksChange = { [weak self] inDispatchQueue.main.async {self?.refreshControls()}}}341 unmodified linesprivate func startProgressUpdates() {progressTimer?.invalidate()progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ inself?.refreshControls()}}private func refreshControls() {let audioTracks = backend.audioTrackslet subtitleTracks = backend.subtitleTracksplayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)scrubber.isEnabled = backend.isSeekablejumpBackButton.isEnabled = backend.isSeekablejumpForwardButton.isEnabled = backend.isSeekableupdateAudioMenuIfNeeded(audioTracks: audioTracks)updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"if !isScrubbing {scrubber.value = backend.position}[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }}private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) {287 unmodified lines288289290291292293294295296297298299300301302303304341 unmodified lines646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678287 unmodified lines}backend.onStateChange = { [weak self] inDispatchQueue.main.async {self?.refreshProgressControls()}}backend.onSubtitleTracksChange = { [weak self] inDispatchQueue.main.async {self?.refreshTrackMenus()}}backend.onAudioTracksChange = { [weak self] inDispatchQueue.main.async {self?.refreshTrackMenus()}}}341 unmodified linesprivate func startProgressUpdates() {progressTimer?.invalidate()progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ inself?.refreshProgressControls()}}private func refreshControls() {refreshProgressControls()refreshTrackMenus()}private func refreshProgressControls() {let isSeekable = backend.isSeekableplayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)scrubber.isEnabled = isSeekablejumpBackButton.isEnabled = isSeekablejumpForwardButton.isEnabled = isSeekableelapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"if !isScrubbing {scrubber.value = backend.position}[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = isSeekable ? 1 : 0.45 }}private func refreshTrackMenus() {updateAudioMenuIfNeeded(audioTracks: backend.audioTracks)updateCaptionsMenuIfNeeded(subtitleTracks: backend.subtitleTracks)}private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) {
Tests/StreamResolverTests.swift ยท toggle policy coverage
23 unmodified lines242526272829475 unmodified lines50550650750850951023 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}475 unmodified linesassertEqual(options.map(\.name), ["None", "English", "Commentary"])}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 lines24252627282930475 unmodified lines50650750850951051151251351451551651751851952052123 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testNativePlaybackTogglePolicy()print("StreamResolverTests passed")}475 unmodified linesassertEqual(options.map(\.name), ["None", "English", "Commentary"])}private static func testNativePlaybackTogglePolicy() {assertEqual(NativePlaybackTogglePolicy.action(for: .playing), .pause)assertEqual(NativePlaybackTogglePolicy.action(for: .buffering), .pause)assertEqual(NativePlaybackTogglePolicy.action(for: .paused), .play)assertEqual(NativePlaybackTogglePolicy.action(for: .stopped), .play)assertEqual(NativePlaybackTogglePolicy.action(for: .ended), .play)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)}
Diffs were 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
Pause and resume should feel more deterministic, rapid taps should be less likely to desynchronize audio and video, and normal playback should spend less time rebuilding audio and subtitle menus during ordinary progress ticks.
Validation
xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator buildsucceeded.- The focused Swift compile for the existing test sources plus
NativePlaybackBackend.swiftsucceeded for the iOS simulator target. It cannot execute as a standalone iOS binary from the shell. - Manual device validation is still required for MobileVLCKit timing and real audio-buffer behavior.
Issues, Limitations, and Mitigations
- No device was available in this session, so the logs are in place to confirm whether remaining lag is buffer drain or stale UI state.
- The workaround options of setting playback rate to 0 or briefly muting remain intentionally unused until the new instrumentation proves they are necessary.
:clock-synchro=0was not added because it can affect normal sync on unstable streams and needs device evidence first.
Follow-up Work
- Run the manual validation matrix on device for pause, resume, rapid toggles, seeking, audio switching, subtitles, and 10 to 15 minutes of smooth playback.
- If DEBUG logs show persistent audio after pause, test MobileVLCKit-specific mitigation options in isolation.
- If glitches correlate with elementary stream events, consider more granular menu refresh debouncing.