diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 7ef197d..90714bb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -43,3 +43,4 @@ {"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."}} {"id":"int-3533f9f7","kind":"field_change","created_at":"2026-05-27T04:09:20.72451Z","actor":"dirtydishes","issue_id":"dreamio-ccn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented playback-first native startup and background parallel subtitle resolution."}} {"id":"int-c55d4a7b","kind":"field_change","created_at":"2026-05-27T04:20:08.84278Z","actor":"dirtydishes","issue_id":"dreamio-e2q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete."}} +{"id":"int-bfb2d962","kind":"field_change","created_at":"2026-05-27T04:30:15.810274Z","actor":"dirtydishes","issue_id":"dreamio-69r","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 02b032a..7138835 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,7 @@ {"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-69r","title":"Fix audio lag after native video resume","description":"Audio takes a moment to resume after pausing and playing native video; previous attempts did not resolve the lag.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:26:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:30:16Z","started_at":"2026-05-27T04:26:56Z","closed_at":"2026-05-27T04:30:16Z","close_reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e2q","title":"Gate native player readiness on startup subtitle loading","description":"The native VLC player reports ready as soon as VLC enters buffering, which hides the loading overlay before startup subtitle candidates finish resolving and attaching. Keep startup loading active until the initial subtitle batch has completed or no candidates are queued.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:16:35Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:20:09Z","started_at":"2026-05-27T04:16:37Z","closed_at":"2026-05-27T04:20:09Z","close_reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-5cz","title":"Make VLC range cache non-blocking at startup","description":"Native playback startup currently bypasses Dreamio's local range cache after cache probing caused VLC startup timeouts. Reintroduce cache startup only when preparation is fast and safe, otherwise fall back to direct playback immediately, with focused tests and clear logs.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T00:36:56Z","created_by":"dirtydishes","updated_at":"2026-05-27T00:43:03Z","started_at":"2026-05-27T00:37:03Z","closed_at":"2026-05-27T00:43:03Z","close_reason":"Implemented bounded non-blocking range-cache startup for VLC, with direct fallback on timeout, skipped probes, or local server failures; added focused startup policy tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3sw","title":"Fix VLC range cache fallback for tail-index MKV streams","description":"Video range caching currently refuses streams classified as tail-index containers, causing VLC playback to use direct mode and lose seek prefetch behavior. Investigate the probe logic and enable safe local range caching for these streams without breaking playback startup.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:05:20Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:10:16Z","started_at":"2026-05-26T12:05:38Z","closed_at":"2026-05-26T12:10:16Z","close_reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index d394fbd..44fc254 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -64,6 +64,17 @@ enum NativePlaybackTogglePolicy { } } +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 diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index f55a38b..dec6b91 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -1,3 +1,4 @@ +import AVFoundation import UIKit #if canImport(MobileVLCKit) @@ -73,6 +74,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { media.addOption(":http-header=\(headerValue)") } addConservativePlaybackOptions(to: media) + prepareAudioSessionForPlayback(reason: "initial-play") mediaPlayer.currentAudioPlaybackDelay = 0 mediaPlayer.media = media @@ -91,6 +93,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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 @@ -114,6 +120,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return } mediaPlayer.pause() + keepAudioSessionWarmAfterPause() #if DEBUG logPlaybackSnapshot(reason: "after-pause-command") DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in @@ -342,6 +349,28 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { ].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: diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c8dafb7..dd96a94 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -25,6 +25,7 @@ struct StreamResolverTests { testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() testNativePlaybackTogglePolicy() + testNativePlaybackAudioSessionPolicy() print("StreamResolverTests passed") } @@ -516,6 +517,17 @@ struct StreamResolverTests { 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(_ 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-27-audio-warm-resume.html b/docs/turns/2026-05-27-audio-warm-resume.html new file mode 100644 index 0000000..5a686a3 --- /dev/null +++ b/docs/turns/2026-05-27-audio-warm-resume.html @@ -0,0 +1,548 @@ + + + + + + Audio Warm Resume + + + + + + +
+
+

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.

+
+ +
+

Validation

+
    +
  • DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build succeeded.
  • +
  • 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.
  • +
+
+
+
+ +