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.
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 afterbackend.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
addSubtitleCandidatesqueues work in a SwiftTask. - Parallel subtitle resolution sorts by original index before attaching, so menu ordering remains stable.
Relevant Diff Snippets
Dreamio/NativePlayerViewController.swift · playback-first caption loading
257 unmodified lines25825926026126226326426526626726826927038 unmodified lines309310311312313314315316317318257 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 linesfailureContainer.isHidden = truestartStartupTimer()backend.play(request: request)addSubtitleCandidates(request.subtitleCandidates)revealControls()}private func startStartupTimer() {startupTimer?.invalidate()startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in257 unmodified lines25825926026126226326426526626726826927027127227327427527627727827928028138 unmodified lines320321322323324325326327328329330331332333334335336257 unmodified lines}private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {await withTaskGroup(of: (Int, SubtitleCandidate?).self) { group infor (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 linesfailureContainer.isHidden = truestartStartupTimer()backend.play(request: request)startCaptionLoadingInBackground()revealControls()}private func startCaptionLoadingInBackground() {let queuedCount = addSubtitleCandidates(request.subtitleCandidates)#if DEBUGprint("[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
onReadyoverlay dismissal withfinishStartupLoadingIfReady(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
11 unmodified lines1213141516272 unmodified lines28328428528628728828929029129229320 unmodified lines31531631731831932032832933033133233333433533611 unmodified linesprivate var audioMenuSignature: String?private var captionsMenuSignature: String?private var controlsMaximumWidthConstraint: NSLayoutConstraint?private let bottomScrimLayer = CAGradientLayer()var onDismiss: (() -> Void)?272 unmodified linesbackend.view.translatesAutoresizingMaskIntoConstraints = falsebackend.onReady = { [weak self] inDispatchQueue.main.async {self?.startupTimer?.invalidate()self?.loadingView.stopAnimating()self?.loadingContainer.isHidden = trueself?.startProgressUpdates()self?.refreshControls()self?.scheduleControlsHide()}}20 unmodified lines}private func startPlayback() {loadingContainer.isHidden = falseloadingView.startAnimating()failureContainer.isHidden = truerevealControls()}private func startCaptionLoadingInBackground() {let queuedCount = addSubtitleCandidates(request.subtitleCandidates)#if DEBUGprint("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true")#endif}11 unmodified lines1213141516171819272 unmodified lines29329429529629729829920 unmodified lines32132232332432532632732832933733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037111 unmodified linesprivate var audioMenuSignature: String?private var captionsMenuSignature: String?private var controlsMaximumWidthConstraint: NSLayoutConstraint?private var isBackendReady = falseprivate var isLoadingStartupCaptions = falseprivate var hasCompletedStartupCaptions = falseprivate let bottomScrimLayer = CAGradientLayer()var onDismiss: (() -> Void)?272 unmodified linesbackend.view.translatesAutoresizingMaskIntoConstraints = falsebackend.onReady = { [weak self] inDispatchQueue.main.async {self?.isBackendReady = trueself?.finishStartupLoadingIfReady(reason: "backend-ready")}}20 unmodified lines}private func startPlayback() {isBackendReady = falseisLoadingStartupCaptions = falsehasCompletedStartupCaptions = request.subtitleCandidates.isEmptyloadingContainer.isHidden = falseloadingView.startAnimating()failureContainer.isHidden = truerevealControls()}private func startCaptionLoadingInBackground() {isLoadingStartupCaptions = trueloadingTextLabel.text = "Loading subtitles…"let queuedCount = enqueueSubtitleCandidates(request.subtitleCandidates) { [weak self] inguard let self else { return }self.isLoadingStartupCaptions = falseself.hasCompletedStartupCaptions = trueself.finishStartupLoadingIfReady(reason: "startup-captions-complete")}#if DEBUGprint("[DreamioNativePlayer] startup captions queued=\(queuedCount) total=\(request.subtitleCandidates.count) playbackAlreadyRequested=true")#endif}private func finishStartupLoadingIfReady(reason: String) {guard isBackendReady, hasCompletedStartupCaptions else {#if DEBUGprint("[DreamioNativePlayer] startup loading waiting reason=\(reason) backendReady=\(isBackendReady) captionsComplete=\(hasCompletedStartupCaptions) loadingCaptions=\(isLoadingStartupCaptions)")#endifreturn}#if DEBUGprint("[DreamioNativePlayer] startup loading complete reason=\(reason)")#endifstartupTimer?.invalidate()loadingView.stopAnimating()loadingContainer.isHidden = trueloadingTextLabel.text = "Opening stream…"startProgressUpdates()refreshControls()scheduleControlsHide()}
Related issue: dreamio-e2q.