From add6ae37b0b7438a94d1b017edb3edf60608c42f Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Wed, 27 May 2026 00:20:05 -0400 Subject: [PATCH] Gate startup loading on subtitle readiness --- .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 65 ++++++++++++-- ...05-27-start-buffering-before-captions.html | 86 +++++++++++++++++++ 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 52c9960..bbc78dd 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-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":"in_progress","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:16:37Z","started_at":"2026-05-27T04:16:37Z","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} {"_type":"issue","id":"dreamio-816","title":"Fix local range cache playback buffering","description":"Normal VLC playback can stay in buffering after the local progressive HTTP range cache is enabled. Logs show VLC repeatedly probes header/tail MKV ranges through the loopback server while the cache foreground fetch path serializes 1 MB remote requests. Investigate and adjust the cache path so normal direct-file playback can start reliably.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:54:13Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:56:14Z","started_at":"2026-05-26T04:54:17Z","closed_at":"2026-05-26T04:56:14Z","close_reason":"Bypassed the local range cache for Matroska-family tail-index containers and added a regression test confirming MKV probes fall back to direct VLC playback without issuing cache probe requests.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index a2f6e27..53de8ba 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -12,6 +12,9 @@ final class NativePlayerViewController: UIViewController { private var audioMenuSignature: String? private var captionsMenuSignature: String? private var controlsMaximumWidthConstraint: NSLayoutConstraint? + private var isBackendReady = false + private var isLoadingStartupCaptions = false + private var hasCompletedStartupCaptions = false private let bottomScrimLayer = CAGradientLayer() var onDismiss: (() -> Void)? @@ -205,11 +208,17 @@ final class NativePlayerViewController: UIViewController { @discardableResult func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int { + enqueueSubtitleCandidates(candidates) + } + + @discardableResult + private func enqueueSubtitleCandidates(_ candidates: [SubtitleCandidate], onComplete: (() -> Void)? = nil) -> Int { let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) } guard !pendingCandidates.isEmpty else { #if DEBUG print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)") #endif + onComplete?() return 0 } @@ -221,6 +230,7 @@ final class NativePlayerViewController: UIViewController { } let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates) await MainActor.run { + defer { onComplete?() } guard !resolvedCandidates.isEmpty else { #if DEBUG print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) candidates=\(SubtitleDebugFormatter.candidateSummary(pendingCandidates))") @@ -283,12 +293,8 @@ final class NativePlayerViewController: UIViewController { backend.view.translatesAutoresizingMaskIntoConstraints = false backend.onReady = { [weak self] in DispatchQueue.main.async { - self?.startupTimer?.invalidate() - self?.loadingView.stopAnimating() - self?.loadingContainer.isHidden = true - self?.startProgressUpdates() - self?.refreshControls() - self?.scheduleControlsHide() + self?.isBackendReady = true + self?.finishStartupLoadingIfReady(reason: "backend-ready") } } backend.onFailure = { [weak self] error in @@ -315,6 +321,9 @@ final class NativePlayerViewController: UIViewController { } private func startPlayback() { + isBackendReady = false + isLoadingStartupCaptions = false + hasCompletedStartupCaptions = request.subtitleCandidates.isEmpty loadingContainer.isHidden = false loadingView.startAnimating() failureContainer.isHidden = true @@ -325,12 +334,54 @@ final class NativePlayerViewController: UIViewController { } private func startCaptionLoadingInBackground() { - let queuedCount = addSubtitleCandidates(request.subtitleCandidates) + guard !request.subtitleCandidates.isEmpty else { + hasCompletedStartupCaptions = true + finishStartupLoadingIfReady(reason: "no-startup-captions") +#if DEBUG + print("[DreamioNativePlayer] startup captions queued=0 total=0 playbackAlreadyRequested=true") +#endif + return + } + + isLoadingStartupCaptions = true + loadingTextLabel.text = "Loading subtitles…" + let queuedCount = enqueueSubtitleCandidates(request.subtitleCandidates) { [weak self] in + guard let self else { + return + } + self.isLoadingStartupCaptions = false + self.hasCompletedStartupCaptions = true + self.finishStartupLoadingIfReady(reason: "startup-captions-complete") + } + if queuedCount == 0 { + isLoadingStartupCaptions = false + hasCompletedStartupCaptions = true + finishStartupLoadingIfReady(reason: "startup-captions-duplicates") + } #if DEBUG print("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true") #endif } + private func finishStartupLoadingIfReady(reason: String) { + guard isBackendReady, hasCompletedStartupCaptions else { +#if DEBUG + print("[DreamioNativePlayer] startup loading waiting reason=\(reason) backendReady=\(isBackendReady) captionsComplete=\(hasCompletedStartupCaptions) loadingCaptions=\(isLoadingStartupCaptions)") +#endif + return + } +#if DEBUG + print("[DreamioNativePlayer] startup loading complete reason=\(reason)") +#endif + startupTimer?.invalidate() + loadingView.stopAnimating() + loadingContainer.isHidden = true + loadingTextLabel.text = "Opening stream…" + startProgressUpdates() + refreshControls() + scheduleControlsHide() + } + private func startStartupTimer() { startupTimer?.invalidate() startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in diff --git a/docs/turns/2026-05-27-start-buffering-before-captions.html b/docs/turns/2026-05-27-start-buffering-before-captions.html index f457249..a73b67b 100644 --- a/docs/turns/2026-05-27-start-buffering-before-captions.html +++ b/docs/turns/2026-05-27-start-buffering-before-captions.html @@ -134,6 +134,92 @@

