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

- 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`.

Dreamio/VLCNativePlaybackBackend.swift
+48

    27 unmodified lines28293031323320 unmodified lines545556575859295 unmodified lines35535635735835936010 unmodified lines37137237337437537636 unmodified lines41341441541641741827 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 = nil295 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 lines28293031323334353620 unmodified lines575859606162636465295 unmodified lines36136236336436536636736810 unmodified lines37938038138238338438538638738838939039139239339436 unmodified lines43143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546627 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 = nil295 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 lines35363738394041424325 unmodified lines69707172737412 unmodified lines87888990919238 unmodified lines13113213313413513613716 unmodified lines15415515615715815934 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 lines353637383940414243444546474825 unmodified lines7475767778798081828312 unmodified lines9697989910010110210310410510610710810911011111211311411511611711811912012112212312412512638 unmodified lines16516616716816917016 unmodified lines18718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523634 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 lines161718192021248 unmodified lines270271272273274275276160 unmodified lines43743843944044144244314 unmodified lines45845946046146246312 unmodified lines47647747847948048115 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 lines16171819202122248 unmodified lines271272273274275276277160 unmodified lines43843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248314 unmodified lines49849950050150250350412 unmodified lines51751851952052152252315 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 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 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 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 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 lines171819202122453 unmodified lines47647747847948048116 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 lines17181920212223453 unmodified lines47747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651716 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 lines3553563573583593601 unmodified line36236336436536636736847 unmodified lines4164174184194204216 unmodified lines42842943043143243343412 unmodified lines447448449450451452453454455456457458459460461462463464354 unmodified lines            media.addOption(":http-reconnect")            addRemoteHeaders(to: media, request: request)        }
            mediaPlayer.media = media#if DEBUG1 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 lines3553563573583593603611 unmodified line36336436536636736836937037147 unmodified lines4194204214224234244254264274284294304314326 unmodified lines43944044144244344412 unmodified lines457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490354 unmodified lines            media.addOption(":http-reconnect")            addRemoteHeaders(to: media, request: request)        }        let queuedSubtitleCount = addQueuedSubtitleSlaves(to: media)
            mediaPlayer.media = media#if DEBUG1 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 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 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 false83 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)")#endif5 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 {            return9 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 = 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 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 false83 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)")#endif5 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 {            return9 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 = false35 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

- Ran `git diff --check`: passed.
- Ran `pod install` to 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 subtitles` after `opening mode=local-cache`.
- Re-test on device and look for `[DreamioSubtitles] cached subtitle` followed by VLC attachment logs with `ext=srt`, `ext=vtt`, or `ext=ass`. If rejection still occurs, inspect the new `preview=` field in the rejection log. Also verify the next log shows `[DreamioCaptions] parsed external subtitle` entries 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.