# Forward Late OpenSubtitles Subtitles to Native Player

Native playback now keeps listening for subtitle discoveries after VLC opens, so OpenSubtitles V3 tracks that arrive late can be attached to the active player session without duplicating captions.

Date: 2026-05-25 Beads: dreamio-lw6 Scope: native playback subtitles

## Summary

Fixed the timing gap where subtitle candidates were only passed to native playback during the initial stream-candidate message. The web bridge now emits dedicated subtitle-candidate messages as new URLs are discovered, and the active native player can append those subtitles to VLC while playback is already running.

## Changes Made

- Added a dedicated `dreamioSubtitleCandidate` web bridge message for newly discovered subtitle URLs.
- Tracked the currently presented `NativePlayerViewController` so late subtitle candidates can be routed into the active VLC session.
- Added `addSubtitleCandidates(_:)` to the native player and playback backend protocols.
- Updated the VLC backend to append subtitles through `addPlaybackSlave(..., type: .subtitle, enforce: false)` after playback has started.
- Kept de-duplication in JavaScript, the native player, and the VLC backend.
- Added parser tests for OpenSubtitles V3-like payloads and duplicate preservation behavior.

## Context

The old bridge stored subtitle candidates in JavaScript and included the last batch only when posting a native stream candidate. OpenSubtitles V3 addon responses can arrive after the stream has already been handed to VLC, which meant the native player opened with no chance to learn about those later tracks.

## Important Implementation Details

- The bridge posts each new subtitle URL once through `postedSubtitleURLs`, while retaining the existing subtitle list for stream-candidate startup payloads.
- The native player keeps its own `Set<URL>` for late candidates, preventing duplicate menu entries before they reach the backend.
- The VLC backend still maintains `attachedSubtitleURLs`, so backend-level calls remain idempotent even if callers repeat URLs.
- DEBUG logs now report discovered, forwarded, attached, and duplicate counts with redacted URLs for individual VLC attachments.

## Relevant Diff Snippets