Validation

Issues, Limitations, and Mitigations

Follow-up Work

+ +
+

New Changes as of 2026-05-27 00:19

+

Summary of changes: The startup loading overlay now waits for both VLC readiness and the initial subtitle batch completion before dismissing.

+

Why this change was made: The device run showed VLC reporting ready during buffering before all 20 startup subtitle candidates had resolved and attached, so the previous background-loading change did not actually hold the visible startup/buffer flow through subtitle loading.

+ +
+
+

Dreamio/NativePlayerViewController.swift · gate startup loading on captions

+
Dreamio/NativePlayerViewController.swift
-7+35
11 unmodified lines
12
13
14
15
16
272 unmodified lines
283
284
285
286
287
288
289
290
291
292
293
20 unmodified lines
315
316
317
318
319
320
328
329
330
331
332
333
334
335
336
11 unmodified lines
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
private let bottomScrimLayer = CAGradientLayer()
var onDismiss: (() -> Void)?
272 unmodified lines
backend.view.translatesAutoresizingMaskIntoConstraints = false
backend.onReady = { [weak self] in
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingContainer.isHidden = true
self?.startProgressUpdates()
self?.refreshControls()
self?.scheduleControlsHide()
}
}
20 unmodified lines
}
+
private func startPlayback() {
loadingContainer.isHidden = false
loadingView.startAnimating()
failureContainer.isHidden = true
revealControls()
}
+
private func startCaptionLoadingInBackground() {
let queuedCount = addSubtitleCandidates(request.subtitleCandidates)
#if DEBUG
print("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true")
#endif
}
11 unmodified lines
12
13
14
15
16
17
18
19
272 unmodified lines
293
294
295
296
297
298
299
20 unmodified lines
321
322
323
324
325
326
327
328
329
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
11 unmodified lines
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
private var isBackendReady = false
private var isLoadingStartupCaptions = false
private var hasCompletedStartupCaptions = false
private let bottomScrimLayer = CAGradientLayer()
var onDismiss: (() -> Void)?
272 unmodified lines
backend.view.translatesAutoresizingMaskIntoConstraints = false
backend.onReady = { [weak self] in
DispatchQueue.main.async {
self?.isBackendReady = true
self?.finishStartupLoadingIfReady(reason: "backend-ready")
}
}
20 unmodified lines
}
+
private func startPlayback() {
isBackendReady = false
isLoadingStartupCaptions = false
hasCompletedStartupCaptions = request.subtitleCandidates.isEmpty
loadingContainer.isHidden = false
loadingView.startAnimating()
failureContainer.isHidden = true
revealControls()
}
+
private func startCaptionLoadingInBackground() {
isLoadingStartupCaptions = true
loadingTextLabel.text = "Loading subtitles…"
let queuedCount = enqueueSubtitleCandidates(request.subtitleCandidates) { [weak self] in
guard let self else { return }
self.isLoadingStartupCaptions = false
self.hasCompletedStartupCaptions = true
self.finishStartupLoadingIfReady(reason: "startup-captions-complete")
}
#if DEBUG
print("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true")
#endif
}
+
private func finishStartupLoadingIfReady(reason: String) {
guard isBackendReady, hasCompletedStartupCaptions else {
#if DEBUG
print("[DreamioNativePlayer] startup loading waiting reason=\(reason) backendReady=\(isBackendReady) captionsComplete=\(hasCompletedStartupCaptions) loadingCaptions=\(isLoadingStartupCaptions)")
#endif
return
}
#if DEBUG
print("[DreamioNativePlayer] startup loading complete reason=\(reason)")
#endif
startupTimer?.invalidate()
loadingView.stopAnimating()
loadingContainer.isHidden = true
loadingTextLabel.text = "Opening stream…"
startProgressUpdates()
refreshControls()
scheduleControlsHide()
}
+
+
+
+

Related issue: dreamio-e2q.

+