Queue VLC Subtitles Until Media Start
Fixed a startup race introduced by the real local range buffer: subtitle candidates can now arrive during the cache probe and wait safely until VLC has an active media item.
Summary
The native VLC backend now queues external subtitle candidates received before media startup completes. Once the local-cache or direct VLC media item is created and playback begins, the queued candidates are flushed through the existing subtitle attachment path.
Changes Made
- Added a pending subtitle queue and URL set to
VLCNativePlaybackBackend. - Reset the queue and startup flag for each new playback request.
- Marked media as started inside
startVLCMedia, then flushed queued subtitles. - Kept the existing attachment, dedupe, delayed refresh, display-name preservation, and auto-selection behavior intact.
Context
The logs showed subtitle attachments happening after cache-probe but before opening mode=local-cache. With the real range cache, VLC media creation is delayed by the async probe and local server setup. The native player still forwarded buffered subtitles immediately, so addPlaybackSlave was called while VLC had no active media item.
Important Implementation Details
The fix treats pre-media subtitle candidates as valid work, not duplicates. Candidates queued during startup are not inserted into attachedSubtitleURLs; they are held separately so the later flush can attach them normally once VLC is ready.
When subtitles arrive after media startup, behavior is unchanged. They still attach immediately and use the existing delayed refresh timers to wait for VLC to expose external subtitle tracks.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr from the one-file patch for Dreamio/VLCNativePlaybackBackend.swift.
27 unmodified lines28293031323320 unmodified lines545556575859295 unmodified lines35535635735835936010 unmodified lines37137237337437537636 unmodified lines41341441541641741827 unmodified linesprivate var lastLoggedState: String?private var lastBufferingLogTime: Date?private var attachedSubtitleURLs = Set<URL>()private var didAutoSelectSubtitleTrack = falseprivate var didUserSelectSubtitleTrack = falseprivate var autoSelectedSubtitleTrackID: Int32?20 unmodified lines#if canImport(MobileVLCKit)playbackStartupTask?.cancel()attachedSubtitleURLs.removeAll()didAutoSelectSubtitleTrack = falsedidUserSelectSubtitleTrack = falseautoSelectedSubtitleTrackID = nil295 unmodified linesprint("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")#endifmediaPlayer.play()}private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {10 unmodified lines}private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {var attachedCount = 0var duplicateCount = 0let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))36 unmodified linesreturn attachedCount}private func rawSubtitleTracks() -> [SubtitleTrack] {let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []27 unmodified lines28293031323334353620 unmodified lines575859606162636465295 unmodified lines36136236336436536636736810 unmodified lines37938038138238338438538638738838939039139239339436 unmodified lines43143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546627 unmodified linesprivate var lastLoggedState: String?private var lastBufferingLogTime: Date?private var attachedSubtitleURLs = Set<URL>()private var pendingSubtitleCandidates: [SubtitleCandidate] = []private var pendingSubtitleURLs = Set<URL>()private var hasStartedMedia = falseprivate var didAutoSelectSubtitleTrack = falseprivate var didUserSelectSubtitleTrack = falseprivate var autoSelectedSubtitleTrackID: Int32?20 unmodified lines#if canImport(MobileVLCKit)playbackStartupTask?.cancel()attachedSubtitleURLs.removeAll()pendingSubtitleCandidates.removeAll()pendingSubtitleURLs.removeAll()hasStartedMedia = falsedidAutoSelectSubtitleTrack = falsedidUserSelectSubtitleTrack = falseautoSelectedSubtitleTrackID = nil295 unmodified linesprint("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")#endifmediaPlayer.play()hasStartedMedia = trueflushPendingSubtitleCandidates()}private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {10 unmodified lines}private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {guard hasStartedMedia else {let queued = queuePendingSubtitleCandidates(candidates)#if DEBUGif !candidates.isEmpty {print("[DreamioVLC] subtitle candidates=\(candidates.count) queued=\(queued) reason=media-not-started")}#endifreturn queued}var attachedCount = 0var duplicateCount = 0let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))36 unmodified linesreturn attachedCount}private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {var queuedCount = 0candidates.forEach { candidate inguard !attachedSubtitleURLs.contains(candidate.url),!pendingSubtitleURLs.contains(candidate.url)else {return}pendingSubtitleURLs.insert(candidate.url)pendingSubtitleCandidates.append(candidate)queuedCount += 1}return queuedCount}private func flushPendingSubtitleCandidates() {guard !pendingSubtitleCandidates.isEmpty else {return}let candidates = pendingSubtitleCandidatespendingSubtitleCandidates.removeAll()pendingSubtitleURLs.removeAll()#if DEBUGprint("[DreamioVLC] flushing queued subtitles count=\(candidates.count)")#endif_ = attachSubtitles(candidates)}private func rawSubtitleTracks() -> [SubtitleTrack] {let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
New Changes as of May 25, 2026 at 6:55 PM EDT
Summary of changes
After the queue fix landed, device logs showed the queued subtitles flushing after VLC media startup, but VLC still exposed no subtitle tracks. The remaining problem was the subtitle URL shape: Stremio provider download URLs are extensionless and VLC accepts them without creating visible external subtitle tracks.
The subtitle resolver now downloads resolvable subtitle bodies and caches them as local files with real subtitle extensions before handing them to VLC. Stremio download URLs are still parsed as candidates, but they are no longer treated as direct VLC-ready subtitle files.
Why this change was made
The native backend debug output showed ext=none for every attached Stremio subtitle and visible=0 after attachment. Caching the subtitle payload gives VLC a local .srt, .vtt, or .ass file, which is a more reliable attachment target than a provider download endpoint.
Code diffs
34 unmodified lines35363738394041424325 unmodified lines69707172737412 unmodified lines87888990919238 unmodified lines13113213313413513613716 unmodified lines15415515615715815934 unmodified linesfinal class SubtitleResolver: SubtitleResolving {private let session: URLSessioninit(session: URLSession = .shared) {self.session = session}func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {25 unmodified linesreturn SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)}return Self.bestPlayableCandidate(from: data,responseURL: response.url,12 unmodified lines}}static func bestPlayableCandidate(from data: Data,responseURL: URL?,38 unmodified lineslet lowercased = url.absoluteString.lowercased()return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)|| isStremioSubtitleDownloadURL(url)}private static func shouldResolve(_ url: URL) -> Bool {16 unmodified lines|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil}private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {#if DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"34 unmodified lines353637383940414243444546474825 unmodified lines7475767778798081828312 unmodified lines9697989910010110210310410510610710810911011111211311411511611711811912012112212312412512638 unmodified lines16516616716816917016 unmodified lines18718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523634 unmodified linesfinal class SubtitleResolver: SubtitleResolving {private let session: URLSessionprivate let cacheDirectory: URLinit(session: URLSession = .shared,cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitles", isDirectory: true)) {self.session = sessionself.cacheDirectory = cacheDirectory}func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {25 unmodified linesreturn SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)}if let cachedCandidate = cacheSubtitleDataIfNeeded(data, original: candidate) {return cachedCandidate}return Self.bestPlayableCandidate(from: data,responseURL: response.url,12 unmodified lines}}private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {guard let subtitleType = Self.subtitleType(in: data) else {return nil}do {try FileManager.default.createDirectory(at: cacheDirectory,withIntermediateDirectories: true)let filename = "\(UUID().uuidString).\(subtitleType.fileExtension)"let fileURL = cacheDirectory.appendingPathComponent(filename)try data.write(to: fileURL, options: .atomic)#if DEBUGprint("[DreamioSubtitles] cached subtitle url=\(URLRedactor.redactedURLString(original.url.absoluteString)) file=\(fileURL.lastPathComponent)")#endifreturn SubtitleCandidate(url: fileURL, label: original.label, language: original.language)} catch {#if DEBUGprint("[DreamioSubtitles] cache failure=\(error.localizedDescription) url=\(URLRedactor.redactedURLString(original.url.absoluteString))")#endifreturn nil}}static func bestPlayableCandidate(from data: Data,responseURL: URL?,38 unmodified lineslet lowercased = url.absoluteString.lowercased()return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)}private static func shouldResolve(_ url: URL) -> Bool {16 unmodified lines|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil}private enum SubtitlePayloadType {case srtcase vttcase assvar fileExtension: String {switch self {case .srt:return "srt"case .vtt:return "vtt"case .ass:return "ass"}}}private static func subtitleType(in data: Data) -> SubtitlePayloadType? {guard !data.isEmpty,let text = String(data: data.prefix(4096), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),!text.isEmptyelse {return nil}let lowercased = text.lowercased()if lowercased.hasPrefix("webvtt") {return .vtt}if lowercased.hasPrefix("[script info]")|| lowercased.contains("\n[events]")|| lowercased.contains("\r\n[events]") {return .ass}if lowercased.range(of: #"(?m)^\d+\s*[\r\n]+(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}\s*-->\s*(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}"#,options: .regularExpression) != nil {return .srt}return nil}private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {#if DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
15 unmodified lines161718192021248 unmodified lines270271272273274275276160 unmodified lines43743843944044144244314 unmodified lines45845946046146246312 unmodified lines47647747847948048115 unmodified linestestStremioSubtitleDownloadURLParsing()testOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()248 unmodified linesassertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")assertEqual(candidates[0].label, "English")assertEqual(candidates[0].language, "eng")assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")}private static func testContentRangeParsing() {160 unmodified linesassertEqual(candidate?.language, "eng")}private static func testSubtitleResolverDownloadJSONReturningLink() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/123": (200,14 unmodified lines}private static func testSubtitleResolverRedirectToDirectSubtitle() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/redirect": (200,12 unmodified lines}private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/not-found": (200,15 unmodified lines16171819202122248 unmodified lines271272273274275276277160 unmodified lines43843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248314 unmodified lines49849950050150250350412 unmodified lines51751851952052152252315 unmodified linestestStremioSubtitleDownloadURLParsing()testOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverCachesStremioDownloadBody()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()248 unmodified linesassertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")assertEqual(candidates[0].label, "English")assertEqual(candidates[0].language, "eng")assert(!SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be resolved before VLC attachment")}private static func testContentRangeParsing() {160 unmodified linesassertEqual(candidate?.language, "eng")}private static func testSubtitleResolverCachesStremioDownloadBody() async {let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941"let subtitleBody = """100:00:01,000 --> 00:00:02,000Hello from Stremio"""MockURLProtocol.handler = nilMockURLProtocol.handlers = [sourceURL: (200,URL(string: sourceURL)!,subtitleBody.data(using: .utf8)!)]let cacheDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitleResolverTests-\(UUID().uuidString)", isDirectory: true)defer {try? FileManager.default.removeItem(at: cacheDirectory)}let resolver = SubtitleResolver(session: mockSession(), cacheDirectory: cacheDirectory)let candidate = await resolver.resolve(SubtitleCandidate(url: URL(string: sourceURL)!,label: "English",language: "eng"))assertEqual(candidate?.url.isFileURL, true)assertEqual(candidate?.url.pathExtension, "srt")assertEqual(candidate?.label, "English")assertEqual(candidate?.language, "eng")let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8)assertEqual(cachedBody, subtitleBody)}private static func testSubtitleResolverDownloadJSONReturningLink() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/123": (200,14 unmodified lines}private static func testSubtitleResolverRedirectToDirectSubtitle() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/redirect": (200,12 unmodified lines}private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/not-found": (200,
Related issues or PRs
Related Beads issue: dreamio-771.
New Changes as of May 25, 2026 at 7:00 PM EDT
Summary of changes
Device logs showed Stremio subtitle downloads returning text, but the strict resolver detector rejected every body as text-without-direct-subtitle. The resolver now accepts plausible plain subtitle text from Stremio download endpoints and caches it as a local .srt file.
Why this change was made
The previous cache step only recognized classic indexed SRT, WebVTT, and ASS signatures. The live Stremio payloads appear to be subtitle-like text without matching that narrow signature, so they never reached VLC. This update keeps strict handling for general URLs while applying a provider-specific fallback for Stremio subtitle downloads.
Code diffs
96 unmodified lines979899100101102103100 unmodified lines20420520620720820921017 unmodified lines2282292302312322332342352367 unmodified lines24424524624724824925025125225396 unmodified lines}private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {guard let subtitleType = Self.subtitleType(in: data) else {return nil}100 unmodified lines}}private static func subtitleType(in data: Data) -> SubtitlePayloadType? {guard !data.isEmpty,let text = String(data: data.prefix(4096), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),17 unmodified lines) != nil {return .srt}return nil}private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {#if DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"7 unmodified lines} else {bodyKind = "unreadable"}print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")#endifreturn nil}}protocol StreamResolving {96 unmodified lines979899100101102103100 unmodified lines20420520620720820921017 unmodified lines2282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582597 unmodified lines26726826927027127227327427527627727827928028128228328428528628796 unmodified lines}private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {guard let subtitleType = Self.subtitleType(in: data, sourceURL: original.url) else {return nil}100 unmodified lines}}private static func subtitleType(in data: Data, sourceURL: URL? = nil) -> SubtitlePayloadType? {guard !data.isEmpty,let text = String(data: data.prefix(4096), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),17 unmodified lines) != nil {return .srt}if let sourceURL,isStremioSubtitleDownloadURL(sourceURL),isPlausiblePlainSubtitleText(text) {return .srt}return nil}private static func isPlausiblePlainSubtitleText(_ text: String) -> Bool {let lowercased = text.lowercased()guard !lowercased.hasPrefix("{"),!lowercased.hasPrefix("["),!lowercased.hasPrefix("<!doctype"),!lowercased.hasPrefix("<html"),!lowercased.hasPrefix("<?xml")else {return false}return lowercased.contains("-->")|| lowercased.contains("<font")|| lowercased.contains("{\\")|| lowercased.contains("\\n")|| lowercased.split(whereSeparator: \.isNewline).count > 1}private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {#if DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"7 unmodified lines} else {bodyKind = "unreadable"}print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription) preview=\(rejectionPreview(data))")#endifreturn nil}#if DEBUGprivate static func rejectionPreview(_ data: Data) -> String {guard let text = String(data: data.prefix(180), encoding: .utf8) else {return "unreadable"}return text.replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\r", with: "\\r")}#endif}protocol StreamResolving {
16 unmodified lines171819202122453 unmodified lines47647747847948048116 unmodified linestestOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverCachesStremioDownloadBody()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()453 unmodified linesassertEqual(cachedBody, subtitleBody)}private static func testSubtitleResolverDownloadJSONReturningLink() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = [16 unmodified lines17181920212223453 unmodified lines47747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651716 unmodified linestestOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverCachesStremioDownloadBody()await testSubtitleResolverCachesPlainStremioDownloadBody()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()453 unmodified linesassertEqual(cachedBody, subtitleBody)}private static func testSubtitleResolverCachesPlainStremioDownloadBody() async {let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341942"let subtitleBody = """00:01.000 --> 00:02.000Plain cue text without an index"""MockURLProtocol.handler = nilMockURLProtocol.handlers = [sourceURL: (200,URL(string: sourceURL)!,subtitleBody.data(using: .utf8)!)]let cacheDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitleResolverTests-\(UUID().uuidString)", isDirectory: true)defer {try? FileManager.default.removeItem(at: cacheDirectory)}let resolver = SubtitleResolver(session: mockSession(), cacheDirectory: cacheDirectory)let candidate = await resolver.resolve(SubtitleCandidate(url: URL(string: sourceURL)!,label: "English",language: "eng"))assertEqual(candidate?.url.isFileURL, true)assertEqual(candidate?.url.pathExtension, "srt")let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8)assertEqual(cachedBody, subtitleBody)}private static func testSubtitleResolverDownloadJSONReturningLink() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = [
Related issues or PRs
Related Beads issue: dreamio-8oe.
New Changes as of May 25, 2026 at 7:05 PM EDT
Summary of changes
Device logs showed local cached subtitle files reaching MobileVLCKit, but addPlaybackSlave still did not expose any subtitle tracks. The VLC backend now adds queued subtitle files to the VLCMedia with :input-slave=... before playback starts, and keeps addPlaybackSlave only for subtitles that arrive after media startup.
Why this change was made
The bundled libVLC headers note that media slaves should be added before parsing or playback. The previous flow waited until after mediaPlayer.play(), which MobileVLCKit accepted without turning into visible tracks.
Code diffs
354 unmodified lines3553563573583593601 unmodified line36236336436536636736847 unmodified lines4164174184194204216 unmodified lines42842943043143243343412 unmodified lines447448449450451452453454455456457458459460461462463464354 unmodified linesmedia.addOption(":http-reconnect")addRemoteHeaders(to: media, request: request)}mediaPlayer.media = media#if DEBUG1 unmodified line#endifmediaPlayer.play()hasStartedMedia = trueflushPendingSubtitleCandidates()}private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {47 unmodified linesguard attachedCount > 0 else {return attachedCount}[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay inDispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] inself?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")6 unmodified linesself?.onSubtitleTracksChange?()}}return attachedCount}private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {12 unmodified linesreturn queuedCount}private func flushPendingSubtitleCandidates() {guard !pendingSubtitleCandidates.isEmpty else {return}let candidates = pendingSubtitleCandidatespendingSubtitleCandidates.removeAll()pendingSubtitleURLs.removeAll()#if DEBUGprint("[DreamioVLC] flushing queued subtitles count=\(candidates.count)")#endif_ = attachSubtitles(candidates)}private func rawSubtitleTracks() -> [SubtitleTrack] {354 unmodified lines3553563573583593603611 unmodified line36336436536636736836937037147 unmodified lines4194204214224234244254264274284294304314326 unmodified lines43944044144244344412 unmodified lines457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490354 unmodified linesmedia.addOption(":http-reconnect")addRemoteHeaders(to: media, request: request)}let queuedSubtitleCount = addQueuedSubtitleSlaves(to: media)mediaPlayer.media = media#if DEBUG1 unmodified line#endifmediaPlayer.play()hasStartedMedia = trueif queuedSubtitleCount > 0 {scheduleSubtitleTrackRefreshes(attachedCount: queuedSubtitleCount)}}private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {47 unmodified linesguard attachedCount > 0 else {return attachedCount}scheduleSubtitleTrackRefreshes(attachedCount: attachedCount)return attachedCount}private func scheduleSubtitleTrackRefreshes(attachedCount: Int) {guard attachedCount > 0 else {return}[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay inDispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] inself?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")6 unmodified linesself?.onSubtitleTracksChange?()}}}private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {12 unmodified linesreturn queuedCount}private func addQueuedSubtitleSlaves(to media: VLCMedia) -> Int {guard !pendingSubtitleCandidates.isEmpty else {return 0}let candidates = pendingSubtitleCandidatespendingSubtitleCandidates.removeAll()pendingSubtitleURLs.removeAll()var addedCount = 0let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))#if DEBUGprint("[DreamioVLC] flushing queued subtitles count=\(candidates.count)")#endifcandidates.forEach { candidate inguard !attachedSubtitleURLs.contains(candidate.url) else {return}attachedSubtitleURLs.insert(candidate.url)externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)hasPendingExternalSubtitleSelection = truependingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))media.addOption(":input-slave=\(candidate.url.absoluteString)")addedCount += 1#if DEBUGprint("[DreamioVLC] queued subtitle slave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")#endif}return addedCount}private func rawSubtitleTracks() -> [SubtitleTrack] {
Related issues or PRs
Related Beads issue: dreamio-3aq.
New Changes as of May 25, 2026 at 7:10 PM EDT
Summary of changes
After confirming that MobileVLCKit still exposed no tracks from local subtitle files added before playback, Dreamio now has a native subtitle overlay fallback. Resolved local subtitle files are parsed into cue tracks, auto-selected when VLC has no subtitle tracks, and rendered over the player using the backend playback time.
Why this change was made
The logs proved the full handoff into VLC was correct: local .srt files existed, were queued before playback, and were passed as media slaves. Since MobileVLCKit continued reporting an empty subtitle track list, native rendering is the practical path that gives users captions without depending on VLC external track import.
Code diffs
8 unmodified lines9101112131484 unmodified lines9910010110210310460 unmodified lines16516616716816917083 unmodified lines25425525625725825955 unmodified lines31531631731831932082 unmodified lines4034044054064074084094104114124134144154164174184194205 unmodified lines42642742842943043116 unmodified lines44844945045145245345442 unmodified lines49749849950050150227 unmodified lines5305315325335345355365375389 unmodified lines54854955055155255355455555655716 unmodified lines57457557657757857935 unmodified lines6156166178 unmodified linesprivate var progressTimer: Timer?private var isScrubbing = falseprivate var attachedSubtitleURLs: Set<URL>private var audioMenuSignature: String?private var captionsMenuSignature: String?var onDismiss: (() -> Void)?84 unmodified linesreturn label}()init(request: NativePlaybackRequest,backend: NativePlaybackBackend = VLCNativePlaybackBackend(),60 unmodified lines#endifreturn}let attachableCandidates = resolvedCandidates.filter { candidate inguard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {return false83 unmodified linesview.addSubview(tapSurfaceView)view.addSubview(loadingView)view.addSubview(failureLabel)view.addSubview(controlsContainer)view.addSubview(closeButton)closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)55 unmodified linesfailureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),closeButton.widthAnchor.constraint(equalToConstant: 36),closeButton.heightAnchor.constraint(equalToConstant: 36),closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),82 unmodified linesprivate func captionsMenu() -> UIMenu {let selectedTrackID = backend.selectedSubtitleTrackIDlet tracks = backend.subtitleTrackslet options = SubtitleOptionMapper.options(from: tracks)#if DEBUGprint("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) options=\(SubtitleDebugFormatter.trackSummary(options)) selected=\(selectedTrackID)")#endiflet trackActions = options.map { track inUIAction(title: track.name,state: track.id == selectedTrackID ? .on : .off) { [weak self] _ inguard let self else {return}#if DEBUGprint("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endif5 unmodified linesself.refreshControls()}}let delayActions = UIMenu(title: "Delay",16 unmodified lines])return UIMenu(title: "Captions", children: trackActions + [delayActions])}private func audioMenu() -> UIMenu {42 unmodified linesjumpForwardButton.isEnabled = backend.isSeekableupdateAudioMenuIfNeeded(audioTracks: audioTracks)updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {27 unmodified lineslet signature = captionsMenuSignatureValue(tracks: subtitleTracks,selectedTrackID: selectedTrackID,delay: backend.subtitleDelay)let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }captionsButton.isEnabled = hasSelectableTrackguard signature != captionsMenuSignature else {return9 unmodified linesprivate func captionsMenuSignatureValue(tracks: [SubtitleTrack],selectedTrackID: Int32,delay: TimeInterval) -> String {let trackSignature = trackMenuSignatureValue(tracks: tracks, selectedTrackID: selectedTrackID)return "\(trackSignature)#delay=\(String(format: "%.1f", delay))"}private func trackMenuSignatureValue(16 unmodified linesscheduleControlsHide()}private func hideControls() {controlsContainer.isUserInteractionEnabled = falsecloseButton.isUserInteractionEnabled = false35 unmodified lines}}}8 unmodified lines910111213141516171884 unmodified lines10310410510610710810911011111211311411511611711811912012112260 unmodified lines18318418518618718818983 unmodified lines27327427527627727827955 unmodified lines33533633733833934034134234334434582 unmodified lines4284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594605 unmodified lines46646746846947047147247347447547647747847948048148248348448516 unmodified lines50250350450550650750842 unmodified lines55155255355455555655727 unmodified lines5855865875885895905915925935949 unmodified lines60460560660760860961061161261361461561661716 unmodified lines63463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568635 unmodified lines7227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208 unmodified linesprivate var progressTimer: Timer?private var isScrubbing = falseprivate var attachedSubtitleURLs: Set<URL>private var parsedExternalSubtitleURLs: Set<URL> = []private var externalSubtitleTracks: [ExternalSubtitleTrack] = []private var selectedExternalSubtitleTrackID: Int?private var nextExternalSubtitleTrackID = 1private var audioMenuSignature: String?private var captionsMenuSignature: String?var onDismiss: (() -> Void)?84 unmodified linesreturn label}()private let subtitleOverlayLabel: UILabel = {let label = UILabel()label.translatesAutoresizingMaskIntoConstraints = falselabel.textColor = .whitelabel.textAlignment = .centerlabel.numberOfLines = 3label.font = .systemFont(ofSize: 20, weight: .semibold)label.backgroundColor = UIColor.black.withAlphaComponent(0.48)label.layer.cornerRadius = 6label.clipsToBounds = truelabel.isHidden = truereturn label}()init(request: NativePlaybackRequest,backend: NativePlaybackBackend = VLCNativePlaybackBackend(),60 unmodified lines#endifreturn}self.ingestExternalSubtitleTracks(resolvedCandidates)let attachableCandidates = resolvedCandidates.filter { candidate inguard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {return false83 unmodified linesview.addSubview(tapSurfaceView)view.addSubview(loadingView)view.addSubview(failureLabel)view.addSubview(subtitleOverlayLabel)view.addSubview(controlsContainer)view.addSubview(closeButton)closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)55 unmodified linesfailureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),subtitleOverlayLabel.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),subtitleOverlayLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),subtitleOverlayLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),subtitleOverlayLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -92),closeButton.widthAnchor.constraint(equalToConstant: 36),closeButton.heightAnchor.constraint(equalToConstant: 36),closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),82 unmodified linesprivate func captionsMenu() -> UIMenu {let selectedTrackID = backend.selectedSubtitleTrackIDlet tracks = backend.subtitleTrackslet backendOptions = tracks.filter { $0.id >= 0 }#if DEBUGprint("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) external=\(externalSubtitleTracks.map { "{id=\($0.id), name=\($0.name)}" }.joined(separator: ", ")) selected=\(selectedTrackID) externalSelected=\(selectedExternalSubtitleTrackID.map(String.init) ?? "none")")#endiflet noneAction = UIAction(title: SubtitleOptionMapper.noneTrack.name,state: selectedTrackID < 0 && selectedExternalSubtitleTrackID == nil ? .on : .off) { [weak self] _ inguard let self else {return}self.selectedExternalSubtitleTrackID = nilself.subtitleOverlayLabel.isHidden = trueself.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)self.captionsMenuSignature = nilself.refreshControls()}let backendActions = backendOptions.map { track inUIAction(title: track.name,state: selectedExternalSubtitleTrackID == nil && track.id == selectedTrackID ? .on : .off) { [weak self] _ inguard let self else {return}self.selectedExternalSubtitleTrackID = nilself.subtitleOverlayLabel.isHidden = true#if DEBUGprint("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endif5 unmodified linesself.refreshControls()}}let externalActions = externalSubtitleTracks.map { track inUIAction(title: track.name,state: track.id == selectedExternalSubtitleTrackID ? .on : .off) { [weak self] _ inguard let self else {return}self.selectedExternalSubtitleTrackID = track.idself.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)self.captionsMenuSignature = nilself.refreshControls()}}let delayActions = UIMenu(title: "Delay",16 unmodified lines])return UIMenu(title: "Captions", children: [noneAction] + backendActions + externalActions + [delayActions])}private func audioMenu() -> UIMenu {42 unmodified linesjumpForwardButton.isEnabled = backend.isSeekableupdateAudioMenuIfNeeded(audioTracks: audioTracks)updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)updateExternalSubtitleOverlay()elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {27 unmodified lineslet signature = captionsMenuSignatureValue(tracks: subtitleTracks,selectedTrackID: selectedTrackID,selectedExternalTrackID: selectedExternalSubtitleTrackID,delay: backend.subtitleDelay)let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 } || !externalSubtitleTracks.isEmptycaptionsButton.isEnabled = hasSelectableTrackguard signature != captionsMenuSignature else {return9 unmodified linesprivate func captionsMenuSignatureValue(tracks: [SubtitleTrack],selectedTrackID: Int32,selectedExternalTrackID: Int?,delay: TimeInterval) -> String {let trackSignature = trackMenuSignatureValue(tracks: tracks, selectedTrackID: selectedTrackID)let externalSignature = externalSubtitleTracks.map { "\($0.id):\($0.name):\($0.cues.count)" }.joined(separator: "|")return "\(trackSignature)#external=\(externalSignature)#externalSelected=\(selectedExternalTrackID.map(String.init) ?? "none")#delay=\(String(format: "%.1f", delay))"}private func trackMenuSignatureValue(16 unmodified linesscheduleControlsHide()}private func ingestExternalSubtitleTracks(_ candidates: [SubtitleCandidate]) {candidates.forEach { candidate inguard candidate.url.isFileURL,!parsedExternalSubtitleURLs.contains(candidate.url),let track = ExternalSubtitleTrackParser.track(from: candidate,id: nextExternalSubtitleTrackID)else {return}parsedExternalSubtitleURLs.insert(candidate.url)externalSubtitleTracks.append(track)nextExternalSubtitleTrackID += 1if selectedExternalSubtitleTrackID == nil,!backend.subtitleTracks.contains(where: { $0.id >= 0 }) {selectedExternalSubtitleTrackID = track.id}#if DEBUGprint("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")#endif}if !candidates.isEmpty {captionsMenuSignature = nil}}private func updateExternalSubtitleOverlay() {guard let selectedExternalSubtitleTrackID,backend.selectedSubtitleTrackID < 0,let track = externalSubtitleTracks.first(where: { $0.id == selectedExternalSubtitleTrackID })else {subtitleOverlayLabel.isHidden = truereturn}let adjustedTime = backend.currentTime - backend.subtitleDelayguard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else {subtitleOverlayLabel.isHidden = truereturn}subtitleOverlayLabel.text = " \(cue.text) "subtitleOverlayLabel.isHidden = false}private func hideControls() {controlsContainer.isUserInteractionEnabled = falsecloseButton.isUserInteractionEnabled = false35 unmodified lines}}}private struct ExternalSubtitleTrack {let id: Intlet name: Stringlet cues: [ExternalSubtitleCue]}private struct ExternalSubtitleCue {let start: TimeIntervallet end: TimeIntervallet text: String}private enum ExternalSubtitleTrackParser {static func track(from candidate: SubtitleCandidate, id: Int) -> ExternalSubtitleTrack? {guard let text = try? String(contentsOf: candidate.url, encoding: .utf8) else {return nil}let cues = parseCues(from: text)guard !cues.isEmpty else {return nil}return ExternalSubtitleTrack(id: id,name: SubtitleDisplayName.displayName(for: candidate),cues: cues)}private static func parseCues(from text: String) -> [ExternalSubtitleCue] {let normalized = text.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")let blocks = normalized.components(separatedBy: "\n\n")return blocks.compactMap(parseCueBlock)}private static func parseCueBlock(_ block: String) -> ExternalSubtitleCue? {let lines = block.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty && !$0.lowercased().hasPrefix("webvtt") }guard !lines.isEmpty else {return nil}guard let timingIndex = lines.firstIndex(where: { $0.contains("-->") }) else {return nil}let timingParts = lines[timingIndex].components(separatedBy: "-->")guard timingParts.count == 2,let start = parseTimestamp(timingParts[0]),let end = parseTimestamp(timingParts[1])else {return nil}let cueText = lines.dropFirst(timingIndex + 1).map(cleanCueText).filter { !$0.isEmpty }.joined(separator: "\n")guard !cueText.isEmpty else {return nil}return ExternalSubtitleCue(start: start, end: end, text: cueText)}private static func parseTimestamp(_ value: String) -> TimeInterval? {let timestamp = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: ".").components(separatedBy: .whitespaces).first ?? ""let pieces = timestamp.split(separator: ":").map(String.init)guard let secondsPiece = pieces.last,let seconds = Double(secondsPiece)else {return nil}let minutes = pieces.count >= 2 ? Double(pieces[pieces.count - 2]) ?? 0 : 0let hours = pieces.count >= 3 ? Double(pieces[pieces.count - 3]) ?? 0 : 0return hours * 3600 + minutes * 60 + seconds}private static func cleanCueText(_ value: String) -> String {value.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression).replacingOccurrences(of: #"\{\\[^}]+\}"#, with: "", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)}}
Related issues or PRs
Related Beads issue: dreamio-7wi.
Expected Impact for End-Users
When an MKV stream opens through the real local range buffer, subtitles discovered before playback should still appear in the captions menu and be eligible for auto-selection once VLC exposes the track list. Stremio subtitle downloads should now reach VLC as local subtitle files rather than extensionless provider URLs, including plain cue-text payloads that do not match classic indexed SRT. Queued subtitles are now registered with VLC before playback starts, and Dreamio can render parsed external subtitles natively when VLC still exposes no text tracks.
Validation
- Ran
git diff --check: passed. - Ran
pod installto restore missing local CocoaPods support files in this worktree. - Ran
xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' -quiet build: passed after adding the native subtitle overlay fallback. - Ran
swiftc -parse-as-library -D DEBUG Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests: passed, including the Stremio subtitle cache tests for strict SRT and plain cue text. - The build still reports existing MobileVLCKit warnings about simulator deployment target and a run-script phase, but compilation succeeded.
Issues, Limitations, and Mitigations
This addresses the pre-media attachment race shown in the logs and the follow-on issue where extensionless Stremio subtitle download URLs were accepted by VLC without visible tracks. If a provider returns a compressed archive, a non-UTF-8 payload, HTML/XML, JSON without a direct subtitle link, or a format VLC cannot parse after local caching, that would still need a separate resolver enhancement. Dreamio now includes a subtitle rendering path outside VLC track import. The fallback parser handles SRT/WebVTT-style cue blocks and simple HTML/ASS cleanup, but more advanced ASS styling is intentionally flattened to plain text.
Follow-up Work
- Re-test on device with the South Park stream and confirm the log order changes to queued subtitles followed by
flushing queued subtitlesafteropening mode=local-cache. - Re-test on device and look for
[DreamioSubtitles] cached subtitlefollowed by VLC attachment logs withext=srt,ext=vtt, orext=ass. If rejection still occurs, inspect the newpreview=field in the rejection log. Also verify the next log shows[DreamioCaptions] parsed external subtitleentries and that the captions menu is enabled even while VLC track logs remain empty. - If tracks still do not appear after local caching, capture whether VLC receives file URLs and whether MobileVLCKit reports any subtitle import errors.