### Dreamio/DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-3+56

    5 unmodified lines678910116 unmodified lines18192021222328 unmodified lines52535455565715 unmodified lines73747576777840 unmodified lines1191201211221231246 unmodified lines131132133134135136137138139140141280 unmodified lines4224234244254264274281 unmodified line43043143243343443519 unmodified lines455456457458459460461462206 unmodified lines6696706716726736745 unmodified lines        static let stremioWebURL = URL(string: "https://web.stremio.com/")!        static let diagnosticsMessageHandler = "dreamioDiagnostics"        static let streamCandidateMessageHandler = "dreamioStreamCandidate"    }
        private lazy var webView: WKWebView = {6 unmodified lines            WeakScriptMessageHandler(delegate: self),            name: Constants.streamCandidateMessageHandler        )        configuration.userContentController.addUserScript(Self.streamCandidateScript)#if DEBUG        configuration.userContentController.add(28 unmodified lines    private var progressObservation: NSKeyValueObservation?    private var userAgent: String?    private var lastNativePlaybackURL: URL?    private let streamResolver: StreamResolving = StremioStreamResolver()
        private static let streamCandidateScript = WKUserScript(15 unmodified lines            /\.mp4(?:[?#]|$)/i          ];          const subtitleCandidates = [];          const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
              const looksNative = (url) => {40 unmodified lines            } catch (_) {}          };
              const addSubtitleCandidate = (entry) => {            const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);            const url = absoluteURL(rawURL);6 unmodified lines            if (subtitleCandidates.some((candidate) => candidate.url === url)) {              return;            }            subtitleCandidates.push({              url,              label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",              language: entry && (entry.lang || entry.language) || ""            });          };
              const inspectSubtitlePayload = (payload) => {280 unmodified lines
    #if DEBUG        let classification = request.classification        print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endif
            Task { [weak self] in1 unmodified line        }    }
        @MainActor    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {        guard VLCNativePlaybackBackend.isAvailable else {19 unmodified lines                subtitleCandidates: request.subtitleCandidates            )            let player = NativePlayerViewController(request: resolvedRequest)            player.onDismiss = { [weak self] in                self?.lastNativePlaybackURL = nil                self?.cleanUpStremioPlayerAfterNativeDismiss()            }            present(player, animated: true)206 unmodified lines            return        }
    #if DEBUG        guard message.name == Constants.diagnosticsMessageHandler,              let body = message.body as? [String: Any],5 unmodified lines67891011126 unmodified lines1920212223242526272828 unmodified lines5758596061626315 unmodified lines7980818283848540 unmodified lines1261271281291301311321331341351361371381391401411421431441451461471481491506 unmodified lines157158159160161162163164165166167168169280 unmodified lines4504514524534544554561 unmodified line45845946046146246346446546646746846947047147247347447547647747847948048119 unmodified lines501502503504505506507508509510206 unmodified lines7177187197207217227237247257267275 unmodified lines        static let stremioWebURL = URL(string: "https://web.stremio.com/")!        static let diagnosticsMessageHandler = "dreamioDiagnostics"        static let streamCandidateMessageHandler = "dreamioStreamCandidate"        static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate"    }
        private lazy var webView: WKWebView = {6 unmodified lines            WeakScriptMessageHandler(delegate: self),            name: Constants.streamCandidateMessageHandler        )        configuration.userContentController.add(            WeakScriptMessageHandler(delegate: self),            name: Constants.subtitleCandidateMessageHandler        )        configuration.userContentController.addUserScript(Self.streamCandidateScript)#if DEBUG        configuration.userContentController.add(28 unmodified lines    private var progressObservation: NSKeyValueObservation?    private var userAgent: String?    private var lastNativePlaybackURL: URL?    private weak var currentNativePlayer: NativePlayerViewController?    private let streamResolver: StreamResolving = StremioStreamResolver()
        private static let streamCandidateScript = WKUserScript(15 unmodified lines            /\.mp4(?:[?#]|$)/i          ];          const subtitleCandidates = [];          const postedSubtitleURLs = new Set();          const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
              const looksNative = (url) => {40 unmodified lines            } catch (_) {}          };
              const postSubtitleCandidates = (candidates) => {            const fresh = candidates.filter((candidate) => {              if (postedSubtitleURLs.has(candidate.url)) {                return false;              }              postedSubtitleURLs.add(candidate.url);              return true;            });            if (fresh.length === 0) {              return;            }            try {              window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({                pageUrl: window.location.href,                subtitles: fresh              });            } catch (_) {}          };
              const addSubtitleCandidate = (entry) => {            const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);            const url = absoluteURL(rawURL);6 unmodified lines            if (subtitleCandidates.some((candidate) => candidate.url === url)) {              return;            }            const candidate = {              url,              label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",              language: entry && (entry.lang || entry.language) || ""            };            subtitleCandidates.push(candidate);            postSubtitleCandidates([candidate]);          };
              const inspectSubtitlePayload = (payload) => {280 unmodified lines
    #if DEBUG        let classification = request.classification        print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endif
            Task { [weak self] in1 unmodified line        }    }
        private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {        guard !candidates.isEmpty else {            return        }
            guard let currentNativePlayer else {#if DEBUG            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")#endif            return        }
            let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUG        print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")#endif    }
        @MainActor    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {        guard VLCNativePlaybackBackend.isAvailable else {19 unmodified lines                subtitleCandidates: request.subtitleCandidates            )            let player = NativePlayerViewController(request: resolvedRequest)            currentNativePlayer = player            player.onDismiss = { [weak self] in                self?.lastNativePlaybackURL = nil                self?.currentNativePlayer = nil                self?.cleanUpStremioPlayerAfterNativeDismiss()            }            present(player, animated: true)206 unmodified lines            return        }
            if message.name == Constants.subtitleCandidateMessageHandler {            handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))            return        }
    #if DEBUG        guard message.name == Constants.diagnosticsMessageHandler,              let body = message.body as? [String: Any],

### Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
+22

    6 unmodified lines78910111281 unmodified lines94959697989926 unmodified lines1261271281291301316 unmodified lines    private var controlsTimer: Timer?    private var progressTimer: Timer?    private var isScrubbing = false    var onDismiss: (() -> Void)?
        private let loadingView: UIActivityIndicatorView = {81 unmodified lines    init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {        self.request = request        self.backend = backend        super.init(nibName: nil, bundle: nil)        modalPresentationStyle = .fullScreen        modalTransitionStyle = .crossDissolve26 unmodified lines        backend.play(request: request)    }
        override func viewDidDisappear(_ animated: Bool) {        super.viewDidDisappear(animated)        startupTimer?.invalidate()6 unmodified lines7891011121381 unmodified lines959697989910010126 unmodified lines1281291301311321331341351361371381391401411421431441451461471481491501511521536 unmodified lines    private var controlsTimer: Timer?    private var progressTimer: Timer?    private var isScrubbing = false    private var attachedSubtitleURLs: Set<URL>    var onDismiss: (() -> Void)?
        private let loadingView: UIActivityIndicatorView = {81 unmodified lines    init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {        self.request = request        self.backend = backend        self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))        super.init(nibName: nil, bundle: nil)        modalPresentationStyle = .fullScreen        modalTransitionStyle = .crossDissolve26 unmodified lines        backend.play(request: request)    }
        @discardableResult    func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {        let newCandidates = candidates.filter { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                return false            }            attachedSubtitleURLs.insert(candidate.url)            return true        }        let attachedCount = backend.addSubtitleCandidates(newCandidates)        if attachedCount > 0 {            refreshControls()        }#if DEBUG        let duplicateCount = candidates.count - newCandidates.count        print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endif        return attachedCount    }
        override func viewDidDisappear(_ animated: Bool) {        super.viewDidDisappear(animated)        startupTimer?.invalidate()

### Dreamio/NativePlaybackBackend.swift

Dreamio/NativePlaybackBackend.swift
+2

    24 unmodified lines25262728293024 unmodified lines    func jump(by seconds: TimeInterval)    func selectSubtitleTrack(id: Int32)    func adjustSubtitleDelay(by seconds: TimeInterval)    func stop()}
    24 unmodified lines252627282930313224 unmodified lines    func jump(by seconds: TimeInterval)    func selectSubtitleTrack(id: Int32)    func adjustSubtitleDelay(by seconds: TimeInterval)    @discardableResult    func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int    func stop()}

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-4+23

    57 unmodified lines5859606162636448 unmodified lines11311411511611711875 unmodified lines19419519619719819920020120220320420520620720820921021121221321421521657 unmodified lines        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endif        mediaPlayer.play()        attachSubtitles(request.subtitleCandidates)#else        onFailure?(NativePlaybackError.backendUnavailable)#endif48 unmodified lines#endif    }
        func stop() {#if canImport(MobileVLCKit)        mediaPlayer.stop()75 unmodified lines    }
    #if canImport(MobileVLCKit)    private func attachSubtitles(_ candidates: [SubtitleCandidate]) {        candidates.forEach { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                return            }            attachedSubtitleURLs.insert(candidate.url)            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)#if DEBUG            print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif        }        guard !candidates.isEmpty else {            return        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            self?.onSubtitleTracksChange?()        }    }#endif}57 unmodified lines5859606162636448 unmodified lines11311411511611711811912012112212312412512612775 unmodified lines20320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423557 unmodified lines        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endif        mediaPlayer.play()        addSubtitleCandidates(request.subtitleCandidates)#else        onFailure?(NativePlaybackError.backendUnavailable)#endif48 unmodified lines#endif    }
        @discardableResult    func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {#if canImport(MobileVLCKit)        return attachSubtitles(candidates)#else        return 0#endif    }
        func stop() {#if canImport(MobileVLCKit)        mediaPlayer.stop()75 unmodified lines    }
    #if canImport(MobileVLCKit)    private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {        var attachedCount = 0        var duplicateCount = 0        candidates.forEach { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                duplicateCount += 1                return            }            attachedSubtitleURLs.insert(candidate.url)            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)            attachedCount += 1#if DEBUG            print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif        }#if DEBUG        if !candidates.isEmpty {            print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")        }#endif        guard attachedCount > 0 else {            return attachedCount        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            self?.onSubtitleTracksChange?()        }        return attachedCount    }#endif}

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+61

    8 unmodified lines9101112131495 unmodified lines1101111121131141158 unmodified lines        testRedactorHandlesPercentEncodedPath()        testPlaybackTimeFormatting()        testSubtitleCandidateParsing()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }95 unmodified lines        assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")    }
        private static func testSubtitleOptionMappingIncludesNone() {        let options = SubtitleOptionMapper.options(from: [            SubtitleTrack(id: 2, name: "English"),8 unmodified lines91011121314151695 unmodified lines1121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751768 unmodified lines        testRedactorHandlesPercentEncodedPath()        testPlaybackTimeFormatting()        testSubtitleCandidateParsing()        testOpenSubtitlesV3CandidateParsing()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }95 unmodified lines        assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")    }
        private static func testOpenSubtitlesV3CandidateParsing() {        let payload: [String: Any] = [            "subtitles": [                [                    "language": "English",                    "download": "https://api.opensubtitles.com/api/v1/download/subtitle-file",                    "nested": [                        [                            "file": "https://dl.opensubtitles.org/en/subtitle.vtt?download=1"                        ]                    ]                ],                [                    "lang": "spa",                    "url": "https://opensubtitles.example.test/download/episode.srt"                ]            ],            "body": "alternate https://cdn.example.test/from-string.ass?source=opensubtitles",            "ignored": [                "https://cdn.example.test/poster.jpg",                ["file": "https://cdn.example.test/video.mkv"]            ]        ]
            let candidates = SubtitleCandidateParser.candidates(in: payload)
            assertEqual(candidates.count, 4)        assertEqual(candidates[0].label, "English")        assertEqual(candidates[0].language, "English")        assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")        assertEqual(candidates[2].label, "spa")        assertEqual(candidates[2].language, "spa")        assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")    }
        private static func testSubtitleCandidateDeduplicationPreservesLabels() {        let payload: [String: Any] = [            "subtitles": [                [                    "label": "English SDH",                    "lang": "eng",                    "url": "https://opensubtitles.example.test/download/duplicate.srt"                ],                [                    "label": "Duplicate",                    "language": "English",                    "download": "https://opensubtitles.example.test/download/duplicate.srt"                ],                "https://opensubtitles.example.test/download/duplicate.srt"            ]        ]
            let candidates = SubtitleCandidateParser.candidates(in: payload)
            assertEqual(candidates.count, 1)        assertEqual(candidates[0].label, "English SDH")        assertEqual(candidates[0].language, "eng")    }
        private static func testSubtitleOptionMappingIncludesNone() {        let options = SubtitleOptionMapper.options(from: [            SubtitleTrack(id: 2, name: "English"),

## Expected Impact for End-Users

When a Stremio stream opens in the native player, OpenSubtitles tracks that arrive after VLC starts should still appear in the captions menu. Repeated addon/network responses should not pile up duplicate entries.

## Validation

Passed: `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests`
Passed: `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`
Completed with limitation: Beads issue `dreamio-lw6` was closed locally. `bd dolt pull` reported `no remote`, and `bd dolt push` skipped because no Dolt remote is configured.

## Issues, Limitations, and Mitigations

- Manual validation with a live OpenSubtitles V3-enabled title was not performed in this environment. The code path is covered by parser tests and a simulator build.
- This keeps the assumption that discovered OpenSubtitles URLs are directly consumable by VLC. If a provider returns short-lived API URLs that require authenticated download, a cache/download layer may still be needed later.
- Caption menu refresh relies on VLC reporting track changes or the delayed refresh after subtitle attachment.
- Beads remote sync could not be performed because this workspace has no Dolt remote configured.

## Follow-up Work

- Manually test on a real OpenSubtitles V3-enabled title and verify subtitle selection displays text in VLC.
- Create a future Beads issue if live validation shows VLC cannot consume any discovered OpenSubtitles URLs directly.

## New Changes as of May 25, 2026 at 7:07 AM EDT

### Summary of changes

Hardened the injected network observers so Stremio can continue loading external subtitle files through binary or blob-style requests while Dreamio still observes text and JSON payloads for late subtitle candidates.

### Why this change was made

After the late-subtitle bridge landed, Stremio external subtitle loading could fail when the XHR observer touched `responseText` for a non-text response. The observer now ignores non-text XHR payloads and catches inspection errors inside the load callback.

### Code diffs

### Dreamio/DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-2+19

    193 unmodified lines1941951961971981992002 unmodified lines203204205206207208209193 unmodified lines            window.fetch = async (...args) => {              const response = await originalFetch(...args);              try {                response.clone().text().then(inspectSubtitlePayload).catch(() => {});              } catch (_) {}              return response;            };2 unmodified lines          const originalXHRSend = XMLHttpRequest.prototype.send;          XMLHttpRequest.prototype.send = function(...args) {            try {              this.addEventListener("load", () => inspectSubtitlePayload(this.responseText));            } catch (_) {}            return originalXHRSend.apply(this, args);          };193 unmodified lines1941951961971981992002012022032042052062072082092 unmodified lines212213214215216217218219220221222223224225226193 unmodified lines            window.fetch = async (...args) => {              const response = await originalFetch(...args);              try {                const contentType = response.headers && response.headers.get("content-type") || "";                const url = response.url || "";                subtitleURLPattern.lastIndex = 0;                const shouldInspect = !contentType                  || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)                  || subtitleURLPattern.test(url);                if (shouldInspect) {                  subtitleURLPattern.lastIndex = 0;                  response.clone().text().then(inspectSubtitlePayload).catch(() => {});                }              } catch (_) {}              return response;            };2 unmodified lines          const originalXHRSend = XMLHttpRequest.prototype.send;          XMLHttpRequest.prototype.send = function(...args) {            try {              this.addEventListener("load", () => {                try {                  const responseType = this.responseType || "";                  if (responseType && responseType !== "text") {                    return;                  }                  inspectSubtitlePayload(this.responseText);                } catch (_) {}              });            } catch (_) {}            return originalXHRSend.apply(this, args);          };

### Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-1+22

    128 unmodified lines12913013113213313413523 unmodified lines159160161162163164128 unmodified lines            if let candidate = candidate(from: dictionary) {                results.append(candidate)            }            dictionary.values.forEach { collect(from: $0, into: &results) }        case let array as [Any]:            array.forEach { collect(from: $0, into: &results) }        case let string as String:23 unmodified lines        )    }
        private static func subtitleURL(from string: String?) -> URL? {        guard let string,              let url = URL(string: string),128 unmodified lines12913013113213313413523 unmodified lines159160161162163164165166167168169170171172173174175176177178179180181182183184185128 unmodified lines            if let candidate = candidate(from: dictionary) {                results.append(candidate)            }            orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }        case let array as [Any]:            array.forEach { collect(from: $0, into: &results) }        case let string as String:23 unmodified lines        )    }
        private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {        let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]        var visitedKeys = Set<String>()        var values: [Any] = []
            preferredKeys.forEach { key in            if let value = dictionary[key] {                values.append(value)                visitedKeys.insert(key)            }        }
            dictionary.keys            .filter { !visitedKeys.contains($0) && !urlFields.contains($0) }            .sorted()            .compactMap { dictionary[$0] }            .forEach { values.append($0) }
            return values    }
        private static func subtitleURL(from string: String?) -> URL? {        guard let string,              let url = URL(string: string),

### Related issues or PRs

Beads issue: `dreamio-8cz`.

### Validation

Passed: `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests`
Passed: `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`