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.

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.

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. If MobileVLCKit still refuses :input-slave subtitle files on iOS, the next mitigation would be a different subtitle rendering path outside VLC track import.

Follow-up Work