diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index df78ee8..9698466 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -13,3 +13,4 @@ {"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}} {"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}} +{"id":"int-38a97132","kind":"field_change","created_at":"2026-05-25T10:43:21.805452Z","actor":"dirtydishes","issue_id":"dreamio-lw6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5ad5342..1d67a35 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"dreamio-lw6","title":"forward late opensubtitles subtitles to native player","description":"Native playback only receives subtitle candidates discovered before the stream candidate is posted. OpenSubtitles V3 candidates can arrive later through addon/network responses, so the active native player needs an append path for newly discovered external subtitles.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:40:28Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:43:22Z","started_at":"2026-05-25T10:40:36Z","closed_at":"2026-05-25T10:43:22Z","close_reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 301ef99..bfbe113 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -6,6 +6,7 @@ final class DreamioWebViewController: UIViewController { 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 = { @@ -18,6 +19,10 @@ final class DreamioWebViewController: UIViewController { 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( @@ -52,6 +57,7 @@ final class DreamioWebViewController: UIViewController { 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( @@ -73,6 +79,7 @@ final class DreamioWebViewController: UIViewController { /\.mp4(?:[?#]|$)/i ]; const subtitleCandidates = []; + const postedSubtitleURLs = new Set(); const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const looksNative = (url) => { @@ -119,6 +126,25 @@ final class DreamioWebViewController: UIViewController { } 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); @@ -131,11 +157,13 @@ final class DreamioWebViewController: UIViewController { if (subtitleCandidates.some((candidate) => candidate.url === url)) { return; } - subtitleCandidates.push({ + 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) => { @@ -422,7 +450,7 @@ final class DreamioWebViewController: UIViewController { #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")") + 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] in @@ -430,6 +458,24 @@ final class DreamioWebViewController: UIViewController { } } + 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 { @@ -455,8 +501,10 @@ final class DreamioWebViewController: UIViewController { 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) @@ -669,6 +717,11 @@ extension DreamioWebViewController: WKScriptMessageHandler { 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], diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 57ec708..0648eb0 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -25,6 +25,8 @@ protocol NativePlaybackBackend: AnyObject { func jump(by seconds: TimeInterval) func selectSubtitleTrack(id: Int32) func adjustSubtitleDelay(by seconds: TimeInterval) + @discardableResult + func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int func stop() } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 6c30810..1c02151 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -7,6 +7,7 @@ final class NativePlayerViewController: UIViewController { private var controlsTimer: Timer? private var progressTimer: Timer? private var isScrubbing = false + private var attachedSubtitleURLs: Set var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -94,6 +95,7 @@ final class NativePlayerViewController: UIViewController { 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 = .crossDissolve @@ -126,6 +128,26 @@ final class NativePlayerViewController: UIViewController { 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() diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index d891c6f..9ce07d9 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -58,7 +58,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") #endif mediaPlayer.play() - attachSubtitles(request.subtitleCandidates) + addSubtitleCandidates(request.subtitleCandidates) #else onFailure?(NativePlaybackError.backendUnavailable) #endif @@ -113,6 +113,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #endif } + @discardableResult + func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int { +#if canImport(MobileVLCKit) + return attachSubtitles(candidates) +#else + return 0 +#endif + } + func stop() { #if canImport(MobileVLCKit) mediaPlayer.stop() @@ -194,23 +203,33 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } #if canImport(MobileVLCKit) - private func attachSubtitles(_ candidates: [SubtitleCandidate]) { + 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 } - guard !candidates.isEmpty else { - return +#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 } diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index e70fc2b..555c147 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -9,6 +9,8 @@ struct StreamResolverTests { testRedactorHandlesPercentEncodedPath() testPlaybackTimeFormatting() testSubtitleCandidateParsing() + testOpenSubtitlesV3CandidateParsing() + testSubtitleCandidateDeduplicationPreservesLabels() testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") } @@ -110,6 +112,65 @@ struct StreamResolverTests { 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"), diff --git a/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html b/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html new file mode 100644 index 0000000..3d8adee --- /dev/null +++ b/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html @@ -0,0 +1,563 @@ + + + + + + Forward Late OpenSubtitles Subtitles to Native Player + + + +
+
+

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 lines
6
7
8
9
10
11
6 unmodified lines
18
19
20
21
22
23
28 unmodified lines
52
53
54
55
56
57
15 unmodified lines
73
74
75
76
77
78
40 unmodified lines
119
120
121
122
123
124
6 unmodified lines
131
132
133
134
135
136
137
138
139
140
141
280 unmodified lines
422
423
424
425
426
427
428
1 unmodified line
430
431
432
433
434
435
19 unmodified lines
455
456
457
458
459
460
461
462
206 unmodified lines
669
670
671
672
673
674
5 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] in
1 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 lines
6
7
8
9
10
11
12
6 unmodified lines
19
20
21
22
23
24
25
26
27
28
28 unmodified lines
57
58
59
60
61
62
63
15 unmodified lines
79
80
81
82
83
84
85
40 unmodified lines
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
6 unmodified lines
157
158
159
160
161
162
163
164
165
166
167
168
169
280 unmodified lines
450
451
452
453
454
455
456
1 unmodified line
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
19 unmodified lines
501
502
503
504
505
506
507
508
509
510
206 unmodified lines
717
718
719
720
721
722
723
724
725
726
727
5 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] in
1 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 lines
7
8
9
10
11
12
81 unmodified lines
94
95
96
97
98
99
26 unmodified lines
126
127
128
129
130
131
6 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 = .crossDissolve
26 unmodified lines
backend.play(request: request)
}
+
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
startupTimer?.invalidate()
6 unmodified lines
7
8
9
10
11
12
13
81 unmodified lines
95
96
97
98
99
100
101
26 unmodified lines
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
6 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 = .crossDissolve
26 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 lines
25
26
27
28
29
30
24 unmodified lines
func jump(by seconds: TimeInterval)
func selectSubtitleTrack(id: Int32)
func adjustSubtitleDelay(by seconds: TimeInterval)
func stop()
}
+
24 unmodified lines
25
26
27
28
29
30
31
32
24 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 lines
58
59
60
61
62
63
64
48 unmodified lines
113
114
115
116
117
118
75 unmodified lines
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
57 unmodified lines
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
attachSubtitles(request.subtitleCandidates)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
48 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 lines
58
59
60
61
62
63
64
48 unmodified lines
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
75 unmodified lines
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
57 unmodified lines
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
addSubtitleCandidates(request.subtitleCandidates)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
48 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 lines
9
10
11
12
13
14
95 unmodified lines
110
111
112
113
114
115
8 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 lines
9
10
11
12
13
14
15
16
95 unmodified lines
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
8 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.
  • +
+
+ + +
+ +