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.

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.

  • Added explicit readiness flags for backend readiness, active startup caption loading, and startup caption completion.
  • Replaced direct onReady overlay dismissal with finishStartupLoadingIfReady(reason:).
  • Changed startup subtitle enqueueing to report completion back to the startup gate and added DEBUG wait/complete logs for the next run.

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.