Dreamio turn document

Start Buffering Before Captions

Native playback now makes the media open request the explicit first startup action, while caption resolution is queued as background work and resolved in parallel.

2026-05-27 Beads issue dreamio-ccn Native playback

Summary

Confirmed the startup path and tightened it so VLC begins opening the media before subtitle loading work starts. Caption candidates are still accepted at startup and later discovery time, but resolution no longer proceeds one by one.

Changes Made

  • Extracted startup caption work into startCaptionLoadingInBackground(), called only after backend.play(request:).
  • Changed subtitle candidate resolution to run with a task group, preserving the original candidate order after parallel resolution.
  • Added a debug log that records startup captions were queued after playback was requested.

Context

The existing startup path already presented native playback without waiting for late captions: DreamioWebViewController resolves and presents the player, then forwards buffered and newly discovered subtitle candidates. Inside NativePlayerViewController, playback was requested before captions were attached. This change makes that ordering explicit and reduces caption resolution latency while media buffering is underway.

Important Implementation Details

  • startPlayback() still starts the startup timeout, requests VLC playback, then reveals controls.
  • Caption loading remains non-blocking because addSubtitleCandidates queues work in a Swift Task.
  • Parallel subtitle resolution sorts by original index before attaching, so menu ordering remains stable.

Relevant Diff Snippets

Dreamio/NativePlayerViewController.swift · playback-first caption loading

Dreamio/NativePlayerViewController.swift
-6+24
257 unmodified lines
258
259
260
261
262
263
264
265
266
267
268
269
270
38 unmodified lines
309
310
311
312
313
314
315
316
317
318
257 unmodified lines
}
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {
if let playableCandidate = await subtitleResolver.resolve(candidate) {
resolved.append(playableCandidate)
}
}
return resolved
}
private func configureBackend() {
38 unmodified lines
failureContainer.isHidden = true
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
revealControls()
}
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
257 unmodified lines
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
38 unmodified lines
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
257 unmodified lines
}
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
await withTaskGroup(of: (Int, SubtitleCandidate?).self) { group in
for (index, candidate) in candidates.enumerated() {
group.addTask { [subtitleResolver] in
(index, await subtitleResolver.resolve(candidate))
}
}
var resolvedCandidates: [(index: Int, candidate: SubtitleCandidate)] = []
for await (index, candidate) in group {
if let candidate {
resolvedCandidates.append((index, candidate))
}
}
return resolvedCandidates
.sorted { $0.index < $1.index }
.map(\.candidate)
}
}
private func configureBackend() {
38 unmodified lines
failureContainer.isHidden = true
startStartupTimer()
backend.play(request: request)
startCaptionLoadingInBackground()
revealControls()
}
private func startCaptionLoadingInBackground() {
let queuedCount = addSubtitleCandidates(request.subtitleCandidates)
#if DEBUG
print("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true")
#endif
}
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in

Diff rendered with @pierre/diffs/ssr using a single-file patch.

Expected Impact for End-Users

Streams should begin opening and buffering as soon as the native player starts. External captions can continue resolving and appearing shortly after playback starts, with less delay when multiple caption candidates need network resolution.

Validation

  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
  • Manual device playback validation remains recommended for real VLC buffering timing and external subtitle provider latency.

Issues, Limitations, and Mitigations

  • No user-facing loading copy was changed. The existing “Opening stream…” text remains scoped to media startup rather than captions.
  • Subtitle provider failures are still handled as missing captions, not media playback failures.

Follow-up Work

  • Add a dedicated UIKit test seam for startup ordering if the project introduces an iOS unit test target for NativePlayerViewController.
  • Manually verify on device with a stream that has several OpenSubtitles candidates.