Dreamio turn record

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.

Issue dreamio-qyh May 25, 2026 Native playback

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

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.

Dreamio/VLCNativePlaybackBackend.swift
+48
27 unmodified lines
28
29
30
31
32
33
20 unmodified lines
54
55
56
57
58
59
295 unmodified lines
355
356
357
358
359
360
10 unmodified lines
371
372
373
374
375
376
36 unmodified lines
413
414
415
416
417
418
27 unmodified lines
private var lastLoggedState: String?
private var lastBufferingLogTime: Date?
private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
20 unmodified lines
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
295 unmodified lines
print("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")
#endif
mediaPlayer.play()
}
private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {
10 unmodified lines
}
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
36 unmodified lines
return attachedCount
}
private func rawSubtitleTracks() -> [SubtitleTrack] {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
27 unmodified lines
28
29
30
31
32
33
34
35
36
20 unmodified lines
57
58
59
60
61
62
63
64
65
295 unmodified lines
361
362
363
364
365
366
367
368
10 unmodified lines
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
36 unmodified lines
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
27 unmodified lines
private var lastLoggedState: String?
private var lastBufferingLogTime: Date?
private var attachedSubtitleURLs = Set<URL>()
private var pendingSubtitleCandidates: [SubtitleCandidate] = []
private var pendingSubtitleURLs = Set<URL>()
private var hasStartedMedia = false
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
20 unmodified lines
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
attachedSubtitleURLs.removeAll()
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
hasStartedMedia = false
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
295 unmodified lines
print("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")
#endif
mediaPlayer.play()
hasStartedMedia = true
flushPendingSubtitleCandidates()
}
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 DEBUG
if !candidates.isEmpty {
print("[DreamioVLC] subtitle candidates=\(candidates.count) queued=\(queued) reason=media-not-started")
}
#endif
return queued
}
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
36 unmodified lines
return attachedCount
}
private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
var queuedCount = 0
candidates.forEach { candidate in
guard !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 = pendingSubtitleCandidates
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
#if DEBUG
print("[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

Dreamio/StreamResolver.swift
-2+79
34 unmodified lines
35
36
37
38
39
40
41
42
43
25 unmodified lines
69
70
71
72
73
74
12 unmodified lines
87
88
89
90
91
92
38 unmodified lines
131
132
133
134
135
136
137
16 unmodified lines
154
155
156
157
158
159
34 unmodified lines
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
25 unmodified lines
return 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 lines
let 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 DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
34 unmodified lines
35
36
37
38
39
40
41
42
43
44
45
46
47
48
25 unmodified lines
74
75
76
77
78
79
80
81
82
83
12 unmodified lines
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
38 unmodified lines
165
166
167
168
169
170
16 unmodified lines
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
34 unmodified lines
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
private let cacheDirectory: URL
init(
session: URLSession = .shared,
cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitles", isDirectory: true)
) {
self.session = session
self.cacheDirectory = cacheDirectory
}
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
25 unmodified lines
return 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 DEBUG
print("[DreamioSubtitles] cached subtitle url=\(URLRedactor.redactedURLString(original.url.absoluteString)) file=\(fileURL.lastPathComponent)")
#endif
return SubtitleCandidate(url: fileURL, label: original.label, language: original.language)
} catch {
#if DEBUG
print("[DreamioSubtitles] cache failure=\(error.localizedDescription) url=\(URLRedactor.redactedURLString(original.url.absoluteString))")
#endif
return nil
}
}
static func bestPlayableCandidate(
from data: Data,
responseURL: URL?,
38 unmodified lines
let 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 srt
case vtt
case ass
var 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.isEmpty
else {
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 DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
Tests/StreamResolverTests.swift
-1+43
15 unmodified lines
16
17
18
19
20
21
248 unmodified lines
270
271
272
273
274
275
276
160 unmodified lines
437
438
439
440
441
442
443
14 unmodified lines
458
459
460
461
462
463
12 unmodified lines
476
477
478
479
480
481
15 unmodified lines
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
248 unmodified lines
assertEqual(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 lines
assertEqual(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 lines
16
17
18
19
20
21
22
248 unmodified lines
271
272
273
274
275
276
277
160 unmodified lines
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
14 unmodified lines
498
499
500
501
502
503
504
12 unmodified lines
517
518
519
520
521
522
523
15 unmodified lines
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
248 unmodified lines
assertEqual(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 lines
assertEqual(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 = """
1
00:00:01,000 --> 00:00:02,000
Hello from Stremio
"""
MockURLProtocol.handler = nil
MockURLProtocol.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 = nil
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
14 unmodified lines
}
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
12 unmodified lines
}
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handler = nil
MockURLProtocol.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

Dreamio/StreamResolver.swift
-3+37
96 unmodified lines
97
98
99
100
101
102
103
100 unmodified lines
204
205
206
207
208
209
210
17 unmodified lines
228
229
230
231
232
233
234
235
236
7 unmodified lines
244
245
246
247
248
249
250
251
252
253
96 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 DEBUG
let 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)")
#endif
return nil
}
}
protocol StreamResolving {
96 unmodified lines
97
98
99
100
101
102
103
100 unmodified lines
204
205
206
207
208
209
210
17 unmodified lines
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
7 unmodified lines
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
96 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 DEBUG
let 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))")
#endif
return nil
}
#if DEBUG
private 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 {
Tests/StreamResolverTests.swift
+36
16 unmodified lines
17
18
19
20
21
22
453 unmodified lines
476
477
478
479
480
481
16 unmodified lines
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
453 unmodified lines
assertEqual(cachedBody, subtitleBody)
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
16 unmodified lines
17
18
19
20
21
22
23
453 unmodified lines
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
16 unmodified lines
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverCachesPlainStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
453 unmodified lines
assertEqual(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.000
Plain cue text without an index
"""
MockURLProtocol.handler = nil
MockURLProtocol.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 = nil
MockURLProtocol.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

Dreamio/VLCNativePlaybackBackend.swift
-5+31
354 unmodified lines
355
356
357
358
359
360
1 unmodified line
362
363
364
365
366
367
368
47 unmodified lines
416
417
418
419
420
421
6 unmodified lines
428
429
430
431
432
433
434
12 unmodified lines
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
354 unmodified lines
media.addOption(":http-reconnect")
addRemoteHeaders(to: media, request: request)
}
mediaPlayer.media = media
#if DEBUG
1 unmodified line
#endif
mediaPlayer.play()
hasStartedMedia = true
flushPendingSubtitleCandidates()
}
private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {
47 unmodified lines
guard attachedCount > 0 else {
return attachedCount
}
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
6 unmodified lines
self?.onSubtitleTracksChange?()
}
}
return attachedCount
}
private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
12 unmodified lines
return queuedCount
}
private func flushPendingSubtitleCandidates() {
guard !pendingSubtitleCandidates.isEmpty else {
return
}
let candidates = pendingSubtitleCandidates
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
#if DEBUG
print("[DreamioVLC] flushing queued subtitles count=\(candidates.count)")
#endif
_ = attachSubtitles(candidates)
}
private func rawSubtitleTracks() -> [SubtitleTrack] {
354 unmodified lines
355
356
357
358
359
360
361
1 unmodified line
363
364
365
366
367
368
369
370
371
47 unmodified lines
419
420
421
422
423
424
425
426
427
428
429
430
431
432
6 unmodified lines
439
440
441
442
443
444
12 unmodified lines
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
354 unmodified lines
media.addOption(":http-reconnect")
addRemoteHeaders(to: media, request: request)
}
let queuedSubtitleCount = addQueuedSubtitleSlaves(to: media)
mediaPlayer.media = media
#if DEBUG
1 unmodified line
#endif
mediaPlayer.play()
hasStartedMedia = true
if queuedSubtitleCount > 0 {
scheduleSubtitleTrackRefreshes(attachedCount: queuedSubtitleCount)
}
}
private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {
47 unmodified lines
guard 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 in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
6 unmodified lines
self?.onSubtitleTracksChange?()
}
}
}
private func queuePendingSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
12 unmodified lines
return queuedCount
}
private func addQueuedSubtitleSlaves(to media: VLCMedia) -> Int {
guard !pendingSubtitleCandidates.isEmpty else {
return 0
}
let candidates = pendingSubtitleCandidates
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
var addedCount = 0
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
#if DEBUG
print("[DreamioVLC] flushing queued subtitles count=\(candidates.count)")
#endif
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
return
}
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))
media.addOption(":input-slave=\(candidate.url.absoluteString)")
addedCount += 1
#if DEBUG
print("[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

Dreamio/NativePlayerViewController.swift
-7+210
8 unmodified lines
9
10
11
12
13
14
84 unmodified lines
99
100
101
102
103
104
60 unmodified lines
165
166
167
168
169
170
83 unmodified lines
254
255
256
257
258
259
55 unmodified lines
315
316
317
318
319
320
82 unmodified lines
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
5 unmodified lines
426
427
428
429
430
431
16 unmodified lines
448
449
450
451
452
453
454
42 unmodified lines
497
498
499
500
501
502
27 unmodified lines
530
531
532
533
534
535
536
537
538
9 unmodified lines
548
549
550
551
552
553
554
555
556
557
16 unmodified lines
574
575
576
577
578
579
35 unmodified lines
615
616
617
8 unmodified lines
private var progressTimer: Timer?
private var isScrubbing = false
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)?
84 unmodified lines
return label
}()
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
60 unmodified lines
#endif
return
}
let attachableCandidates = resolvedCandidates.filter { candidate in
guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {
return false
83 unmodified lines
view.addSubview(tapSurfaceView)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
view.addSubview(closeButton)
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
55 unmodified lines
failureLabel.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 lines
private func captionsMenu() -> UIMenu {
let selectedTrackID = backend.selectedSubtitleTrackID
let tracks = backend.subtitleTracks
let options = SubtitleOptionMapper.options(from: tracks)
#if DEBUG
print("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) options=\(SubtitleDebugFormatter.trackSummary(options)) selected=\(selectedTrackID)")
#endif
let trackActions = options.map { track in
UIAction(
title: track.name,
state: track.id == selectedTrackID ? .on : .off
) { [weak self] _ in
guard let self else {
return
}
#if DEBUG
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
#endif
5 unmodified lines
self.refreshControls()
}
}
let delayActions = UIMenu(
title: "Delay",
16 unmodified lines
]
)
return UIMenu(title: "Captions", children: trackActions + [delayActions])
}
private func audioMenu() -> UIMenu {
42 unmodified lines
jumpForwardButton.isEnabled = backend.isSeekable
updateAudioMenuIfNeeded(audioTracks: audioTracks)
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
27 unmodified lines
let signature = captionsMenuSignatureValue(
tracks: subtitleTracks,
selectedTrackID: selectedTrackID,
delay: backend.subtitleDelay
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
captionsButton.isEnabled = hasSelectableTrack
guard signature != captionsMenuSignature else {
return
9 unmodified lines
private 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 lines
scheduleControlsHide()
}
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
35 unmodified lines
}
}
}
8 unmodified lines
9
10
11
12
13
14
15
16
17
18
84 unmodified lines
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
60 unmodified lines
183
184
185
186
187
188
189
83 unmodified lines
273
274
275
276
277
278
279
55 unmodified lines
335
336
337
338
339
340
341
342
343
344
345
82 unmodified lines
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
5 unmodified lines
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
16 unmodified lines
502
503
504
505
506
507
508
42 unmodified lines
551
552
553
554
555
556
557
27 unmodified lines
585
586
587
588
589
590
591
592
593
594
9 unmodified lines
604
605
606
607
608
609
610
611
612
613
614
615
616
617
16 unmodified lines
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
35 unmodified lines
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
8 unmodified lines
private var progressTimer: Timer?
private var isScrubbing = false
private var attachedSubtitleURLs: Set<URL>
private var parsedExternalSubtitleURLs: Set<URL> = []
private var externalSubtitleTracks: [ExternalSubtitleTrack] = []
private var selectedExternalSubtitleTrackID: Int?
private var nextExternalSubtitleTrackID = 1
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)?
84 unmodified lines
return label
}()
private let subtitleOverlayLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 3
label.font = .systemFont(ofSize: 20, weight: .semibold)
label.backgroundColor = UIColor.black.withAlphaComponent(0.48)
label.layer.cornerRadius = 6
label.clipsToBounds = true
label.isHidden = true
return label
}()
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
60 unmodified lines
#endif
return
}
self.ingestExternalSubtitleTracks(resolvedCandidates)
let attachableCandidates = resolvedCandidates.filter { candidate in
guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {
return false
83 unmodified lines
view.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 lines
failureLabel.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 lines
private func captionsMenu() -> UIMenu {
let selectedTrackID = backend.selectedSubtitleTrackID
let tracks = backend.subtitleTracks
let backendOptions = tracks.filter { $0.id >= 0 }
#if DEBUG
print("[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")")
#endif
let noneAction = UIAction(
title: SubtitleOptionMapper.noneTrack.name,
state: selectedTrackID < 0 && selectedExternalSubtitleTrackID == nil ? .on : .off
) { [weak self] _ in
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = nil
self.subtitleOverlayLabel.isHidden = true
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
self.captionsMenuSignature = nil
self.refreshControls()
}
let backendActions = backendOptions.map { track in
UIAction(
title: track.name,
state: selectedExternalSubtitleTrackID == nil && track.id == selectedTrackID ? .on : .off
) { [weak self] _ in
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = nil
self.subtitleOverlayLabel.isHidden = true
#if DEBUG
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
#endif
5 unmodified lines
self.refreshControls()
}
}
let externalActions = externalSubtitleTracks.map { track in
UIAction(
title: track.name,
state: track.id == selectedExternalSubtitleTrackID ? .on : .off
) { [weak self] _ in
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = track.id
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
self.captionsMenuSignature = nil
self.refreshControls()
}
}
let delayActions = UIMenu(
title: "Delay",
16 unmodified lines
]
)
return UIMenu(title: "Captions", children: [noneAction] + backendActions + externalActions + [delayActions])
}
private func audioMenu() -> UIMenu {
42 unmodified lines
jumpForwardButton.isEnabled = backend.isSeekable
updateAudioMenuIfNeeded(audioTracks: audioTracks)
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
updateExternalSubtitleOverlay()
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
27 unmodified lines
let signature = captionsMenuSignatureValue(
tracks: subtitleTracks,
selectedTrackID: selectedTrackID,
selectedExternalTrackID: selectedExternalSubtitleTrackID,
delay: backend.subtitleDelay
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 } || !externalSubtitleTracks.isEmpty
captionsButton.isEnabled = hasSelectableTrack
guard signature != captionsMenuSignature else {
return
9 unmodified lines
private 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 lines
scheduleControlsHide()
}
private func ingestExternalSubtitleTracks(_ candidates: [SubtitleCandidate]) {
candidates.forEach { candidate in
guard 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 += 1
if selectedExternalSubtitleTrackID == nil,
!backend.subtitleTracks.contains(where: { $0.id >= 0 }) {
selectedExternalSubtitleTrackID = track.id
}
#if DEBUG
print("[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 = true
return
}
let adjustedTime = backend.currentTime - backend.subtitleDelay
guard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else {
subtitleOverlayLabel.isHidden = true
return
}
subtitleOverlayLabel.text = " \(cue.text) "
subtitleOverlayLabel.isHidden = false
}
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
35 unmodified lines
}
}
}
private struct ExternalSubtitleTrack {
let id: Int
let name: String
let cues: [ExternalSubtitleCue]
}
private struct ExternalSubtitleCue {
let start: TimeInterval
let end: TimeInterval
let 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 : 0
let hours = pieces.count >= 3 ? Double(pieces[pieces.count - 3]) ?? 0 : 0
return 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

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