diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index a0cd213..bba1d40 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -40,3 +40,4 @@ {"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}} {"id":"int-fc9ecdb1","kind":"field_change","created_at":"2026-05-27T01:50:32.02792Z","actor":"dirtydishes","issue_id":"dreamio-ee1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed baseline UX audit in docs/native-player-ux-audit.md"}} {"id":"int-c8a14c48","kind":"field_change","created_at":"2026-05-27T01:56:02.08139Z","actor":"dirtydishes","issue_id":"dreamio-060","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native player Liquid Glass UX improvements and validated simulator build."}} +{"id":"int-8c109835","kind":"field_change","created_at":"2026-05-27T03:47:00.090296Z","actor":"dirtydishes","issue_id":"dreamio-e3u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ba89dea..1595d55 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -46,6 +46,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-e3u","title":"Harden VLC play pause synchronization","description":"Implement state-aware MobileVLCKit play/pause handling, instrumentation, readiness gating, conservative caching, and validation for pause/audio lag.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T03:43:10Z","created_by":"dirtydishes","updated_at":"2026-05-27T03:47:00Z","started_at":"2026-05-27T03:43:55Z","closed_at":"2026-05-27T03:47:00Z","close_reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ee1","title":"Audit native player UX baseline","description":"Audit the existing native player controls and document current user experience strengths, gaps, and implementation constraints before making UI changes.","acceptance_criteria":"Current NativePlayerViewController controls are reviewed; backend constraints are summarized; UX improvement opportunities are documented.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:49:46Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:50:32Z","started_at":"2026-05-27T01:49:48Z","closed_at":"2026-05-27T01:50:32Z","close_reason":"Completed baseline UX audit in docs/native-player-ux-audit.md","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 90da71e..d394fbd 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -34,6 +34,36 @@ protocol NativePlaybackBackend: AnyObject { func stop() } +enum NativePlaybackToggleState { + case opening + case buffering + case playing + case paused + case stopped + case ended + case error + case unknown +} + +enum NativePlaybackToggleAction { + case play + case pause + case waitForTransition +} + +enum NativePlaybackTogglePolicy { + static func action(for state: NativePlaybackToggleState) -> NativePlaybackToggleAction { + switch state { + case .playing, .buffering: + return .pause + case .paused, .stopped, .ended, .error: + return .play + case .opening, .unknown: + return .waitForTransition + } + } +} + enum NativePlaybackError: LocalizedError { case backendUnavailable case startupTimedOut diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 48b7dd6..0c8a670 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -288,17 +288,17 @@ final class NativePlayerViewController: UIViewController { } backend.onStateChange = { [weak self] in DispatchQueue.main.async { - self?.refreshControls() + self?.refreshProgressControls() } } backend.onSubtitleTracksChange = { [weak self] in DispatchQueue.main.async { - self?.refreshControls() + self?.refreshTrackMenus() } } backend.onAudioTracksChange = { [weak self] in DispatchQueue.main.async { - self?.refreshControls() + self?.refreshTrackMenus() } } } @@ -646,26 +646,33 @@ final class NativePlayerViewController: UIViewController { private func startProgressUpdates() { progressTimer?.invalidate() progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.refreshControls() + self?.refreshProgressControls() } } private func refreshControls() { - let audioTracks = backend.audioTracks - let subtitleTracks = backend.subtitleTracks + refreshProgressControls() + refreshTrackMenus() + } + + private func refreshProgressControls() { + let isSeekable = backend.isSeekable playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) - scrubber.isEnabled = backend.isSeekable - jumpBackButton.isEnabled = backend.isSeekable - jumpForwardButton.isEnabled = backend.isSeekable - updateAudioMenuIfNeeded(audioTracks: audioTracks) - updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks) + scrubber.isEnabled = isSeekable + jumpBackButton.isEnabled = isSeekable + jumpForwardButton.isEnabled = isSeekable 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 } + [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]) { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index c3c2318..f55a38b 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -31,6 +31,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var hasPendingExternalSubtitleSelection = false private var pendingExternalSubtitleDisplayNames: [String] = [] private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:] + private var didReportReadyForCurrentMedia = false + private var lastToggleDate = Date.distantPast + private let minimumToggleInterval: TimeInterval = 0.35 override init() { super.init() @@ -56,6 +59,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { hasPendingExternalSubtitleSelection = false pendingExternalSubtitleDisplayNames.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll() + didReportReadyForCurrentMedia = false + lastToggleDate = .distantPast let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers .map { "\($0.key): \($0.value)" } @@ -67,12 +72,18 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { if !headerValue.isEmpty { media.addOption(":http-header=\(headerValue)") } + addConservativePlaybackOptions(to: media) + mediaPlayer.currentAudioPlaybackDelay = 0 mediaPlayer.media = media #if DEBUG - print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") + print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) options=conservative-low-latency") + logPlaybackSnapshot(reason: "before-initial-play") #endif mediaPlayer.play() +#if DEBUG + logPlaybackSnapshot(reason: "after-initial-play-command") +#endif #else onFailure?(NativePlaybackError.backendUnavailable) #endif @@ -80,18 +91,65 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play() { #if canImport(MobileVLCKit) +#if DEBUG + logPlaybackSnapshot(reason: "before-play") +#endif mediaPlayer.play() +#if DEBUG + logPlaybackSnapshot(reason: "after-play-command") +#endif #endif } func pause() { #if canImport(MobileVLCKit) + let state = mediaPlayer.state +#if DEBUG + logPlaybackSnapshot(reason: "before-pause canPause=\(mediaPlayer.canPause) pauseable=\(isPauseableState(state))") +#endif + guard mediaPlayer.canPause, isPauseableState(state) else { +#if DEBUG + print("[DreamioVLC] pause skipped state=\(stateName(state)) canPause=\(mediaPlayer.canPause)") +#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") + } +#endif #endif } func togglePlayPause() { +#if canImport(MobileVLCKit) + let now = Date() + guard now.timeIntervalSince(lastToggleDate) >= minimumToggleInterval else { +#if DEBUG + print("[DreamioVLC] toggle skipped rapid-repeat elapsed=\(String(format: "%.3f", now.timeIntervalSince(lastToggleDate)))") +#endif + return + } + lastToggleDate = now + + let state = playbackToggleState(for: mediaPlayer.state) + let action = NativePlaybackTogglePolicy.action(for: state) +#if DEBUG + logPlaybackSnapshot(reason: "toggle action=\(action) mappedState=\(state)") +#endif + switch action { + case .play: + play() + case .pause: + pause() + case .waitForTransition: + break + } +#else isPlaying ? pause() : play() +#endif } func seek(to position: Float) { @@ -119,8 +177,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { logAudioTracks(reason: "before-select-\(id)") #endif mediaPlayer.currentAudioTrackIndex = id + mediaPlayer.currentAudioPlaybackDelay = 0 #if DEBUG logAudioTracks(reason: "after-select-\(id)") + logPlaybackSnapshot(reason: "after-audio-select-\(id)") #endif onAudioTracksChange?() #endif @@ -165,6 +225,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func stop() { #if canImport(MobileVLCKit) +#if DEBUG + logPlaybackSnapshot(reason: "before-stop") +#endif + didReportReadyForCurrentMedia = false mediaPlayer.stop() mediaPlayer.drawable = nil mediaPlayer.media = nil @@ -269,6 +333,52 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } #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 true + default: + return false + } + } + + private func playbackToggleState(for state: VLCMediaPlayerState) -> NativePlaybackToggleState { + switch state { + case .opening: + return .opening + case .buffering: + return .buffering + case .playing: + return .playing + case .paused: + return .paused + case .stopped: + return .stopped + case .ended: + return .ended + case .error: + return .error + default: + return .unknown + } + } + +#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 + private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int { var attachedCount = 0 var duplicateCount = 0 @@ -430,17 +540,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification) { #if DEBUG - print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") + logPlaybackSnapshot(reason: "state-change") #endif switch mediaPlayer.state { case .buffering, .playing: reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state)) - onReady?() + reportReadyIfNeeded() onStateChange?() - onAudioTracksChange?() case .error: onFailure?(NativePlaybackError.playbackFailed) case .paused, .stopped, .ended: +#if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.logPlaybackSnapshot(reason: "inactive-state-follow-up-250ms") + } +#endif onStateChange?() case .esAdded: selectPreferredSubtitleTrackIfNeeded(reason: "esAdded") @@ -455,6 +569,22 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { } } + private func reportReadyIfNeeded() { + guard !didReportReadyForCurrentMedia else { +#if DEBUG + print("[DreamioVLC] ready skipped already-reported state=\(stateName(mediaPlayer.state))") +#endif + return + } + didReportReadyForCurrentMedia = true +#if DEBUG + print("[DreamioVLC] ready reported state=\(stateName(mediaPlayer.state))") +#endif + onReady?() + onAudioTracksChange?() + onSubtitleTracksChange?() + } + private func stateName(_ state: VLCMediaPlayerState) -> String { switch state { case .opening: diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c846579..c8dafb7 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -24,6 +24,7 @@ struct StreamResolverTests { testSubtitleDisplayNameNormalization() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() + testNativePlaybackTogglePolicy() print("StreamResolverTests passed") } @@ -505,6 +506,16 @@ struct StreamResolverTests { assertEqual(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(_ 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) } diff --git a/docs/turns/2026-05-26-fix-vlc-playback-sync.html b/docs/turns/2026-05-26-fix-vlc-playback-sync.html new file mode 100644 index 0000000..3d36d76 --- /dev/null +++ b/docs/turns/2026-05-26-fix-vlc-playback-sync.html @@ -0,0 +1,387 @@ + + + + + + Fix VLC Playback Sync + + + + + + +
+
+

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.

+
+ 2026-05-26 + Beads issue dreamio-e3u + MobileVLCKit +
+
+ +
+

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 canPause plus an active VLC state before sending pause().
  • Added a ready-once flag so onReady is only emitted once for each play(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=0 out 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

  • .playing and .buffering are pauseable; .paused, .stopped, .ended, and .error are playable; .opening and 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

+
+
Dreamio/NativePlaybackBackend.swift
+30
33 unmodified lines
34
35
36
37
38
39
33 unmodified lines
func stop()
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
33 unmodified lines
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
33 unmodified lines
func stop()
}
+
enum NativePlaybackToggleState {
case opening
case buffering
case playing
case paused
case stopped
case ended
case error
case unknown
}
+
enum NativePlaybackToggleAction {
case play
case pause
case waitForTransition
}
+
enum NativePlaybackTogglePolicy {
static func action(for state: NativePlaybackToggleState) -> NativePlaybackToggleAction {
switch state {
case .playing, .buffering:
return .pause
case .paused, .stopped, .ended, .error:
return .play
case .opening, .unknown:
return .waitForTransition
}
}
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
+
+
+
+
+

Dreamio/VLCNativePlaybackBackend.swift ยท VLC command hardening and instrumentation

+
+
Dreamio/VLCNativePlaybackBackend.swift
-4+134
30 unmodified lines
31
32
33
34
35
36
19 unmodified lines
56
57
58
59
60
61
5 unmodified lines
67
68
69
70
71
72
73
74
75
76
77
78
1 unmodified line
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
21 unmodified lines
119
120
121
122
123
124
125
126
38 unmodified lines
165
166
167
168
169
170
98 unmodified lines
269
270
271
272
273
274
155 unmodified lines
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
8 unmodified lines
455
456
457
458
459
460
30 unmodified lines
private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
+
override init() {
super.init()
19 unmodified lines
hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
5 unmodified lines
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
+
mediaPlayer.media = media
#if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
1 unmodified line
+
func 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 lines
logAudioTracks(reason: "before-select-\(id)")
#endif
mediaPlayer.currentAudioTrackIndex = id
#if DEBUG
logAudioTracks(reason: "after-select-\(id)")
#endif
onAudioTracksChange?()
#endif
38 unmodified lines
+
func stop() {
#if canImport(MobileVLCKit)
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
98 unmodified lines
}
+
#if canImport(MobileVLCKit)
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
155 unmodified lines
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
#endif
switch 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 lines
31
32
33
34
35
36
37
38
39
19 unmodified lines
59
60
61
62
63
64
65
66
5 unmodified lines
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
1 unmodified line
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
21 unmodified lines
177
178
179
180
181
182
183
184
185
186
38 unmodified lines
225
226
227
228
229
230
231
232
233
234
98 unmodified lines
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
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
378
379
380
381
382
383
384
155 unmodified lines
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
8 unmodified lines
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
30 unmodified lines
private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
private var didReportReadyForCurrentMedia = false
private var lastToggleDate = Date.distantPast
private let minimumToggleInterval: TimeInterval = 0.35
+
override init() {
super.init()
19 unmodified lines
hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
didReportReadyForCurrentMedia = false
lastToggleDate = .distantPast
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
5 unmodified lines
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
addConservativePlaybackOptions(to: media)
mediaPlayer.currentAudioPlaybackDelay = 0
+
mediaPlayer.media = media
#if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) options=conservative-low-latency")
logPlaybackSnapshot(reason: "before-initial-play")
#endif
mediaPlayer.play()
#if DEBUG
logPlaybackSnapshot(reason: "after-initial-play-command")
#endif
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
1 unmodified line
+
func play() {
#if canImport(MobileVLCKit)
#if DEBUG
logPlaybackSnapshot(reason: "before-play")
#endif
mediaPlayer.play()
#if DEBUG
logPlaybackSnapshot(reason: "after-play-command")
#endif
#endif
}
+
func pause() {
#if canImport(MobileVLCKit)
let state = mediaPlayer.state
#if DEBUG
logPlaybackSnapshot(reason: "before-pause canPause=\(mediaPlayer.canPause) pauseable=\(isPauseableState(state))")
#endif
guard mediaPlayer.canPause, isPauseableState(state) else {
#if DEBUG
print("[DreamioVLC] pause skipped state=\(stateName(state)) canPause=\(mediaPlayer.canPause)")
#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")
}
#endif
#endif
}
+
func togglePlayPause() {
#if canImport(MobileVLCKit)
let now = Date()
guard now.timeIntervalSince(lastToggleDate) >= minimumToggleInterval else {
#if DEBUG
print("[DreamioVLC] toggle skipped rapid-repeat elapsed=\(String(format: "%.3f", now.timeIntervalSince(lastToggleDate)))")
#endif
return
}
lastToggleDate = now
+
let state = playbackToggleState(for: mediaPlayer.state)
let action = NativePlaybackTogglePolicy.action(for: state)
#if DEBUG
logPlaybackSnapshot(reason: "toggle action=\(action) mappedState=\(state)")
#endif
switch action {
case .play:
play()
case .pause:
pause()
case .waitForTransition:
break
}
#else
isPlaying ? pause() : play()
#endif
}
+
func seek(to position: Float) {
21 unmodified lines
logAudioTracks(reason: "before-select-\(id)")
#endif
mediaPlayer.currentAudioTrackIndex = id
mediaPlayer.currentAudioPlaybackDelay = 0
#if DEBUG
logAudioTracks(reason: "after-select-\(id)")
logPlaybackSnapshot(reason: "after-audio-select-\(id)")
#endif
onAudioTracksChange?()
#endif
38 unmodified lines
+
func stop() {
#if canImport(MobileVLCKit)
#if DEBUG
logPlaybackSnapshot(reason: "before-stop")
#endif
didReportReadyForCurrentMedia = false
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
98 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 true
default:
return false
}
}
+
private func playbackToggleState(for state: VLCMediaPlayerState) -> NativePlaybackToggleState {
switch state {
case .opening:
return .opening
case .buffering:
return .buffering
case .playing:
return .playing
case .paused:
return .paused
case .stopped:
return .stopped
case .ended:
return .ended
case .error:
return .error
default:
return .unknown
}
}
+
#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
+
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
155 unmodified lines
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG
logPlaybackSnapshot(reason: "state-change")
#endif
switch mediaPlayer.state {
case .buffering, .playing:
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
reportReadyIfNeeded()
onStateChange?()
case .error:
onFailure?(NativePlaybackError.playbackFailed)
case .paused, .stopped, .ended:
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.logPlaybackSnapshot(reason: "inactive-state-follow-up-250ms")
}
#endif
onStateChange?()
case .esAdded:
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
8 unmodified lines
}
}
+
private func reportReadyIfNeeded() {
guard !didReportReadyForCurrentMedia else {
#if DEBUG
print("[DreamioVLC] ready skipped already-reported state=\(stateName(mediaPlayer.state))")
#endif
return
}
didReportReadyForCurrentMedia = true
#if DEBUG
print("[DreamioVLC] ready reported state=\(stateName(mediaPlayer.state))")
#endif
onReady?()
onAudioTracksChange?()
onSubtitleTracksChange?()
}
+
private func stateName(_ state: VLCMediaPlayerState) -> String {
switch state {
case .opening:
+
+
+
+
+

Dreamio/NativePlayerViewController.swift ยท progress/menu refresh split

+
+
Dreamio/NativePlayerViewController.swift
-12+19
287 unmodified lines
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
341 unmodified lines
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
287 unmodified lines
}
backend.onStateChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshControls()
}
}
backend.onSubtitleTracksChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshControls()
}
}
backend.onAudioTracksChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshControls()
}
}
}
341 unmodified lines
private func startProgressUpdates() {
progressTimer?.invalidate()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshControls()
}
}
+
private func refreshControls() {
let audioTracks = backend.audioTracks
let subtitleTracks = backend.subtitleTracks
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
scrubber.isEnabled = backend.isSeekable
jumpBackButton.isEnabled = backend.isSeekable
jumpForwardButton.isEnabled = backend.isSeekable
updateAudioMenuIfNeeded(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 lines
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
341 unmodified lines
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
287 unmodified lines
}
backend.onStateChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshProgressControls()
}
}
backend.onSubtitleTracksChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshTrackMenus()
}
}
backend.onAudioTracksChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshTrackMenus()
}
}
}
341 unmodified lines
private func startProgressUpdates() {
progressTimer?.invalidate()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshProgressControls()
}
}
+
private func refreshControls() {
refreshProgressControls()
refreshTrackMenus()
}
+
private func refreshProgressControls() {
let isSeekable = backend.isSeekable
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
scrubber.isEnabled = isSeekable
jumpBackButton.isEnabled = isSeekable
jumpForwardButton.isEnabled = isSeekable
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 = 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

+
+
Tests/StreamResolverTests.swift
+11
23 unmodified lines
24
25
26
27
28
29
475 unmodified lines
505
506
507
508
509
510
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
+
475 unmodified lines
assertEqual(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 lines
24
25
26
27
28
29
30
475 unmodified lines
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testNativePlaybackTogglePolicy()
print("StreamResolverTests passed")
}
+
475 unmodified lines
assertEqual(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 build succeeded.
  • The focused Swift compile for the existing test sources plus NativePlaybackBackend.swift succeeded 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=0 was 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.
+
+
+ +