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.
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
dreamioSubtitleCandidateweb bridge message for newly discovered subtitle URLs. - Tracked the currently presented
NativePlayerViewControllerso 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
5 unmodified lines678910116 unmodified lines18192021222328 unmodified lines52535455565715 unmodified lines73747576777840 unmodified lines1191201211221231246 unmodified lines131132133134135136137138139140141280 unmodified lines4224234244254264274281 unmodified line43043143243343443519 unmodified lines455456457458459460461462206 unmodified lines6696706716726736745 unmodified linesstatic let stremioWebURL = URL(string: "https://web.stremio.com/")!static let diagnosticsMessageHandler = "dreamioDiagnostics"static let streamCandidateMessageHandler = "dreamioStreamCandidate"}private lazy var webView: WKWebView = {6 unmodified linesWeakScriptMessageHandler(delegate: self),name: Constants.streamCandidateMessageHandler)configuration.userContentController.addUserScript(Self.streamCandidateScript)#if DEBUGconfiguration.userContentController.add(28 unmodified linesprivate 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 linesif (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 DEBUGlet classification = request.classificationprint("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endifTask { [weak self] in1 unmodified line}}@MainActorprivate func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {guard VLCNativePlaybackBackend.isAvailable else {19 unmodified linessubtitleCandidates: request.subtitleCandidates)let player = NativePlayerViewController(request: resolvedRequest)player.onDismiss = { [weak self] inself?.lastNativePlaybackURL = nilself?.cleanUpStremioPlayerAfterNativeDismiss()}present(player, animated: true)206 unmodified linesreturn}#if DEBUGguard 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 linesstatic 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 linesWeakScriptMessageHandler(delegate: self),name: Constants.streamCandidateMessageHandler)configuration.userContentController.add(WeakScriptMessageHandler(delegate: self),name: Constants.subtitleCandidateMessageHandler)configuration.userContentController.addUserScript(Self.streamCandidateScript)#if DEBUGconfiguration.userContentController.add(28 unmodified linesprivate 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 linesif (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 DEBUGlet classification = request.classificationprint("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endifTask { [weak self] in1 unmodified line}}private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {guard !candidates.isEmpty else {return}guard let currentNativePlayer else {#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")#endifreturn}let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")#endif}@MainActorprivate func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {guard VLCNativePlaybackBackend.isAvailable else {19 unmodified linessubtitleCandidates: request.subtitleCandidates)let player = NativePlayerViewController(request: resolvedRequest)currentNativePlayer = playerplayer.onDismiss = { [weak self] inself?.lastNativePlaybackURL = nilself?.currentNativePlayer = nilself?.cleanUpStremioPlayerAfterNativeDismiss()}present(player, animated: true)206 unmodified linesreturn}if message.name == Constants.subtitleCandidateMessageHandler {handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))return}#if DEBUGguard message.name == Constants.diagnosticsMessageHandler,let body = message.body as? [String: Any],
Dreamio/NativePlayerViewController.swift
6 unmodified lines78910111281 unmodified lines94959697989926 unmodified lines1261271281291301316 unmodified linesprivate var controlsTimer: Timer?private var progressTimer: Timer?private var isScrubbing = falsevar onDismiss: (() -> Void)?private let loadingView: UIActivityIndicatorView = {81 unmodified linesinit(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {self.request = requestself.backend = backendsuper.init(nibName: nil, bundle: nil)modalPresentationStyle = .fullScreenmodalTransitionStyle = .crossDissolve26 unmodified linesbackend.play(request: request)}override func viewDidDisappear(_ animated: Bool) {super.viewDidDisappear(animated)startupTimer?.invalidate()6 unmodified lines7891011121381 unmodified lines959697989910010126 unmodified lines1281291301311321331341351361371381391401411421431441451461471481491501511521536 unmodified linesprivate var controlsTimer: Timer?private var progressTimer: Timer?private var isScrubbing = falseprivate var attachedSubtitleURLs: Set<URL>var onDismiss: (() -> Void)?private let loadingView: UIActivityIndicatorView = {81 unmodified linesinit(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {self.request = requestself.backend = backendself.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))super.init(nibName: nil, bundle: nil)modalPresentationStyle = .fullScreenmodalTransitionStyle = .crossDissolve26 unmodified linesbackend.play(request: request)}@discardableResultfunc addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {let newCandidates = candidates.filter { candidate inguard !attachedSubtitleURLs.contains(candidate.url) else {return false}attachedSubtitleURLs.insert(candidate.url)return true}let attachedCount = backend.addSubtitleCandidates(newCandidates)if attachedCount > 0 {refreshControls()}#if DEBUGlet duplicateCount = candidates.count - newCandidates.countprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endifreturn attachedCount}override func viewDidDisappear(_ animated: Bool) {super.viewDidDisappear(animated)startupTimer?.invalidate()
Dreamio/NativePlaybackBackend.swift
24 unmodified lines25262728293024 unmodified linesfunc jump(by seconds: TimeInterval)func selectSubtitleTrack(id: Int32)func adjustSubtitleDelay(by seconds: TimeInterval)func stop()}24 unmodified lines252627282930313224 unmodified linesfunc jump(by seconds: TimeInterval)func selectSubtitleTrack(id: Int32)func adjustSubtitleDelay(by seconds: TimeInterval)@discardableResultfunc addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Intfunc stop()}
Dreamio/VLCNativePlaybackBackend.swift
57 unmodified lines5859606162636448 unmodified lines11311411511611711875 unmodified lines19419519619719819920020120220320420520620720820921021121221321421521657 unmodified linesprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endifmediaPlayer.play()attachSubtitles(request.subtitleCandidates)#elseonFailure?(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 inguard !attachedSubtitleURLs.contains(candidate.url) else {return}attachedSubtitleURLs.insert(candidate.url)mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)#if DEBUGprint("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif}guard !candidates.isEmpty else {return}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] inself?.onSubtitleTracksChange?()}}#endif}57 unmodified lines5859606162636448 unmodified lines11311411511611711811912012112212312412512612775 unmodified lines20320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423557 unmodified linesprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endifmediaPlayer.play()addSubtitleCandidates(request.subtitleCandidates)#elseonFailure?(NativePlaybackError.backendUnavailable)#endif48 unmodified lines#endif}@discardableResultfunc addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {#if canImport(MobileVLCKit)return attachSubtitles(candidates)#elsereturn 0#endif}func stop() {#if canImport(MobileVLCKit)mediaPlayer.stop()75 unmodified lines}#if canImport(MobileVLCKit)private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {var attachedCount = 0var duplicateCount = 0candidates.forEach { candidate inguard !attachedSubtitleURLs.contains(candidate.url) else {duplicateCount += 1return}attachedSubtitleURLs.insert(candidate.url)mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)attachedCount += 1#if DEBUGprint("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif}#if DEBUGif !candidates.isEmpty {print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")}#endifguard attachedCount > 0 else {return attachedCount}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] inself?.onSubtitleTracksChange?()}return attachedCount}#endif}
Tests/StreamResolverTests.swift
8 unmodified lines9101112131495 unmodified lines1101111121131141158 unmodified linestestRedactorHandlesPercentEncodedPath()testPlaybackTimeFormatting()testSubtitleCandidateParsing()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}95 unmodified linesassertEqual(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 linestestRedactorHandlesPercentEncodedPath()testPlaybackTimeFormatting()testSubtitleCandidateParsing()testOpenSubtitlesV3CandidateParsing()testSubtitleCandidateDeduplicationPreservesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}95 unmodified linesassertEqual(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
swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-testsxcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NOdreamio-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.