From d6bcb52e8a1d68ad43b2eb6c5ace47751a0e0964 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 06:43:53 -0400 Subject: [PATCH 01/21] forward late subtitles to native player --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 59 +- Dreamio/NativePlaybackBackend.swift | 2 + Dreamio/NativePlayerViewController.swift | 22 + Dreamio/VLCNativePlaybackBackend.swift | 27 +- Tests/StreamResolverTests.swift | 61 ++ ...-forward-late-opensubtitles-subtitles.html | 563 ++++++++++++++++++ 8 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html 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.
  • +
+
+ +
Generated for repository turn documentation. Rendered diffs use @pierre/diffs/ssr.
+
+ + From dc11afc45feb77809dace930f3888282c73607ac Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 07:02:22 -0400 Subject: [PATCH 02/21] update xcode workspace ui state --- .../UserInterfaceState.xcuserstate | Bin 13271 -> 13271 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index d9f17eb4523631b8d54e314b7255fa4cc45052cd..645ae3483dff75e494d0af5617b1e6f38529cacc 100644 GIT binary patch delta 40 ucmcbfem#AI85gVJ@z(f)&DLE11%S- Date: Mon, 25 May 2026 07:07:59 -0400 Subject: [PATCH 03/21] keep stremio subtitle loads untouched --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 21 ++- Dreamio/StreamCandidate.swift | 23 ++- ...-forward-late-opensubtitles-subtitles.html | 158 +++++++++++++++++- 5 files changed, 196 insertions(+), 8 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 9698466..fc5b74e 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -14,3 +14,4 @@ {"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."}} +{"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1d67a35..05a1b0e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index bfbe113..03d20f7 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -194,7 +194,16 @@ final class DreamioWebViewController: UIViewController { window.fetch = async (...args) => { const response = await originalFetch(...args); try { - response.clone().text().then(inspectSubtitlePayload).catch(() => {}); + 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; }; @@ -203,7 +212,15 @@ final class DreamioWebViewController: UIViewController { const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(...args) { try { - this.addEventListener("load", () => inspectSubtitlePayload(this.responseText)); + this.addEventListener("load", () => { + try { + const responseType = this.responseType || ""; + if (responseType && responseType !== "text") { + return; + } + inspectSubtitlePayload(this.responseText); + } catch (_) {} + }); } catch (_) {} return originalXHRSend.apply(this, args); }; diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 3371b54..d33651c 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -129,7 +129,7 @@ enum SubtitleCandidateParser { if let candidate = candidate(from: dictionary) { results.append(candidate) } - dictionary.values.forEach { collect(from: $0, into: &results) } + 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: @@ -159,6 +159,27 @@ enum SubtitleCandidateParser { ) } + private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] { + let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"] + var visitedKeys = Set() + 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), diff --git a/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html b/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html index 3d8adee..07ff922 100644 --- a/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html +++ b/docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html @@ -220,7 +220,7 @@
Dreamio/DreamioWebViewController.swift
-2+19
193 unmodified lines
194
195
196
197
198
199
200
2 unmodified lines
203
204
205
206
207
208
209
193 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 lines
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
2 unmodified lines
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
193 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 lines
129
130
131
132
133
134
135
23 unmodified lines
159
160
161
162
163
164
128 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 lines
129
130
131
132
133
134
135
23 unmodified lines
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
128 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
+
+ +
Generated for repository turn documentation. Rendered diffs use @pierre/diffs/ssr.
From fdc4444f6a53026c6f0054f3af2ba0028a150307 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 09:51:02 -0400 Subject: [PATCH 04/21] resolve opensubtitles subtitle downloads --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/NativePlaybackBackend.swift | 4 + Dreamio/NativePlayerViewController.swift | 74 +- Dreamio/StreamCandidate.swift | 4 +- Dreamio/StreamResolver.swift | 100 +++ Dreamio/VLCNativePlaybackBackend.swift | 1 - Tests/StreamResolverTests.swift | 26 + ...olve-opensubtitles-subtitle-downloads.html | 637 ++++++++++++++++++ 9 files changed, 829 insertions(+), 19 deletions(-) create mode 100644 docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index fc5b74e..fc1d466 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -15,3 +15,4 @@ {"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."}} {"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}} +{"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 05a1b0e..76b395e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 0648eb0..55a0c06 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -30,6 +30,10 @@ protocol NativePlaybackBackend: AnyObject { func stop() } +protocol SubtitleResolving { + func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? +} + enum NativePlaybackError: LocalizedError { case backendUnavailable case startupTimedOut diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 1c02151..e7f5b90 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -3,6 +3,7 @@ import UIKit final class NativePlayerViewController: UIViewController { private let request: NativePlaybackRequest private var backend: NativePlaybackBackend + private let subtitleResolver: SubtitleResolving private var startupTimer: Timer? private var controlsTimer: Timer? private var progressTimer: Timer? @@ -92,10 +93,15 @@ final class NativePlayerViewController: UIViewController { return label }() - init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { + init( + request: NativePlaybackRequest, + backend: NativePlaybackBackend = VLCNativePlaybackBackend(), + subtitleResolver: SubtitleResolving = SubtitleResolver() + ) { self.request = request self.backend = backend - self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url)) + self.subtitleResolver = subtitleResolver + self.attachedSubtitleURLs = [] super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .crossDissolve @@ -126,26 +132,52 @@ final class NativePlayerViewController: UIViewController { configureLayout() startStartupTimer() backend.play(request: request) + addSubtitleCandidates(request.subtitleCandidates) } @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() - } + let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) } + guard !pendingCandidates.isEmpty else { #if DEBUG - let duplicateCount = candidates.count - newCandidates.count - print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)") + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)") #endif - return attachedCount + return 0 + } + + pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) } + + Task { [weak self] in + guard let self else { + return + } + let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates) + await MainActor.run { + guard !resolvedCandidates.isEmpty else { +#if DEBUG + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0") +#endif + return + } + let attachableCandidates = resolvedCandidates.filter { candidate in + guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else { + return false + } + self.attachedSubtitleURLs.insert(candidate.url) + return true + } + let attachedCount = self.backend.addSubtitleCandidates(attachableCandidates) + if attachedCount > 0 { + self.refreshControls() + } +#if DEBUG + let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)") +#endif + } + } + + return pendingCandidates.count } override func viewDidDisappear(_ animated: Bool) { @@ -157,6 +189,16 @@ final class NativePlayerViewController: UIViewController { onDismiss?() } + private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] { + var resolved: [SubtitleCandidate] = [] + for candidate in candidates { + if let playableCandidate = await subtitleResolver.resolve(candidate) { + resolved.append(playableCandidate) + } + } + return resolved + } + private func configureBackend() { backend.prepare(in: self) backend.view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index d33651c..3c9def5 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -105,8 +105,8 @@ struct StreamCandidate { enum SubtitleCandidateParser { private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] - private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] - private static let labelFields = ["label", "name", "title", "lang", "language", "id"] + private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] + private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"] static func candidates(in payload: Any?) -> [SubtitleCandidate] { var results: [SubtitleCandidate] = [] diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index 1943dea..c342cfd 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -29,6 +29,106 @@ enum StreamResolverError: LocalizedError { } } +final class SubtitleResolver: SubtitleResolving { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? { + if Self.isDirectSubtitleFile(candidate.url) { + return candidate + } + + guard Self.shouldResolve(candidate.url) else { + return nil + } + + var request = URLRequest(url: candidate.url) + request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await session.data(for: request) + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { +#if DEBUG + print("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") +#endif + return nil + } + + if let finalURL = response.url, Self.isDirectSubtitleFile(finalURL) { + return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language) + } + + return Self.bestPlayableCandidate( + from: data, + responseURL: response.url, + original: candidate + ) + } catch { +#if DEBUG + print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") +#endif + return nil + } + } + + static func bestPlayableCandidate( + from data: Data, + responseURL: URL?, + original: SubtitleCandidate + ) -> SubtitleCandidate? { + if let responseURL, isDirectSubtitleFile(responseURL) { + return SubtitleCandidate(url: responseURL, label: original.label, language: original.language) + } + + guard !data.isEmpty else { + return nil + } + + if let payload = try? JSONSerialization.jsonObject(with: data) { + return SubtitleCandidateParser.candidates(in: payload) + .first(where: { isDirectSubtitleFile($0.url) }) + .map { playable in + SubtitleCandidate( + url: playable.url, + label: original.label.isEmpty ? playable.label : original.label, + language: playable.language ?? original.language + ) + } + } + + if let text = String(data: data, encoding: .utf8) { + return SubtitleCandidateParser.candidates(in: text) + .first(where: { isDirectSubtitleFile($0.url) }) + .map { playable in + SubtitleCandidate( + url: playable.url, + label: original.label.isEmpty ? playable.label : original.label, + language: playable.language ?? original.language + ) + } + } + + return nil + } + + static func isDirectSubtitleFile(_ url: URL) -> Bool { + 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 { + let lowercased = url.absoluteString.lowercased() + return lowercased.contains("opensubtitles") + || lowercased.contains("/subtitle") + || lowercased.contains("subtitle") + } +} + protocol StreamResolving { func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream } diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 9ce07d9..fa0a1ef 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -58,7 +58,6 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") #endif mediaPlayer.play() - addSubtitleCandidates(request.subtitleCandidates) #else onFailure?(NativePlaybackError.backendUnavailable) #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 555c147..c14ddc8 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -10,6 +10,7 @@ struct StreamResolverTests { testPlaybackTimeFormatting() testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() + testOpenSubtitlesV3DownloadResponseResolution() testSubtitleCandidateDeduplicationPreservesLabels() testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") @@ -147,6 +148,31 @@ struct StreamResolverTests { assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles") } + private static func testOpenSubtitlesV3DownloadResponseResolution() { + let payload = """ + { + "link": "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret", + "file_name": "episode.srt", + "requests": 1 + } + """.data(using: .utf8)! + let original = SubtitleCandidate( + url: URL(string: "https://api.opensubtitles.com/api/v1/download")!, + label: "English", + language: "eng" + ) + + let candidate = SubtitleResolver.bestPlayableCandidate( + from: payload, + responseURL: original.url, + original: original + ) + + assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret") + assertEqual(candidate?.label, "English") + assertEqual(candidate?.language, "eng") + } + private static func testSubtitleCandidateDeduplicationPreservesLabels() { let payload: [String: Any] = [ "subtitles": [ diff --git a/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html new file mode 100644 index 0000000..2431244 --- /dev/null +++ b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html @@ -0,0 +1,637 @@ + + + + + + Resolve OpenSubtitles Subtitle Downloads + + + +
+
+

Turn document · May 25, 2026

+

Resolve OpenSubtitles subtitle downloads

+

Dreamio now resolves OpenSubtitles V3 API-style subtitle download responses into direct subtitle files before handing them to VLC, reducing the chance that Stremio reports “failed to load subtitles” after native playback opens.

+
+ Issue: dreamio-h5q + Scope: native subtitles + Validation: tests and simulator build +
+
+ +
+

Summary

+

Fixed the OpenSubtitles V3 subtitle handoff path by adding a resolver between Stremio subtitle discovery and VLC subtitle attachment. Direct subtitle files still attach immediately, while OpenSubtitles API/download URLs are fetched and inspected for playable .srt, .vtt, .ass, .ssa, or .sub links.

+
+
BeforeVLC received API-like OpenSubtitles URLs directly.
+
AfterDreamio resolves those endpoints to file URLs first.
+
ResultSubtitle tracks are more likely to attach as usable VLC subtitles.
+
+
+ +
+

Changes Made

+
    +
  • Added a SubtitleResolving protocol and a concrete SubtitleResolver.
  • +
  • Moved initial subtitle attachment out of the VLC backend and through NativePlayerViewController.addSubtitleCandidates, so initial and late subtitle discoveries use one path.
  • +
  • Added support for OpenSubtitles response fields like link and file_name.
  • +
  • Added a regression test for resolving OpenSubtitles V3 download JSON into a direct subtitle file URL.
  • +
+
+ +
+

Context

+

The previous OpenSubtitles pass forwarded late subtitle discoveries to the active native player, but it still assumed every discovered URL was directly consumable by VLC. OpenSubtitles V3 commonly returns API/download endpoints that respond with JSON containing the real subtitle file link. Passing those API URLs straight to VLC can surface as Stremio’s “failed to load subtitles” message.

+
+ +
+

Important Implementation Details

+
    +
  • SubtitleResolver.resolve returns direct subtitle file candidates unchanged.
  • +
  • OpenSubtitles-like URLs are fetched with an Accept header that allows JSON, text, and common subtitle formats.
  • +
  • JSON and text responses are parsed through the existing subtitle candidate parser, then filtered down to direct subtitle file URLs.
  • +
  • The captions menu label preserves the original Stremio/OpenSubtitles label when a download response includes a raw filename.
  • +
  • Duplicate tracking now records the originally discovered URL while resolution is pending, then records the resolved playable URL before VLC attachment.
  • +
+
+ +
+

Relevant Diff Snippets

+

Dreamio/NativePlaybackBackend.swift

Dreamio/NativePlaybackBackend.swift
+4
29 unmodified lines
30
31
32
33
34
35
29 unmodified lines
func stop()
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
29 unmodified lines
30
31
32
33
34
35
36
37
38
39
29 unmodified lines
func stop()
}
+
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
+

Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-15+57
2 unmodified lines
3
4
5
6
7
8
83 unmodified lines
92
93
94
95
96
97
98
99
100
101
24 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
151
5 unmodified lines
157
158
159
160
161
162
2 unmodified lines
final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend
private var startupTimer: Timer?
private var controlsTimer: Timer?
private var progressTimer: Timer?
83 unmodified lines
return label
}()
+
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
24 unmodified lines
configureLayout()
startStartupTimer()
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) {
5 unmodified lines
onDismiss?()
}
+
private func configureBackend() {
backend.prepare(in: self)
backend.view.translatesAutoresizingMaskIntoConstraints = false
2 unmodified lines
3
4
5
6
7
8
9
83 unmodified lines
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
24 unmodified lines
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
177
178
179
180
181
182
183
5 unmodified lines
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
2 unmodified lines
final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend
private let subtitleResolver: SubtitleResolving
private var startupTimer: Timer?
private var controlsTimer: Timer?
private var progressTimer: Timer?
83 unmodified lines
return label
}()
+
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
subtitleResolver: SubtitleResolving = SubtitleResolver()
) {
self.request = request
self.backend = backend
self.subtitleResolver = subtitleResolver
self.attachedSubtitleURLs = []
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve
24 unmodified lines
configureLayout()
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
}
+
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }
guard !pendingCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")
#endif
return 0
}
+
pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) }
+
Task { [weak self] in
guard let self else {
return
}
let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates)
await MainActor.run {
guard !resolvedCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")
#endif
return
}
let attachableCandidates = resolvedCandidates.filter { candidate in
guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {
return false
}
self.attachedSubtitleURLs.insert(candidate.url)
return true
}
let attachedCount = self.backend.addSubtitleCandidates(attachableCandidates)
if attachedCount > 0 {
self.refreshControls()
}
#if DEBUG
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
#endif
}
}
+
return pendingCandidates.count
}
+
override func viewDidDisappear(_ animated: Bool) {
5 unmodified lines
onDismiss?()
}
+
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {
if let playableCandidate = await subtitleResolver.resolve(candidate) {
resolved.append(playableCandidate)
}
}
return resolved
}
+
private func configureBackend() {
backend.prepare(in: self)
backend.view.translatesAutoresizingMaskIntoConstraints = false
+

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-2+2
104 unmodified lines
105
106
107
108
109
110
111
112
104 unmodified lines
+
enum SubtitleCandidateParser {
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
private static let labelFields = ["label", "name", "title", "lang", "language", "id"]
+
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = []
104 unmodified lines
105
106
107
108
109
110
111
112
104 unmodified lines
+
enum SubtitleCandidateParser {
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
+
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = []
+

Dreamio/StreamResolver.swift

Dreamio/StreamResolver.swift
+100
28 unmodified lines
29
30
31
32
33
34
28 unmodified lines
}
}
+
protocol StreamResolving {
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
}
28 unmodified lines
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
28 unmodified lines
}
}
+
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
+
init(session: URLSession = .shared) {
self.session = session
}
+
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
if Self.isDirectSubtitleFile(candidate.url) {
return candidate
}
+
guard Self.shouldResolve(candidate.url) else {
return nil
}
+
var request = URLRequest(url: candidate.url)
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
+
do {
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
#if DEBUG
print("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
return nil
}
+
if let finalURL = response.url, Self.isDirectSubtitleFile(finalURL) {
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
}
+
return Self.bestPlayableCandidate(
from: data,
responseURL: response.url,
original: candidate
)
} catch {
#if DEBUG
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
return nil
}
}
+
static func bestPlayableCandidate(
from data: Data,
responseURL: URL?,
original: SubtitleCandidate
) -> SubtitleCandidate? {
if let responseURL, isDirectSubtitleFile(responseURL) {
return SubtitleCandidate(url: responseURL, label: original.label, language: original.language)
}
+
guard !data.isEmpty else {
return nil
}
+
if let payload = try? JSONSerialization.jsonObject(with: data) {
return SubtitleCandidateParser.candidates(in: payload)
.first(where: { isDirectSubtitleFile($0.url) })
.map { playable in
SubtitleCandidate(
url: playable.url,
label: original.label.isEmpty ? playable.label : original.label,
language: playable.language ?? original.language
)
}
}
+
if let text = String(data: data, encoding: .utf8) {
return SubtitleCandidateParser.candidates(in: text)
.first(where: { isDirectSubtitleFile($0.url) })
.map { playable in
SubtitleCandidate(
url: playable.url,
label: original.label.isEmpty ? playable.label : original.label,
language: playable.language ?? original.language
)
}
}
+
return nil
}
+
static func isDirectSubtitleFile(_ url: URL) -> Bool {
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 {
let lowercased = url.absoluteString.lowercased()
return lowercased.contains("opensubtitles")
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
}
}
+
protocol StreamResolving {
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
}
+

Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-1
57 unmodified lines
58
59
60
61
62
63
64
57 unmodified lines
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
addSubtitleCandidates(request.subtitleCandidates)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
57 unmodified lines
58
59
60
61
62
63
57 unmodified lines
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
+

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+26
9 unmodified lines
10
11
12
13
14
15
131 unmodified lines
147
148
149
150
151
152
9 unmodified lines
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
131 unmodified lines
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
+
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
9 unmodified lines
10
11
12
13
14
15
16
131 unmodified lines
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
177
178
9 unmodified lines
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
131 unmodified lines
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
+
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
"link": "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret",
"file_name": "episode.srt",
"requests": 1
}
""".data(using: .utf8)!
let original = SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download")!,
label: "English",
language: "eng"
)
+
let candidate = SubtitleResolver.bestPlayableCandidate(
from: payload,
responseURL: original.url,
original: original
)
+
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
}
+
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
+
+ +
+

Expected Impact for End-Users

+

When a Stremio stream opens in Dreamio’s native player, OpenSubtitles V3 captions should appear more reliably because Dreamio now gives VLC the final subtitle file URL rather than an intermediate API endpoint. The visible label should stay friendly, such as “English,” instead of degrading to a filename.

+
+ +
+

Validation

+
    +
  • Ran swiftc Dreamio/StreamCandidate.swift [temporary SubtitleResolving shim] Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests: passed.
  • +
  • Ran xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO: passed.
  • +
  • The first standalone test attempt failed because NativePlaybackBackend.swift imports UIKit outside the iOS SDK compile context; the follow-up test used a temporary protocol shim for the pure resolver/parser code.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • Live OpenSubtitles credentials and a real Stremio playback session were not available in this environment, so live subtitle rendering still needs a device check.
  • +
  • If a provider returns raw subtitle text directly from an API URL without a final file URL, this change does not yet write a temporary subtitle file. The resolver is structured so that fallback can be added cleanly.
  • +
  • Network failures while resolving subtitles are logged in debug builds and skipped, so playback should continue even if a subtitle endpoint fails.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Manually test a real OpenSubtitles V3 title on device and confirm selected captions render in VLC.
  • +
  • Add a temporary-file fallback if live testing shows OpenSubtitles sometimes returns subtitle text directly instead of a file link.
  • +
  • Consider surfacing subtitle resolution failures in a debug-only diagnostics panel.
  • +
+
+
+ + \ No newline at end of file From d8ebc7c7f9e63c5269706f00e890a5c63d3b4cc0 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:07:28 -0400 Subject: [PATCH 05/21] add subtitle pipeline proof logging --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 40 +- Dreamio/NativePlayerViewController.swift | 6 +- Dreamio/StreamCandidate.swift | 27 ++ Dreamio/VLCNativePlaybackBackend.swift | 17 +- ...-05-25-prove-native-subtitle-pipeline.html | 388 ++++++++++++++++++ 7 files changed, 473 insertions(+), 7 deletions(-) create mode 100644 docs/turns/2026-05-25-prove-native-subtitle-pipeline.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index fc1d466..e392a3d 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -16,3 +16,4 @@ {"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."}} {"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}} {"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation."}} +{"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 76b395e..3675ba6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -11,6 +11,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 03d20f7..bc1ccf0 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -127,6 +127,7 @@ final class DreamioWebViewController: UIViewController { }; const postSubtitleCandidates = (candidates) => { + const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { if (postedSubtitleURLs.has(candidate.url)) { return false; @@ -135,12 +136,28 @@ final class DreamioWebViewController: UIViewController { return true; }); if (fresh.length === 0) { + try { + window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({ + pageUrl: window.location.href, + subtitles: [], + debug: { + discovered: discoveredCount, + deduped: 0, + forwarded: 0 + } + }); + } catch (_) {} return; } try { window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({ pageUrl: window.location.href, - subtitles: fresh + subtitles: fresh, + debug: { + discovered: discoveredCount, + deduped: fresh.length, + forwarded: fresh.length + } }); } catch (_) {} }; @@ -480,6 +497,9 @@ final class DreamioWebViewController: UIViewController { return } +#if DEBUG + print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))") +#endif guard let currentNativePlayer else { #if DEBUG print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player") @@ -489,7 +509,7 @@ final class DreamioWebViewController: UIViewController { let forwarded = currentNativePlayer.addSubtitleCandidates(candidates) #if DEBUG - print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)") + print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player") #endif } @@ -658,6 +678,16 @@ final class DreamioWebViewController: UIViewController { private func redactedURLString(_ value: String) -> String { URLRedactor.redactedURLString(value) } + + private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) { + let dictionary = body as? [String: Any] + let debug = dictionary?["debug"] as? [String: Any] + let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count + let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count + let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count + let pageURL = dictionary?["pageUrl"] as? String + print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") + } #endif } @@ -735,7 +765,11 @@ extension DreamioWebViewController: WKScriptMessageHandler { } if message.name == Constants.subtitleCandidateMessageHandler { - handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body)) + let candidates = SubtitleCandidateParser.candidates(in: message.body) +#if DEBUG + logSubtitleBridgeMessage(message.body, parsedCandidates: candidates) +#endif + handleSubtitleCandidates(candidates) return } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index e7f5b90..3a94caf 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -140,7 +140,7 @@ final class NativePlayerViewController: UIViewController { let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) } guard !pendingCandidates.isEmpty else { #if DEBUG - print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)") + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)") #endif return 0 } @@ -155,7 +155,7 @@ final class NativePlayerViewController: UIViewController { await MainActor.run { guard !resolvedCandidates.isEmpty else { #if DEBUG - print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0") + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) candidates=\(SubtitleDebugFormatter.candidateSummary(pendingCandidates))") #endif return } @@ -172,7 +172,7 @@ final class NativePlayerViewController: UIViewController { } #if DEBUG let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count - print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)") + print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attachable=\(attachableCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) resolvedCandidates=\(SubtitleDebugFormatter.candidateSummary(resolvedCandidates))") #endif } } diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 3c9def5..7b2f209 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -40,6 +40,33 @@ struct SubtitleTrack: Equatable { let name: String } +#if DEBUG +enum SubtitleDebugFormatter { + static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String { + guard !candidates.isEmpty else { + return "[]" + } + + return candidates.map { candidate in + let extensionLabel = candidate.url.pathExtension.isEmpty ? "none" : candidate.url.pathExtension.lowercased() + let language = candidate.language?.isEmpty == false ? candidate.language! : "unknown" + let label = candidate.label.isEmpty ? "External Subtitle" : candidate.label + return "{label=\(label), language=\(language), ext=\(extensionLabel)}" + }.joined(separator: ", ") + } + + static func trackSummary(_ tracks: [SubtitleTrack]) -> String { + guard !tracks.isEmpty else { + return "[]" + } + + return tracks.map { track in + "{id=\(track.id), name=\(track.name)}" + }.joined(separator: ", ") + } +} +#endif + enum PlaybackTimeFormatter { static func label(for seconds: TimeInterval) -> String { guard seconds.isFinite, seconds > 0 else { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index fa0a1ef..84ef193 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -214,7 +214,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) attachedCount += 1 #if DEBUG - print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") + print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())") + logSubtitleTracks(reason: "after-addPlaybackSlave") #endif } #if DEBUG @@ -226,10 +227,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return attachedCount } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + #if DEBUG + self?.logSubtitleTracks(reason: "delayed-refresh") + #endif self?.onSubtitleTracksChange?() } return attachedCount } + +#if DEBUG + private func logSubtitleTracks(reason: String) { + let names = mediaPlayer.videoSubTitlesNames as? [String] ?? [] + let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? [] + print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)") + } +#endif #endif } @@ -248,6 +260,9 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { case .paused, .stopped, .ended: onStateChange?() case .esAdded: +#if DEBUG + logSubtitleTracks(reason: "esAdded") +#endif onSubtitleTracksChange?() default: break diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html new file mode 100644 index 0000000..fd877b3 --- /dev/null +++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html @@ -0,0 +1,388 @@ + + + + + + Prove Native Subtitle Pipeline + + + +
+
+
Turn document, 2026-05-25
+

Prove Native Subtitle Pipeline

+

Added targeted DEBUG-only logging across subtitle discovery, web-to-native forwarding, native resolution, VLC subtitle attachment, and VLC track exposure. The change is diagnostic only and keeps URL output redacted.

+
+ Issue: dreamio-e9p + Scope: diagnostics only + Validation: build passed +
+
+ +
+

Summary

+

The native subtitle path now reports enough DEBUG data to tell whether subtitles disappear during web discovery, bridge forwarding, native player timing, subtitle resolution, VLC attachment, or VLC track enumeration.

+
+ +
+

Changes Made

+
    +
  • Added web bridge metadata for subtitle discovery, dedupe, and post counts.
  • +
  • Logged subtitle bridge messages after native parsing, including whether a native player is active.
  • +
  • Logged native player forwarding, resolution, duplicate filtering, attachment counts, resulting subtitle tracks, and selected track id.
  • +
  • Logged VLC track state after addPlaybackSlave, after the delayed refresh, and when VLC reports .esAdded.
  • +
  • Added DEBUG-only formatting helpers for subtitle candidates and tracks that include labels, languages, extensions, names, indexes, and selected ids without exposing full subtitle URLs.
  • +
+
+ +
+

Context

+

The current failure mode has already shown native playback starting with subtitles=0. This pass avoids behavior changes and instead makes the next Xcode run produce proof about which stage has zero subtitles or loses them.

+
+ +
+

Important Implementation Details

+
    +
  • Swift logs are wrapped in #if DEBUG, so release behavior is unchanged.
  • +
  • The injected web script now includes debug count metadata in subtitle bridge messages. It still sends the same subtitle payload shape used by native parsing.
  • +
  • Full URLs remain redacted through existing URLRedactor where URLs are printed. Candidate summaries intentionally show only label, language, and file extension.
  • +
  • The VLC logs print videoSubTitlesNames, videoSubTitlesIndexes, and currentVideoSubTitleIndex at the moments most relevant to track exposure.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs/ssr using one file diff per render.

+

DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-3+37
126 unmodified lines
127
128
129
130
131
132
2 unmodified lines
135
136
137
138
139
140
141
142
143
144
145
146
333 unmodified lines
480
481
482
483
484
485
3 unmodified lines
489
490
491
492
493
494
495
162 unmodified lines
658
659
660
661
662
663
71 unmodified lines
735
736
737
738
739
740
741
126 unmodified lines
};
+
const postSubtitleCandidates = (candidates) => {
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
return false;
2 unmodified lines
return true;
});
if (fresh.length === 0) {
return;
}
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: fresh
});
} catch (_) {}
};
333 unmodified lines
return
}
+
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
3 unmodified lines
+
let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")
#endif
}
+
162 unmodified lines
private func redactedURLString(_ value: String) -> String {
URLRedactor.redactedURLString(value)
}
#endif
}
+
71 unmodified lines
}
+
if message.name == Constants.subtitleCandidateMessageHandler {
handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))
return
}
+
126 unmodified lines
127
128
129
130
131
132
133
2 unmodified lines
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
333 unmodified lines
497
498
499
500
501
502
503
504
505
3 unmodified lines
509
510
511
512
513
514
515
162 unmodified lines
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
71 unmodified lines
765
766
767
768
769
770
771
772
773
774
775
126 unmodified lines
};
+
const postSubtitleCandidates = (candidates) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
return false;
2 unmodified lines
return true;
});
if (fresh.length === 0) {
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: [],
debug: {
discovered: discoveredCount,
deduped: 0,
forwarded: 0
}
});
} catch (_) {}
return;
}
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: fresh,
debug: {
discovered: discoveredCount,
deduped: fresh.length,
forwarded: fresh.length
}
});
} catch (_) {}
};
333 unmodified lines
return
}
+
#if DEBUG
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
3 unmodified lines
+
let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")
#endif
}
+
162 unmodified lines
private func redactedURLString(_ value: String) -> String {
URLRedactor.redactedURLString(value)
}
+
private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) {
let dictionary = body as? [String: Any]
let debug = dictionary?["debug"] as? [String: Any]
let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
let pageURL = dictionary?["pageUrl"] as? String
print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
}
#endif
}
+
71 unmodified lines
}
+
if message.name == Constants.subtitleCandidateMessageHandler {
let candidates = SubtitleCandidateParser.candidates(in: message.body)
#if DEBUG
logSubtitleBridgeMessage(message.body, parsedCandidates: candidates)
#endif
handleSubtitleCandidates(candidates)
return
}
+
+

NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-3+3
139 unmodified lines
140
141
142
143
144
145
146
8 unmodified lines
155
156
157
158
159
160
161
10 unmodified lines
172
173
174
175
176
177
178
139 unmodified lines
let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }
guard !pendingCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")
#endif
return 0
}
8 unmodified lines
await MainActor.run {
guard !resolvedCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")
#endif
return
}
10 unmodified lines
}
#if DEBUG
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
#endif
}
}
139 unmodified lines
140
141
142
143
144
145
146
8 unmodified lines
155
156
157
158
159
160
161
10 unmodified lines
172
173
174
175
176
177
178
139 unmodified lines
let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }
guard !pendingCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")
#endif
return 0
}
8 unmodified lines
await MainActor.run {
guard !resolvedCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) candidates=\(SubtitleDebugFormatter.candidateSummary(pendingCandidates))")
#endif
return
}
10 unmodified lines
}
#if DEBUG
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attachable=\(attachableCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) resolvedCandidates=\(SubtitleDebugFormatter.candidateSummary(resolvedCandidates))")
#endif
}
}
+

VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-1+16
213 unmodified lines
214
215
216
217
218
219
220
5 unmodified lines
226
227
228
229
230
231
232
233
234
235
12 unmodified lines
248
249
250
251
252
253
213 unmodified lines
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
}
#if DEBUG
5 unmodified lines
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.onSubtitleTracksChange?()
}
return attachedCount
}
#endif
}
+
12 unmodified lines
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
onSubtitleTracksChange?()
default:
break
213 unmodified lines
214
215
216
217
218
219
220
221
5 unmodified lines
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
12 unmodified lines
260
261
262
263
264
265
266
267
268
213 unmodified lines
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")
logSubtitleTracks(reason: "after-addPlaybackSlave")
#endif
}
#if DEBUG
5 unmodified lines
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh")
#endif
self?.onSubtitleTracksChange?()
}
return attachedCount
}
+
#if DEBUG
private func logSubtitleTracks(reason: String) {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
}
#endif
#endif
}
+
12 unmodified lines
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
#if DEBUG
logSubtitleTracks(reason: "esAdded")
#endif
onSubtitleTracksChange?()
default:
break
+

StreamCandidate.swift

Dreamio/StreamCandidate.swift
+27
39 unmodified lines
40
41
42
43
44
45
39 unmodified lines
let name: String
}
+
enum PlaybackTimeFormatter {
static func label(for seconds: TimeInterval) -> String {
guard seconds.isFinite, seconds > 0 else {
39 unmodified lines
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
39 unmodified lines
let name: String
}
+
#if DEBUG
enum SubtitleDebugFormatter {
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
guard !candidates.isEmpty else {
return "[]"
}
+
return candidates.map { candidate in
let extensionLabel = candidate.url.pathExtension.isEmpty ? "none" : candidate.url.pathExtension.lowercased()
let language = candidate.language?.isEmpty == false ? candidate.language! : "unknown"
let label = candidate.label.isEmpty ? "External Subtitle" : candidate.label
return "{label=\(label), language=\(language), ext=\(extensionLabel)}"
}.joined(separator: ", ")
}
+
static func trackSummary(_ tracks: [SubtitleTrack]) -> String {
guard !tracks.isEmpty else {
return "[]"
}
+
return tracks.map { track in
"{id=\(track.id), name=\(track.name)}"
}.joined(separator: ", ")
}
}
#endif
+
enum PlaybackTimeFormatter {
static func label(for seconds: TimeInterval) -> String {
guard seconds.isFinite, seconds > 0 else {
+
+ +
+

Expected Impact for End-Users

+

No user-facing behavior should change. Debug builds should provide clearer Xcode logs for the next playback attempt, making it faster to identify the actual subtitle failure point before changing playback behavior.

+
+ +
+

Validation

+
    +
  • xcodebuild build -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO passed.
  • +
  • xcodebuild test -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'platform=iOS Simulator,name=iPhone 16' could not run because the Dreamio scheme is not configured for the test action.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • This does not fix subtitle playback by design. It proves where the next fix belongs.
  • +
  • The web script can only log subtitle candidates it detects through the existing discovery paths. If the next run shows no bridge discovery logs, Stremio or OpenSubtitles payload discovery is still the likely target.
  • +
  • Duplicate-only subtitle bridge messages now post an empty subtitle list with debug metadata. Native handling ignores empty parsed candidates, so playback behavior remains unchanged.
  • +
+
+ +
+

Follow-up Work

+
    +
  • If logs show no [DreamioSubtitles] bridge discovered entries, inspect the current Stremio/OpenSubtitles payload shape and extend discovery.
  • +
  • If logs show playerActive=false before native playback exists, add a small pending subtitle queue rather than dropping early subtitle messages.
  • +
  • If logs show resolution failures, improve resolver support for the failing OpenSubtitles response format.
  • +
  • If logs show VLC attachment succeeds but track arrays stay empty, test VLC subtitle slave behavior for the resolved file type and timing.
  • +
+
+
+ + From 2cbe982a478d44b57efac932999692d9d3bf4364 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:19:28 -0400 Subject: [PATCH 06/21] add captions selection proof logging --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 27 +++- Dreamio/VLCNativePlaybackBackend.swift | 12 ++ ...-05-25-prove-native-subtitle-pipeline.html | 147 ++++++++++++++++++ 5 files changed, 184 insertions(+), 4 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e392a3d..fea86c5 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -17,3 +17,4 @@ {"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}} {"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation."}} {"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}} +{"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3675ba6..a41aa86 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -11,6 +11,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-c1m","title":"Add captions selection proof logging","description":"Add DEBUG-only logs around the native captions menu and VLC subtitle selection path so subtitle tap actions prove whether the UI fires and whether VLC accepts the selected embedded track index.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:18:06Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:19:19Z","started_at":"2026-05-25T14:18:11Z","closed_at":"2026-05-25T14:19:19Z","close_reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 3a94caf..ccd0c16 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -380,13 +380,27 @@ final class NativePlayerViewController: UIViewController { private func captionsMenu() -> UIMenu { let selectedTrackID = backend.selectedSubtitleTrackID - let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in + 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 - self?.backend.selectSubtitleTrack(id: track.id) - self?.refreshControls() + guard let self else { + return + } +#if DEBUG + print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)") +#endif + self.backend.selectSubtitleTrack(id: track.id) +#if DEBUG + print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))") +#endif + self.refreshControls() } } @@ -420,12 +434,17 @@ final class NativePlayerViewController: UIViewController { } private func refreshControls() { + let subtitleTracks = backend.subtitleTracks + let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks) playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) scrubber.isEnabled = backend.isSeekable jumpBackButton.isEnabled = backend.isSeekable jumpForwardButton.isEnabled = backend.isSeekable - captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty + captionsButton.isEnabled = !subtitleOptions.isEmpty captionsButton.menu = captionsMenu() +#if DEBUG + print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)") +#endif elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" if !isScrubbing { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 84ef193..b1fa064 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -100,14 +100,26 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func selectSubtitleTrack(id: Int32) { #if canImport(MobileVLCKit) +#if DEBUG + logSubtitleTracks(reason: "before-select-\(id)") +#endif mediaPlayer.currentVideoSubTitleIndex = id +#if DEBUG + logSubtitleTracks(reason: "after-select-\(id)") +#endif onSubtitleTracksChange?() #endif } func adjustSubtitleDelay(by seconds: TimeInterval) { #if canImport(MobileVLCKit) +#if DEBUG + print("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)") +#endif mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000) +#if DEBUG + print("[DreamioVLC] subtitle delay after=\(subtitleDelay)") +#endif onSubtitleTracksChange?() #endif } diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html index fd877b3..a6e2700 100644 --- a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html +++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html @@ -383,6 +383,153 @@
  • If logs show VLC attachment succeeds but track arrays stay empty, test VLC subtitle slave behavior for the resolved file type and timing.
  • +
    +

    New Changes as of 2026-05-25 10:19 AM EDT

    +

    Summary of changes

    +

    Added DEBUG-only proof logs for the captions menu selection path after the latest Xcode run showed VLC exposing an embedded subtitle track but no new logs during selection.

    +

    Why this change was made

    +

    The previous diagnostics proved external subtitles were absent and VLC exposed an embedded English (SDH) track. The missing proof was whether tapping the captions menu fires a native action and whether VLC accepts the selected track id.

    +

    Code diffs

    +

    NativePlayerViewController.swift

    Dreamio/NativePlayerViewController.swift
    -4+23
    379 unmodified lines
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    27 unmodified lines
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    379 unmodified lines
    +
    private func captionsMenu() -> UIMenu {
    let selectedTrackID = backend.selectedSubtitleTrackID
    let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in
    UIAction(
    title: track.name,
    state: track.id == selectedTrackID ? .on : .off
    ) { [weak self] _ in
    self?.backend.selectSubtitleTrack(id: track.id)
    self?.refreshControls()
    }
    }
    +
    27 unmodified lines
    }
    +
    private func refreshControls() {
    playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
    scrubber.isEnabled = backend.isSeekable
    jumpBackButton.isEnabled = backend.isSeekable
    jumpForwardButton.isEnabled = backend.isSeekable
    captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
    captionsButton.menu = captionsMenu()
    elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
    remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
    if !isScrubbing {
    379 unmodified lines
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    27 unmodified lines
    434
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    379 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)")
    #endif
    self.backend.selectSubtitleTrack(id: track.id)
    #if DEBUG
    print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
    #endif
    self.refreshControls()
    }
    }
    +
    27 unmodified lines
    }
    +
    private func refreshControls() {
    let subtitleTracks = backend.subtitleTracks
    let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks)
    playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
    scrubber.isEnabled = backend.isSeekable
    jumpBackButton.isEnabled = backend.isSeekable
    jumpForwardButton.isEnabled = backend.isSeekable
    captionsButton.isEnabled = !subtitleOptions.isEmpty
    captionsButton.menu = captionsMenu()
    #if DEBUG
    print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")
    #endif
    elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
    remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
    if !isScrubbing {
    +

    VLCNativePlaybackBackend.swift

    Dreamio/VLCNativePlaybackBackend.swift
    +12
    99 unmodified lines
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    99 unmodified lines
    +
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    mediaPlayer.currentVideoSubTitleIndex = id
    onSubtitleTracksChange?()
    #endif
    }
    +
    func adjustSubtitleDelay(by seconds: TimeInterval) {
    #if canImport(MobileVLCKit)
    mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)
    onSubtitleTracksChange?()
    #endif
    }
    99 unmodified lines
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    99 unmodified lines
    +
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    #if DEBUG
    logSubtitleTracks(reason: "before-select-\(id)")
    #endif
    mediaPlayer.currentVideoSubTitleIndex = id
    #if DEBUG
    logSubtitleTracks(reason: "after-select-\(id)")
    #endif
    onSubtitleTracksChange?()
    #endif
    }
    +
    func adjustSubtitleDelay(by seconds: TimeInterval) {
    #if canImport(MobileVLCKit)
    #if DEBUG
    print("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)")
    #endif
    mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)
    #if DEBUG
    print("[DreamioVLC] subtitle delay after=\(subtitleDelay)")
    #endif
    onSubtitleTracksChange?()
    #endif
    }
    +

    Related issues or PRs

    +

    Related Beads issue: dreamio-c1m.

    +
    + From ff0ee655388090e5e921a41cd45ebfd39d25f658 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:26:07 -0400 Subject: [PATCH 07/21] stabilize captions menu refresh --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 42 ++++++++-- ...-05-25-prove-native-subtitle-pipeline.html | 83 +++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index fea86c5..6223229 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -18,3 +18,4 @@ {"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation."}} {"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}} {"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}} +{"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a41aa86..3d7b0e4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index ccd0c16..e821aea 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -9,6 +9,7 @@ final class NativePlayerViewController: UIViewController { private var progressTimer: Timer? private var isScrubbing = false private var attachedSubtitleURLs: Set + private var captionsMenuSignature: String? var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -400,6 +401,7 @@ final class NativePlayerViewController: UIViewController { #if DEBUG print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))") #endif + self.captionsMenuSignature = nil self.refreshControls() } } @@ -410,10 +412,12 @@ final class NativePlayerViewController: UIViewController { children: [ UIAction(title: "Decrease 0.5s") { [weak self] _ in self?.backend.adjustSubtitleDelay(by: -0.5) + self?.captionsMenuSignature = nil self?.refreshControls() }, UIAction(title: "Increase 0.5s") { [weak self] _ in self?.backend.adjustSubtitleDelay(by: 0.5) + self?.captionsMenuSignature = nil self?.refreshControls() }, UIAction( @@ -435,16 +439,11 @@ final class NativePlayerViewController: UIViewController { private func refreshControls() { let subtitleTracks = backend.subtitleTracks - let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks) playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) scrubber.isEnabled = backend.isSeekable jumpBackButton.isEnabled = backend.isSeekable jumpForwardButton.isEnabled = backend.isSeekable - captionsButton.isEnabled = !subtitleOptions.isEmpty - captionsButton.menu = captionsMenu() -#if DEBUG - print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)") -#endif + updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks) elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" if !isScrubbing { @@ -453,6 +452,37 @@ final class NativePlayerViewController: UIViewController { [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } } + private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) { + let selectedTrackID = backend.selectedSubtitleTrackID + let signature = captionsMenuSignatureValue( + tracks: subtitleTracks, + selectedTrackID: selectedTrackID, + delay: backend.subtitleDelay + ) + let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 } + captionsButton.isEnabled = hasSelectableTrack + guard signature != captionsMenuSignature else { + return + } + + captionsMenuSignature = signature + captionsButton.menu = captionsMenu() +#if DEBUG + print("[DreamioCaptions] refresh-menu enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(selectedTrackID)") +#endif + } + + private func captionsMenuSignatureValue( + tracks: [SubtitleTrack], + selectedTrackID: Int32, + delay: TimeInterval + ) -> String { + let trackSignature = tracks + .map { "\($0.id):\($0.name)" } + .joined(separator: "|") + return "\(trackSignature)#selected=\(selectedTrackID)#delay=\(String(format: "%.1f", delay))" + } + private func revealControls() { controlsContainer.isUserInteractionEnabled = true closeButton.isUserInteractionEnabled = true diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html index a6e2700..622d885 100644 --- a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html +++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html @@ -530,6 +530,89 @@

    Related Beads issue: dreamio-c1m.

    +
    +

    New Changes as of 2026-05-25 10:26 AM EDT

    +

    Summary of changes

    +

    Stabilized captions menu rebuilding so progress refreshes no longer replace the UIMenu every half second while the user is trying to select a subtitle track.

    +

    Why this change was made

    +

    The latest logs showed VLC exposing the embedded subtitle track and the native menu eventually seeing it, but no selection action fired. The repeated build-menu and refresh logs showed the progress timer was constantly recreating the menu.

    +

    Code diffs

    +

    NativePlayerViewController.swift

    Dreamio/NativePlayerViewController.swift
    -6+36
    8 unmodified lines
    9
    10
    11
    12
    13
    14
    385 unmodified lines
    400
    401
    402
    403
    404
    405
    4 unmodified lines
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    15 unmodified lines
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    2 unmodified lines
    453
    454
    455
    456
    457
    458
    8 unmodified lines
    private var progressTimer: Timer?
    private var isScrubbing = false
    private var attachedSubtitleURLs: Set<URL>
    var onDismiss: (() -> Void)?
    +
    private let loadingView: UIActivityIndicatorView = {
    385 unmodified lines
    #if DEBUG
    print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
    #endif
    self.refreshControls()
    }
    }
    4 unmodified lines
    children: [
    UIAction(title: "Decrease 0.5s") { [weak self] _ in
    self?.backend.adjustSubtitleDelay(by: -0.5)
    self?.refreshControls()
    },
    UIAction(title: "Increase 0.5s") { [weak self] _ in
    self?.backend.adjustSubtitleDelay(by: 0.5)
    self?.refreshControls()
    },
    UIAction(
    15 unmodified lines
    +
    private func refreshControls() {
    let subtitleTracks = backend.subtitleTracks
    let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks)
    playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
    scrubber.isEnabled = backend.isSeekable
    jumpBackButton.isEnabled = backend.isSeekable
    jumpForwardButton.isEnabled = backend.isSeekable
    captionsButton.isEnabled = !subtitleOptions.isEmpty
    captionsButton.menu = captionsMenu()
    #if DEBUG
    print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")
    #endif
    elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
    remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
    if !isScrubbing {
    2 unmodified lines
    [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
    }
    +
    private func revealControls() {
    controlsContainer.isUserInteractionEnabled = true
    closeButton.isUserInteractionEnabled = true
    8 unmodified lines
    9
    10
    11
    12
    13
    14
    15
    385 unmodified lines
    401
    402
    403
    404
    405
    406
    407
    4 unmodified lines
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    15 unmodified lines
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    2 unmodified lines
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    8 unmodified lines
    private var progressTimer: Timer?
    private var isScrubbing = false
    private var attachedSubtitleURLs: Set<URL>
    private var captionsMenuSignature: String?
    var onDismiss: (() -> Void)?
    +
    private let loadingView: UIActivityIndicatorView = {
    385 unmodified lines
    #if DEBUG
    print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
    #endif
    self.captionsMenuSignature = nil
    self.refreshControls()
    }
    }
    4 unmodified lines
    children: [
    UIAction(title: "Decrease 0.5s") { [weak self] _ in
    self?.backend.adjustSubtitleDelay(by: -0.5)
    self?.captionsMenuSignature = nil
    self?.refreshControls()
    },
    UIAction(title: "Increase 0.5s") { [weak self] _ in
    self?.backend.adjustSubtitleDelay(by: 0.5)
    self?.captionsMenuSignature = nil
    self?.refreshControls()
    },
    UIAction(
    15 unmodified lines
    +
    private func refreshControls() {
    let subtitleTracks = backend.subtitleTracks
    playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
    scrubber.isEnabled = backend.isSeekable
    jumpBackButton.isEnabled = backend.isSeekable
    jumpForwardButton.isEnabled = backend.isSeekable
    updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
    elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
    remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
    if !isScrubbing {
    2 unmodified lines
    [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
    }
    +
    private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
    let selectedTrackID = backend.selectedSubtitleTrackID
    let signature = captionsMenuSignatureValue(
    tracks: subtitleTracks,
    selectedTrackID: selectedTrackID,
    delay: backend.subtitleDelay
    )
    let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
    captionsButton.isEnabled = hasSelectableTrack
    guard signature != captionsMenuSignature else {
    return
    }
    +
    captionsMenuSignature = signature
    captionsButton.menu = captionsMenu()
    #if DEBUG
    print("[DreamioCaptions] refresh-menu enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(selectedTrackID)")
    #endif
    }
    +
    private func captionsMenuSignatureValue(
    tracks: [SubtitleTrack],
    selectedTrackID: Int32,
    delay: TimeInterval
    ) -> String {
    let trackSignature = tracks
    .map { "\($0.id):\($0.name)" }
    .joined(separator: "|")
    return "\(trackSignature)#selected=\(selectedTrackID)#delay=\(String(format: "%.1f", delay))"
    }
    +
    private func revealControls() {
    controlsContainer.isUserInteractionEnabled = true
    closeButton.isUserInteractionEnabled = true
    +

    Related issues or PRs

    +

    Related Beads issue: dreamio-bd9.

    +
    + From c571eb88735ed2afe444c95aeaf2429cf721302a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:31:54 -0400 Subject: [PATCH 08/21] trace stremio subtitle discovery --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 65 ++++++++++++-- ...-05-25-prove-native-subtitle-pipeline.html | 86 +++++++++++++++++++ 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 6223229..0b1068f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -19,3 +19,4 @@ {"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}} {"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}} {"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}} +{"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3d7b0e4..c67315b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index bc1ccf0..c38ae55 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -81,6 +81,7 @@ final class DreamioWebViewController: UIViewController { const subtitleCandidates = []; const postedSubtitleURLs = new Set(); const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; + const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i; const looksNative = (url) => { if (!url || typeof url !== "string") { @@ -126,7 +127,7 @@ final class DreamioWebViewController: UIViewController { } catch (_) {} }; - const postSubtitleCandidates = (candidates) => { + const postSubtitleCandidates = (candidates, debug = {}) => { const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { if (postedSubtitleURLs.has(candidate.url)) { @@ -143,7 +144,8 @@ final class DreamioWebViewController: UIViewController { debug: { discovered: discoveredCount, deduped: 0, - forwarded: 0 + forwarded: 0, + ...debug } }); } catch (_) {} @@ -156,14 +158,28 @@ final class DreamioWebViewController: UIViewController { debug: { discovered: discoveredCount, deduped: fresh.length, - forwarded: fresh.length + forwarded: fresh.length, + ...debug } }); } catch (_) {} }; const addSubtitleCandidate = (entry) => { - const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download); + const rawURL = typeof entry === "string" + ? entry + : entry && ( + entry.url || + entry.href || + entry.src || + entry.link || + entry.file || + entry.download || + entry.externalUrl || + entry.externalURL || + entry.fileUrl || + entry.fileURL + ); const url = absoluteURL(rawURL); subtitleURLPattern.lastIndex = 0; if (!url || !subtitleURLPattern.test(url)) { @@ -183,6 +199,19 @@ final class DreamioWebViewController: UIViewController { postSubtitleCandidates([candidate]); }; + const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => { + if (afterCount > beforeCount) { + return; + } + postSubtitleCandidates([], { + source, + inspected: true, + url: url || "", + payloadLength: payloadLength || 0, + totalKnown: subtitleCandidates.length + }); + }; + const inspectSubtitlePayload = (payload) => { if (!payload) { return; @@ -206,6 +235,12 @@ final class DreamioWebViewController: UIViewController { } }; + const inspectSubtitleText = (source, url, text) => { + const beforeCount = subtitleCandidates.length; + inspectSubtitlePayload(text); + postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0); + }; + const originalFetch = window.fetch; if (originalFetch) { window.fetch = async (...args) => { @@ -216,10 +251,13 @@ final class DreamioWebViewController: UIViewController { subtitleURLPattern.lastIndex = 0; const shouldInspect = !contentType || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType) - || subtitleURLPattern.test(url); + || subtitleURLPattern.test(url) + || subtitleSignalPattern.test(url); if (shouldInspect) { subtitleURLPattern.lastIndex = 0; - response.clone().text().then(inspectSubtitlePayload).catch(() => {}); + response.clone().text().then((text) => { + inspectSubtitleText("fetch", url, text); + }).catch(() => {}); } } catch (_) {} return response; @@ -235,7 +273,13 @@ final class DreamioWebViewController: UIViewController { if (responseType && responseType !== "text") { return; } - inspectSubtitlePayload(this.responseText); + const url = this.responseURL || ""; + const text = this.responseText || ""; + if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) { + inspectSubtitleText("xhr", url, text); + } else { + inspectSubtitlePayload(text); + } } catch (_) {} }); } catch (_) {} @@ -685,8 +729,13 @@ final class DreamioWebViewController: UIViewController { let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count + let source = debug?["source"] as? String ?? "bridge" + let inspected = debug?["inspected"] as? Bool ?? false + let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none" + let payloadLength = debug?["payloadLength"] as? Int ?? 0 + let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count let pageURL = dictionary?["pageUrl"] as? String - print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") + print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") } #endif } diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html index 622d885..34e226e 100644 --- a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html +++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html @@ -613,6 +613,92 @@

    Related Beads issue: dreamio-bd9.

    +
    +

    New Changes as of 2026-05-25 10:32 AM EDT

    +

    Summary of changes

    +

    Extended subtitle discovery diagnostics upstream of the native captions menu so subtitle-shaped fetch and XHR responses now log even when they produce zero parseable external candidates.

    +

    Why this change was made

    +

    The latest run confirmed the app only receives VLC embedded captions for this stream. The remaining unknown is whether Stremio is making subtitle addon requests that the injected parser misses, or whether no external subtitle payload is requested at all.

    +

    Code diffs

    +

    DreamioWebViewController.swift

    Dreamio/DreamioWebViewController.swift
    -8+57
    80 unmodified lines
    81
    82
    83
    84
    85
    86
    39 unmodified lines
    126
    127
    128
    129
    130
    131
    132
    10 unmodified lines
    143
    144
    145
    146
    147
    148
    149
    6 unmodified lines
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    13 unmodified lines
    183
    184
    185
    186
    187
    188
    17 unmodified lines
    206
    207
    208
    209
    210
    211
    4 unmodified lines
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    9 unmodified lines
    235
    236
    237
    238
    239
    240
    241
    443 unmodified lines
    685
    686
    687
    688
    689
    690
    691
    692
    80 unmodified lines
    const subtitleCandidates = [];
    const postedSubtitleURLs = new Set();
    const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
    +
    const looksNative = (url) => {
    if (!url || typeof url !== "string") {
    39 unmodified lines
    } catch (_) {}
    };
    +
    const postSubtitleCandidates = (candidates) => {
    const discoveredCount = candidates.length;
    const fresh = candidates.filter((candidate) => {
    if (postedSubtitleURLs.has(candidate.url)) {
    10 unmodified lines
    debug: {
    discovered: discoveredCount,
    deduped: 0,
    forwarded: 0
    }
    });
    } catch (_) {}
    6 unmodified lines
    debug: {
    discovered: discoveredCount,
    deduped: fresh.length,
    forwarded: fresh.length
    }
    });
    } 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);
    subtitleURLPattern.lastIndex = 0;
    if (!url || !subtitleURLPattern.test(url)) {
    13 unmodified lines
    postSubtitleCandidates([candidate]);
    };
    +
    const inspectSubtitlePayload = (payload) => {
    if (!payload) {
    return;
    17 unmodified lines
    }
    };
    +
    const originalFetch = window.fetch;
    if (originalFetch) {
    window.fetch = async (...args) => {
    4 unmodified lines
    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;
    9 unmodified lines
    if (responseType && responseType !== "text") {
    return;
    }
    inspectSubtitlePayload(this.responseText);
    } catch (_) {}
    });
    } catch (_) {}
    443 unmodified lines
    let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
    let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
    let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
    let pageURL = dictionary?["pageUrl"] as? String
    print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
    }
    #endif
    }
    80 unmodified lines
    81
    82
    83
    84
    85
    86
    87
    39 unmodified lines
    127
    128
    129
    130
    131
    132
    133
    10 unmodified lines
    144
    145
    146
    147
    148
    149
    150
    151
    6 unmodified lines
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    13 unmodified lines
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    17 unmodified lines
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    4 unmodified lines
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    9 unmodified lines
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    443 unmodified lines
    729
    730
    731
    732
    733
    734
    735
    736
    737
    738
    739
    740
    741
    80 unmodified lines
    const subtitleCandidates = [];
    const postedSubtitleURLs = new Set();
    const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
    const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
    +
    const looksNative = (url) => {
    if (!url || typeof url !== "string") {
    39 unmodified lines
    } catch (_) {}
    };
    +
    const postSubtitleCandidates = (candidates, debug = {}) => {
    const discoveredCount = candidates.length;
    const fresh = candidates.filter((candidate) => {
    if (postedSubtitleURLs.has(candidate.url)) {
    10 unmodified lines
    debug: {
    discovered: discoveredCount,
    deduped: 0,
    forwarded: 0,
    ...debug
    }
    });
    } catch (_) {}
    6 unmodified lines
    debug: {
    discovered: discoveredCount,
    deduped: fresh.length,
    forwarded: fresh.length,
    ...debug
    }
    });
    } catch (_) {}
    };
    +
    const addSubtitleCandidate = (entry) => {
    const rawURL = typeof entry === "string"
    ? entry
    : entry && (
    entry.url ||
    entry.href ||
    entry.src ||
    entry.link ||
    entry.file ||
    entry.download ||
    entry.externalUrl ||
    entry.externalURL ||
    entry.fileUrl ||
    entry.fileURL
    );
    const url = absoluteURL(rawURL);
    subtitleURLPattern.lastIndex = 0;
    if (!url || !subtitleURLPattern.test(url)) {
    13 unmodified lines
    postSubtitleCandidates([candidate]);
    };
    +
    const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
    if (afterCount > beforeCount) {
    return;
    }
    postSubtitleCandidates([], {
    source,
    inspected: true,
    url: url || "",
    payloadLength: payloadLength || 0,
    totalKnown: subtitleCandidates.length
    });
    };
    +
    const inspectSubtitlePayload = (payload) => {
    if (!payload) {
    return;
    17 unmodified lines
    }
    };
    +
    const inspectSubtitleText = (source, url, text) => {
    const beforeCount = subtitleCandidates.length;
    inspectSubtitlePayload(text);
    postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
    };
    +
    const originalFetch = window.fetch;
    if (originalFetch) {
    window.fetch = async (...args) => {
    4 unmodified lines
    subtitleURLPattern.lastIndex = 0;
    const shouldInspect = !contentType
    || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)
    || subtitleURLPattern.test(url)
    || subtitleSignalPattern.test(url);
    if (shouldInspect) {
    subtitleURLPattern.lastIndex = 0;
    response.clone().text().then((text) => {
    inspectSubtitleText("fetch", url, text);
    }).catch(() => {});
    }
    } catch (_) {}
    return response;
    9 unmodified lines
    if (responseType && responseType !== "text") {
    return;
    }
    const url = this.responseURL || "";
    const text = this.responseText || "";
    if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) {
    inspectSubtitleText("xhr", url, text);
    } else {
    inspectSubtitlePayload(text);
    }
    } catch (_) {}
    });
    } catch (_) {}
    443 unmodified lines
    let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
    let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
    let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
    let source = debug?["source"] as? String ?? "bridge"
    let inspected = debug?["inspected"] as? Bool ?? false
    let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none"
    let payloadLength = debug?["payloadLength"] as? Int ?? 0
    let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count
    let pageURL = dictionary?["pageUrl"] as? String
    print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
    }
    #endif
    }
    +

    Related issues or PRs

    +

    Related Beads issue: dreamio-ese.

    +
    + From d6c445e9cbf2bb4ff16663c80ade283ff2084a1a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:38:48 -0400 Subject: [PATCH 09/21] auto select embedded vlc subtitles --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/VLCNativePlaybackBackend.swift | 22 ++ ...05-25-auto-select-vlc-subtitle-tracks.html | 233 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 0b1068f..ba85e4d 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -20,3 +20,4 @@ {"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}} {"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}} {"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}} +{"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c67315b..4b1a01e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index b1fa064..209128d 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -23,6 +23,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private let mediaPlayer = VLCMediaPlayer() #endif private var attachedSubtitleURLs = Set() + private var didAutoSelectSubtitleTrack = false + private var didUserSelectSubtitleTrack = false override init() { super.init() @@ -41,6 +43,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) attachedSubtitleURLs.removeAll() + didAutoSelectSubtitleTrack = false + didUserSelectSubtitleTrack = false let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers .map { "\($0.key): \($0.value)" } @@ -100,6 +104,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func selectSubtitleTrack(id: Int32) { #if canImport(MobileVLCKit) + didUserSelectSubtitleTrack = true #if DEBUG logSubtitleTracks(reason: "before-select-\(id)") #endif @@ -239,6 +244,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return attachedCount } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh") #if DEBUG self?.logSubtitleTracks(reason: "delayed-refresh") #endif @@ -254,6 +260,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)") } #endif + + private func selectInitialSubtitleTrackIfNeeded(reason: String) { + guard !didUserSelectSubtitleTrack, + !didAutoSelectSubtitleTrack, + mediaPlayer.currentVideoSubTitleIndex < 0, + let track = subtitleTracks.first(where: { $0.id >= 0 }) else { + return + } + + didAutoSelectSubtitleTrack = true +#if DEBUG + print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)") +#endif + mediaPlayer.currentVideoSubTitleIndex = track.id + } #endif } @@ -272,6 +293,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { case .paused, .stopped, .ended: onStateChange?() case .esAdded: + selectInitialSubtitleTrackIfNeeded(reason: "esAdded") #if DEBUG logSubtitleTracks(reason: "esAdded") #endif diff --git a/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html new file mode 100644 index 0000000..57bcb80 --- /dev/null +++ b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html @@ -0,0 +1,233 @@ + + + + + + Auto-select VLC subtitle tracks + + + +
    +
    +

    Auto-select VLC subtitle tracks

    +

    Turn document for dreamio-djc, created May 25, 2026.

    +
    +

    Dreamio now asks MobileVLCKit to enable the first real subtitle track when VLC discovers embedded MKV subtitles and playback is still on “Disable.” This targets the log pattern where VLC sees English (SDH) but leaves selected=-1.

    +
    +
    + +
    +

    Summary

    +

    Fixed the remaining native playback subtitle issue for streams that provide no external subtitle candidates but do contain embedded subtitle tracks. VLC can discover those tracks after playback starts; Dreamio now auto-selects the first selectable one once it appears.

    +
    + +
    +

    Changes Made

    +
      +
    • Added per-playback state to track whether Dreamio has already auto-selected subtitles.
    • +
    • Added per-playback state to detect when the user manually chooses a caption option, including disabling captions.
    • +
    • Auto-selects the first subtitle track with a non-negative VLC track id when VLC reports .esAdded or after an external subtitle attach refresh.
    • +
    • Added a debug log line for automatic subtitle selection so future device logs should show the selected track id and reason.
    • +
    +
    + +
    +

    Context

    +

    The reported logs showed subtitle candidates=0 from the native player, followed by VLC reporting subtitle tracks named Disable and English (SDH) - [English] with selected track -1. That means stream parsing and VLC track discovery were both working; the remaining gap was that Dreamio never changed VLC away from the disabled subtitle track.

    +
    + +
    +

    Important Implementation Details

    +
      +
    • The selection guard only runs when currentVideoSubTitleIndex is below zero, so it will not replace an already active subtitle track.
    • +
    • The auto-selection runs only once per playback item.
    • +
    • User interaction wins: once selectSubtitleTrack(id:) is called from the captions menu, Dreamio stops automatic caption selection for that playback item.
    • +
    • The state resets in play(request:), alongside the existing attached subtitle URL reset.
    • +
    +
    + +
    +

    Relevant Diff Snippets

    +

    Rendered with @pierre/diffs/ssr.

    +
    Dreamio/VLCNativePlaybackBackend.swift
    +22
    22 unmodified lines
    23
    24
    25
    26
    27
    28
    12 unmodified lines
    41
    42
    43
    44
    45
    46
    53 unmodified lines
    100
    101
    102
    103
    104
    105
    133 unmodified lines
    239
    240
    241
    242
    243
    244
    9 unmodified lines
    254
    255
    256
    257
    258
    259
    12 unmodified lines
    272
    273
    274
    275
    276
    277
    22 unmodified lines
    private let mediaPlayer = VLCMediaPlayer()
    #endif
    private var attachedSubtitleURLs = Set<URL>()
    +
    override init() {
    super.init()
    12 unmodified lines
    func play(request: NativePlaybackRequest) {
    #if canImport(MobileVLCKit)
    attachedSubtitleURLs.removeAll()
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    53 unmodified lines
    +
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    #if DEBUG
    logSubtitleTracks(reason: "before-select-\(id)")
    #endif
    133 unmodified lines
    return attachedCount
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh")
    #endif
    9 unmodified lines
    print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    }
    #endif
    #endif
    }
    +
    12 unmodified lines
    case .paused, .stopped, .ended:
    onStateChange?()
    case .esAdded:
    #if DEBUG
    logSubtitleTracks(reason: "esAdded")
    #endif
    22 unmodified lines
    23
    24
    25
    26
    27
    28
    29
    30
    12 unmodified lines
    43
    44
    45
    46
    47
    48
    49
    50
    53 unmodified lines
    104
    105
    106
    107
    108
    109
    110
    133 unmodified lines
    244
    245
    246
    247
    248
    249
    250
    9 unmodified lines
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    12 unmodified lines
    293
    294
    295
    296
    297
    298
    299
    22 unmodified lines
    private let mediaPlayer = VLCMediaPlayer()
    #endif
    private var attachedSubtitleURLs = Set<URL>()
    private var didAutoSelectSubtitleTrack = false
    private var didUserSelectSubtitleTrack = false
    +
    override init() {
    super.init()
    12 unmodified lines
    func play(request: NativePlaybackRequest) {
    #if canImport(MobileVLCKit)
    attachedSubtitleURLs.removeAll()
    didAutoSelectSubtitleTrack = false
    didUserSelectSubtitleTrack = false
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    53 unmodified lines
    +
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    didUserSelectSubtitleTrack = true
    #if DEBUG
    logSubtitleTracks(reason: "before-select-\(id)")
    #endif
    133 unmodified lines
    return attachedCount
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
    self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh")
    #endif
    9 unmodified lines
    print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    }
    #endif
    +
    private func selectInitialSubtitleTrackIfNeeded(reason: String) {
    guard !didUserSelectSubtitleTrack,
    !didAutoSelectSubtitleTrack,
    mediaPlayer.currentVideoSubTitleIndex < 0,
    let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
    return
    }
    +
    didAutoSelectSubtitleTrack = true
    #if DEBUG
    print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
    #endif
    mediaPlayer.currentVideoSubTitleIndex = track.id
    }
    #endif
    }
    +
    12 unmodified lines
    case .paused, .stopped, .ended:
    onStateChange?()
    case .esAdded:
    selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
    #if DEBUG
    logSubtitleTracks(reason: "esAdded")
    #endif
    +
    + +
    +

    Expected Impact for End-Users

    +

    MKV streams with embedded subtitles should show captions automatically instead of requiring the user to open the captions menu and pick the embedded track manually. Users can still disable captions or switch tracks afterward.

    +
    + +
    +

    Validation

    +
      +
    • Ran xcodebuild -scheme Dreamio -project Dreamio.xcodeproj -destination 'generic/platform=iOS' build.
    • +
    • The build succeeded against MobileVLCKit.
    • +
    +
    + +
    +

    Issues, Limitations, and Mitigations

    +
      +
    • This was validated by build, not by replaying the exact Real-Debrid stream on device in this turn.
    • +
    • If a file has multiple embedded subtitle tracks, Dreamio chooses the first selectable track VLC exposes. The captions menu remains available for manual switching.
    • +
    • If a stream intentionally starts with subtitles disabled and the user never touches the captions menu, Dreamio will now enable the first discovered track by default.
    • +
    +
    + +
    +

    Follow-up Work

    +
      +
    • Test the same South Park MKV on device and confirm the logs include [DreamioVLC] auto-select subtitle followed by a non-negative selected subtitle id.
    • +
    • Consider a future setting for “auto-enable embedded subtitles” if users want control over the default behavior.
    • +
    +
    +
    + + \ No newline at end of file From 892196421c75b895a4a95f1e7f5571ba1a1c0d3a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:44:15 -0400 Subject: [PATCH 10/21] update xcode workspace state --- .agents/skills/liquid-glass-design/SKILL.md | 279 ++++++++++++++++++ .beads/issues.jsonl | 1 + .../UserInterfaceState.xcuserstate | Bin 13271 -> 13273 bytes skills-lock.json | 11 + 4 files changed, 291 insertions(+) create mode 100644 .agents/skills/liquid-glass-design/SKILL.md create mode 100644 skills-lock.json diff --git a/.agents/skills/liquid-glass-design/SKILL.md b/.agents/skills/liquid-glass-design/SKILL.md new file mode 100644 index 0000000..60551c2 --- /dev/null +++ b/.agents/skills/liquid-glass-design/SKILL.md @@ -0,0 +1,279 @@ +--- +name: liquid-glass-design +description: iOS 26 Liquid Glass design system — dynamic glass material with blur, reflection, and interactive morphing for SwiftUI, UIKit, and WidgetKit. +--- + +# Liquid Glass Design System (iOS 26) + +Patterns for implementing Apple's Liquid Glass — a dynamic material that blurs content behind it, reflects color and light from surrounding content, and reacts to touch and pointer interactions. Covers SwiftUI, UIKit, and WidgetKit integration. + +## When to Activate + +- Building or updating apps for iOS 26+ with the new design language +- Implementing glass-style buttons, cards, toolbars, or containers +- Creating morphing transitions between glass elements +- Applying Liquid Glass effects to widgets +- Migrating existing blur/material effects to the new Liquid Glass API + +## Core Pattern — SwiftUI + +### Basic Glass Effect + +The simplest way to add Liquid Glass to any view: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect() // Default: regular variant, capsule shape +``` + +### Customizing Shape and Tint + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect(.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 16.0)) +``` + +Key customization options: +- `.regular` — standard glass effect +- `.tint(Color)` — add color tint for prominence +- `.interactive()` — react to touch and pointer interactions +- Shape: `.capsule` (default), `.rect(cornerRadius:)`, `.circle` + +### Glass Button Styles + +```swift +Button("Click Me") { /* action */ } + .buttonStyle(.glass) + +Button("Important") { /* action */ } + .buttonStyle(.glassProminent) +``` + +### GlassEffectContainer for Multiple Elements + +Always wrap multiple glass views in a container for performance and morphing: + +```swift +GlassEffectContainer(spacing: 40.0) { + HStack(spacing: 40.0) { + Image(systemName: "scribble.variable") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + + Image(systemName: "eraser.fill") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + } +} +``` + +The `spacing` parameter controls merge distance — closer elements blend their glass shapes together. + +### Uniting Glass Effects + +Combine multiple views into a single glass shape with `glassEffectUnion`: + +```swift +@Namespace private var namespace + +GlassEffectContainer(spacing: 20.0) { + HStack(spacing: 20.0) { + ForEach(symbolSet.indices, id: \.self) { item in + Image(systemName: symbolSet[item]) + .frame(width: 80.0, height: 80.0) + .glassEffect() + .glassEffectUnion(id: item < 2 ? "group1" : "group2", namespace: namespace) + } + } +} +``` + +### Morphing Transitions + +Create smooth morphing when glass elements appear/disappear: + +```swift +@State private var isExpanded = false +@Namespace private var namespace + +GlassEffectContainer(spacing: 40.0) { + HStack(spacing: 40.0) { + Image(systemName: "scribble.variable") + .frame(width: 80.0, height: 80.0) + .glassEffect() + .glassEffectID("pencil", in: namespace) + + if isExpanded { + Image(systemName: "eraser.fill") + .frame(width: 80.0, height: 80.0) + .glassEffect() + .glassEffectID("eraser", in: namespace) + } + } +} + +Button("Toggle") { + withAnimation { isExpanded.toggle() } +} +.buttonStyle(.glass) +``` + +### Extending Horizontal Scrolling Under Sidebar + +To allow horizontal scroll content to extend under a sidebar or inspector, ensure the `ScrollView` content reaches the leading/trailing edges of the container. The system automatically handles the under-sidebar scrolling behavior when the layout extends to the edges — no additional modifier is needed. + +## Core Pattern — UIKit + +### Basic UIGlassEffect + +```swift +let glassEffect = UIGlassEffect() +glassEffect.tintColor = UIColor.systemBlue.withAlphaComponent(0.3) +glassEffect.isInteractive = true + +let visualEffectView = UIVisualEffectView(effect: glassEffect) +visualEffectView.translatesAutoresizingMaskIntoConstraints = false +visualEffectView.layer.cornerRadius = 20 +visualEffectView.clipsToBounds = true + +view.addSubview(visualEffectView) +NSLayoutConstraint.activate([ + visualEffectView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + visualEffectView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + visualEffectView.widthAnchor.constraint(equalToConstant: 200), + visualEffectView.heightAnchor.constraint(equalToConstant: 120) +]) + +// Add content to contentView +let label = UILabel() +label.text = "Liquid Glass" +label.translatesAutoresizingMaskIntoConstraints = false +visualEffectView.contentView.addSubview(label) +NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: visualEffectView.contentView.centerXAnchor), + label.centerYAnchor.constraint(equalTo: visualEffectView.contentView.centerYAnchor) +]) +``` + +### UIGlassContainerEffect for Multiple Elements + +```swift +let containerEffect = UIGlassContainerEffect() +containerEffect.spacing = 40.0 + +let containerView = UIVisualEffectView(effect: containerEffect) + +let firstGlass = UIVisualEffectView(effect: UIGlassEffect()) +let secondGlass = UIVisualEffectView(effect: UIGlassEffect()) + +containerView.contentView.addSubview(firstGlass) +containerView.contentView.addSubview(secondGlass) +``` + +### Scroll Edge Effects + +```swift +scrollView.topEdgeEffect.style = .automatic +scrollView.bottomEdgeEffect.style = .hard +scrollView.leftEdgeEffect.isHidden = true +``` + +### Toolbar Glass Integration + +```swift +let favoriteButton = UIBarButtonItem(image: UIImage(systemName: "heart"), style: .plain, target: self, action: #selector(favoriteAction)) +favoriteButton.hidesSharedBackground = true // Opt out of shared glass background +``` + +## Core Pattern — WidgetKit + +### Rendering Mode Detection + +```swift +struct MyWidgetView: View { + @Environment(\.widgetRenderingMode) var renderingMode + + var body: some View { + if renderingMode == .accented { + // Tinted mode: white-tinted, themed glass background + } else { + // Full color mode: standard appearance + } + } +} +``` + +### Accent Groups for Visual Hierarchy + +```swift +HStack { + VStack(alignment: .leading) { + Text("Title") + .widgetAccentable() // Accent group + Text("Subtitle") + // Primary group (default) + } + Image(systemName: "star.fill") + .widgetAccentable() // Accent group +} +``` + +### Image Rendering in Accented Mode + +```swift +Image("myImage") + .widgetAccentedRenderingMode(.monochrome) +``` + +### Container Background + +```swift +VStack { /* content */ } + .containerBackground(for: .widget) { + Color.blue.opacity(0.2) + } +``` + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| GlassEffectContainer wrapping | Performance optimization, enables morphing between glass elements | +| `spacing` parameter | Controls merge distance — fine-tune how close elements must be to blend | +| `@Namespace` + `glassEffectID` | Enables smooth morphing transitions on view hierarchy changes | +| `interactive()` modifier | Explicit opt-in for touch/pointer reactions — not all glass should respond | +| UIGlassContainerEffect in UIKit | Same container pattern as SwiftUI for consistency | +| Accented rendering mode in widgets | System applies tinted glass when user selects tinted Home Screen | + +## Best Practices + +- **Always use GlassEffectContainer** when applying glass to multiple sibling views — it enables morphing and improves rendering performance +- **Apply `.glassEffect()` after** other appearance modifiers (frame, font, padding) +- **Use `.interactive()`** only on elements that respond to user interaction (buttons, toggleable items) +- **Choose spacing carefully** in containers to control when glass effects merge +- **Use `withAnimation`** when changing view hierarchies to enable smooth morphing transitions +- **Test across appearances** — light mode, dark mode, and accented/tinted modes +- **Ensure accessibility contrast** — text on glass must remain readable + +## Anti-Patterns to Avoid + +- Using multiple standalone `.glassEffect()` views without a GlassEffectContainer +- Nesting too many glass effects — degrades performance and visual clarity +- Applying glass to every view — reserve for interactive elements, toolbars, and cards +- Forgetting `clipsToBounds = true` in UIKit when using corner radii +- Ignoring accented rendering mode in widgets — breaks tinted Home Screen appearance +- Using opaque backgrounds behind glass — defeats the translucency effect + +## When to Use + +- Navigation bars, toolbars, and tab bars with the new iOS 26 design +- Floating action buttons and card-style containers +- Interactive controls that need visual depth and touch feedback +- Widgets that should integrate with the system's Liquid Glass appearance +- Morphing transitions between related UI states diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4b1a01e..503714f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"open","priority":1,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:44:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index 645ae3483dff75e494d0af5617b1e6f38529cacc..0f24b903f1e5c78ed9279ebcd318d642fa4b4240 100644 GIT binary patch delta 1135 zcmWN|drVhV0KoAdjk3(QdG9^(p1 z&bcL3~U2~zCVTR0WR-}ckan^JzIrr*|!-H8?fmLYjvWl#|)(=*Rb-*gODy?ek zwDqUe1tP!*Ai-EL2_%9=APp=7=^z6H!Ah_itO0952{-~Sf@aVL+Cc~C1YO_(cnBVW z|G*PF!nW*@cATALFSeK1DR!#8(ayJb*addT-fi!(kJ@MLwxHc^clb~FFZjd$CysQo zoeJj{=cses`OP`)oOSLxJ+L1f00+S#a0E0VfPNSU7s52S6fTFEFbG$|4KNRu!ZKI^ zD`7P}1b>3H@Gv|AkHQAn2%F#)*bH0XRoDuz!wz^0-iEya9heZ99QZU4To)(^91FCf ze&{)5Ab|WRfG{E`22DV(qc_kbG#Pz>5>XPGgXW<{XfaAbsc0!$i8i4S+K;NyF?0si zp>wDnHK0b+gsz}wbQ^_H4<3#=9*f_`N%#w#iC5rN_$&N1UWaS(3498l!F9MXh_B&x z+<`lB7w*PS+<;5niEh06p*zE!=_a}h+%Mf#?lw2%mbztbghuk5(bQonchQ`u3 znm~h7=sWa1I-Pz*6X|R^muAo$T1+cw6+K96=wCF<95#w2usJN9tzw02H`~LC**;dv z%2_3=W{21X_6NJf8dxK1VpmuzyUuQ~n=H(F*kjh~J>w1b953Kuk9gy~bZ@n{!Q1F< z@^ZX9FW=i9^a{K}?}FFM1s~7j_#{4=C-7N3iO=ElcrwrDyLdUT<~6*Q|H_Z^ll&Av z&oA>EyqiDZVcsL25=x8_Z;2^lzE~nsM5@RXYsChUEpkMz*dca`U7|=7i+!R})QJYs zB09ytA}k(>UKt^S{pA2TNDh(1X!GvRbGKnCL<*IL0p2|;9+tpe1Fp5V_i&_y?5p_)u)m%sGH}woXODF3z{h3~^bM$7t oMQ_z1U8IY3i7wS;`kZdkf9tEdRo~Dz^(}pSWWU^+XcVFT2lK^A>B_#hx1iJ~N;5P}$$;Ug77v>9VW3 z7n{$UDtsg zrb8X+uj$EphF+{+H%1v=<2hrIkz{<5_xF@gUy-rb*k_a&2aRuyGUJF*VN@B_#(Cop zqu+Q8LIDHQ!CVjvmVq>|3Z#P!um<=*HdqJNgEDXuTm~JW3v`2C&!Y+mR!yZ}8+G15& zKUwwGY3mp3ywzacv4&s}42I+21Q-e@LmdJb4U=ISTnSghOt=R6U=GZOH6p3b`7to7nE}DlHqF5A%63|k#3@t|~C>5T;SU-#i|+>85g zKOV%7Y|FOo+4jr!TlQl69Xr;3-~QarvJ32DyWBo%SK5vC75l2)W#1*E$XF6ZW|9v` zDp^I+Nd{R*c931eTa6%m7L^@GUv=igZ zcYLopuRDvJx1Crg-br*aoLr~WsdSDxHBPPbr{kw4ji50!fu_?ex|i;!2WTlhM9XOf zt)kWRIK4!FqgQAPZKZ9rop#b2w1?iLemX=S(ttbK9q*d1<$7K0&T!M+Y&XZ<>~3{) z-F&yu-R&0n+`aB4H^5vrgGIBsY#xhYOIRFBU`tsND`fjv1*>MYtd9N6PP4P@9J|PV zXFY6?-DiF_#66tzsr)5ApTEaf@D!fPGx>U+!?*BUzLW3a-|&6BgqQL|yoxvS7JilY z@qf9W5Ay(jEXD|5uox#Mh!F9Vm?{>CG_gq(h~1({6pIpZP}GSt;)3{9G>glkLv)HR z(Jgw#9dS<#iU(p?j*?@gA(6DDBV8FGr^snCQbx(ya)FGKi84th%MWFWOp~i+hTJGO z$t`l5+%9*@Jee;GZZD-1|ov;ewdD);Qs>!e} Date: Mon, 25 May 2026 10:45:47 -0400 Subject: [PATCH 11/21] reapply embedded subtitle selection --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 2 +- Dreamio/VLCNativePlaybackBackend.swift | 27 +++++++ ...05-25-auto-select-vlc-subtitle-tracks.html | 78 +++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index ba85e4d..654e9c5 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -21,3 +21,4 @@ {"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}} {"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}} {"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}} +{"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 503714f..4986ec7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"open","priority":1,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:44:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 209128d..f23b8bc 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -25,6 +25,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var attachedSubtitleURLs = Set() private var didAutoSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false + private var autoSelectedSubtitleTrackID: Int32? override init() { super.init() @@ -45,6 +46,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { attachedSubtitleURLs.removeAll() didAutoSelectSubtitleTrack = false didUserSelectSubtitleTrack = false + autoSelectedSubtitleTrackID = nil let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers .map { "\($0.key): \($0.value)" } @@ -105,6 +107,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func selectSubtitleTrack(id: Int32) { #if canImport(MobileVLCKit) didUserSelectSubtitleTrack = true + autoSelectedSubtitleTrackID = nil #if DEBUG logSubtitleTracks(reason: "before-select-\(id)") #endif @@ -270,10 +273,33 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } didAutoSelectSubtitleTrack = true + autoSelectedSubtitleTrackID = track.id #if DEBUG print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)") #endif mediaPlayer.currentVideoSubTitleIndex = track.id + scheduleAutoSubtitleSelectionReapply(trackID: track.id) + } + + private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) { + [0.3, 1.0, 2.0, 4.0].forEach { delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.reapplyAutoSelectedSubtitleTrackIfNeeded(reason: "delayed-\(String(format: "%.1f", delay))") + } + } + } + + private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) { + guard !didUserSelectSubtitleTrack, + let trackID = autoSelectedSubtitleTrackID, + subtitleTracks.contains(where: { $0.id == trackID }) else { + return + } + + mediaPlayer.currentVideoSubTitleIndex = trackID +#if DEBUG + print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)") +#endif } #endif } @@ -286,6 +312,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { #endif switch mediaPlayer.state { case .buffering, .playing: + reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state)) onReady?() onStateChange?() case .error: diff --git a/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html index 57bcb80..033eb0d 100644 --- a/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html +++ b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html @@ -228,6 +228,84 @@
  • Consider a future setting for “auto-enable embedded subtitles” if users want control over the default behavior.
  • +
    +

    New Changes as of May 25, 2026 at 10:45 AM EDT

    +

    Summary of changes: Added a timed re-apply loop for the automatically selected VLC subtitle track. Dreamio now remembers the auto-selected track id and re-sends it after short delays and when VLC reports buffering or playing.

    +

    Why this change was made: Follow-up device logs showed VLC selected track 3, but subtitles still did not render. That points to a MobileVLCKit timing issue after elementary stream discovery, so the selected embedded track is re-applied after playback settles.

    +

    Code diffs:

    +
    Dreamio/VLCNativePlaybackBackend.swift
    +27
    24 unmodified lines
    25
    26
    27
    28
    29
    30
    14 unmodified lines
    45
    46
    47
    48
    49
    50
    54 unmodified lines
    105
    106
    107
    108
    109
    110
    159 unmodified lines
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    6 unmodified lines
    286
    287
    288
    289
    290
    291
    24 unmodified lines
    private var attachedSubtitleURLs = Set<URL>()
    private var didAutoSelectSubtitleTrack = false
    private var didUserSelectSubtitleTrack = false
    +
    override init() {
    super.init()
    14 unmodified lines
    attachedSubtitleURLs.removeAll()
    didAutoSelectSubtitleTrack = false
    didUserSelectSubtitleTrack = false
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    54 unmodified lines
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    didUserSelectSubtitleTrack = true
    #if DEBUG
    logSubtitleTracks(reason: "before-select-\(id)")
    #endif
    159 unmodified lines
    }
    +
    didAutoSelectSubtitleTrack = true
    #if DEBUG
    print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
    #endif
    mediaPlayer.currentVideoSubTitleIndex = track.id
    }
    #endif
    }
    6 unmodified lines
    #endif
    switch mediaPlayer.state {
    case .buffering, .playing:
    onReady?()
    onStateChange?()
    case .error:
    24 unmodified lines
    25
    26
    27
    28
    29
    30
    31
    14 unmodified lines
    46
    47
    48
    49
    50
    51
    52
    54 unmodified lines
    107
    108
    109
    110
    111
    112
    113
    159 unmodified lines
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    6 unmodified lines
    312
    313
    314
    315
    316
    317
    318
    24 unmodified lines
    private var attachedSubtitleURLs = Set<URL>()
    private var didAutoSelectSubtitleTrack = false
    private var didUserSelectSubtitleTrack = false
    private var autoSelectedSubtitleTrackID: Int32?
    +
    override init() {
    super.init()
    14 unmodified lines
    attachedSubtitleURLs.removeAll()
    didAutoSelectSubtitleTrack = false
    didUserSelectSubtitleTrack = false
    autoSelectedSubtitleTrackID = nil
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    54 unmodified lines
    func selectSubtitleTrack(id: Int32) {
    #if canImport(MobileVLCKit)
    didUserSelectSubtitleTrack = true
    autoSelectedSubtitleTrackID = nil
    #if DEBUG
    logSubtitleTracks(reason: "before-select-\(id)")
    #endif
    159 unmodified lines
    }
    +
    didAutoSelectSubtitleTrack = true
    autoSelectedSubtitleTrackID = track.id
    #if DEBUG
    print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
    #endif
    mediaPlayer.currentVideoSubTitleIndex = track.id
    scheduleAutoSubtitleSelectionReapply(trackID: track.id)
    }
    +
    private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
    [0.3, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    self?.reapplyAutoSelectedSubtitleTrackIfNeeded(reason: "delayed-\(String(format: "%.1f", delay))")
    }
    }
    }
    +
    private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) {
    guard !didUserSelectSubtitleTrack,
    let trackID = autoSelectedSubtitleTrackID,
    subtitleTracks.contains(where: { $0.id == trackID }) else {
    return
    }
    +
    mediaPlayer.currentVideoSubTitleIndex = trackID
    #if DEBUG
    print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    #endif
    }
    #endif
    }
    6 unmodified lines
    #endif
    switch mediaPlayer.state {
    case .buffering, .playing:
    reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
    onReady?()
    onStateChange?()
    case .error:
    +

    Related issues or PRs: Beads issue dreamio-ppj.

    +
    + \ No newline at end of file From 28f1dc4f8e953f0f5d9b683ab343d094fdd69a6e Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:51:52 -0400 Subject: [PATCH 12/21] capture opensubtitles text tracks --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 73 +++++++++++++++- ...05-25-auto-select-vlc-subtitle-tracks.html | 84 +++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 654e9c5..b5a0784 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -22,3 +22,4 @@ {"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}} {"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}} {"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}} +{"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4986ec7..04ca83c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index c38ae55..53d417f 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -199,6 +199,37 @@ final class DreamioWebViewController: UIViewController { postSubtitleCandidates([candidate]); }; + const inspectTrack = (track) => { + if (!track) { + return; + } + if (track instanceof HTMLTrackElement) { + addSubtitleCandidate({ + url: track.src || track.getAttribute("src") || "", + label: track.label || track.srclang || "External Subtitle", + language: track.srclang || "" + }); + return; + } + const source = track.src || track.url || ""; + if (source) { + addSubtitleCandidate({ + url: source, + label: track.label || track.language || track.kind || "External Subtitle", + language: track.language || "" + }); + } + }; + + const inspectTextTracks = (media) => { + try { + Array.from(media.textTracks || []).forEach(inspectTrack); + } catch (_) {} + try { + media.querySelectorAll("track").forEach(inspectTrack); + } catch (_) {} + }; + const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => { if (afterCount > beforeCount) { return; @@ -307,11 +338,17 @@ final class DreamioWebViewController: UIViewController { if (!node) { return; } + if (node instanceof HTMLTrackElement) { + inspectTrack(node); + } if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) { postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); } if (node.querySelectorAll) { - node.querySelectorAll("video, source").forEach(inspectMedia); + node.querySelectorAll("video, source, track").forEach(inspectMedia); + } + if (node instanceof HTMLVideoElement) { + inspectTextTracks(node); } }; @@ -337,10 +374,34 @@ final class DreamioWebViewController: UIViewController { }); } + const trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src"); + if (trackSrcDescriptor && trackSrcDescriptor.set) { + Object.defineProperty(HTMLTrackElement.prototype, "src", { + get: trackSrcDescriptor.get, + set(value) { + addSubtitleCandidate({ + url: value, + label: this.label || this.srclang || "External Subtitle", + language: this.srclang || "" + }); + return trackSrcDescriptor.set.call(this, value); + } + }); + } + const originalSetAttribute = Element.prototype.setAttribute; Element.prototype.setAttribute = function(name, value) { - if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) { - postCandidate(value, this); + if (String(name).toLowerCase() === "src") { + if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) { + postCandidate(value, this); + } + if (this instanceof HTMLTrackElement) { + addSubtitleCandidate({ + url: value, + label: this.label || this.srclang || "External Subtitle", + language: this.srclang || "" + }); + } } return originalSetAttribute.call(this, name, value); }; @@ -349,9 +410,13 @@ final class DreamioWebViewController: UIViewController { HTMLMediaElement.prototype.load = function() { inspectMedia(this); this.querySelectorAll("source").forEach(inspectMedia); + inspectTextTracks(this); return originalLoad.call(this); }; + document.addEventListener("addtrack", (event) => { + inspectTrack(event.track || event.target); + }, true); document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true); document.addEventListener("error", (event) => inspectMedia(event.target), true); new MutationObserver((mutations) => { @@ -365,7 +430,7 @@ final class DreamioWebViewController: UIViewController { childList: true, subtree: true, attributes: true, - attributeFilter: ["src"] + attributeFilter: ["src", "label", "srclang"] }); inspectMedia(document); diff --git a/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html index 033eb0d..2abc22c 100644 --- a/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html +++ b/docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html @@ -306,6 +306,90 @@

    Related issues or PRs: Beads issue dreamio-ppj.

    +
    +

    New Changes as of May 25, 2026 at 10:51 AM EDT

    +

    Summary of changes: Added browser text-track capture for OpenSubtitles V3 subtitles. The web bridge now inspects <track> elements, HTMLTrackElement.src assignments, setAttribute("src", ...) calls, video.textTracks, and late addtrack events.

    +

    Why this change was made: The device logs showed embedded subtitles were visible, but OpenSubtitles V3 options still never reached native playback. That means the external subtitle candidates were being missed before VLC, likely because Stremio attached them as browser text tracks rather than including them in the original stream candidate.

    +

    Code diffs:

    +
    Dreamio/DreamioWebViewController.swift
    -4+69
    198 unmodified lines
    199
    200
    201
    202
    203
    204
    102 unmodified lines
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    19 unmodified lines
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    2 unmodified lines
    349
    350
    351
    352
    353
    354
    355
    356
    357
    7 unmodified lines
    365
    366
    367
    368
    369
    370
    371
    198 unmodified lines
    postSubtitleCandidates([candidate]);
    };
    +
    const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
    if (afterCount > beforeCount) {
    return;
    102 unmodified lines
    if (!node) {
    return;
    }
    if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
    postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
    }
    if (node.querySelectorAll) {
    node.querySelectorAll("video, source").forEach(inspectMedia);
    }
    };
    +
    19 unmodified lines
    });
    }
    +
    const originalSetAttribute = Element.prototype.setAttribute;
    Element.prototype.setAttribute = function(name, value) {
    if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) {
    postCandidate(value, this);
    }
    return originalSetAttribute.call(this, name, value);
    };
    2 unmodified lines
    HTMLMediaElement.prototype.load = function() {
    inspectMedia(this);
    this.querySelectorAll("source").forEach(inspectMedia);
    return originalLoad.call(this);
    };
    +
    document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
    document.addEventListener("error", (event) => inspectMedia(event.target), true);
    new MutationObserver((mutations) => {
    7 unmodified lines
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["src"]
    });
    +
    inspectMedia(document);
    198 unmodified lines
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    102 unmodified lines
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    19 unmodified lines
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    2 unmodified lines
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    7 unmodified lines
    430
    431
    432
    433
    434
    435
    436
    198 unmodified lines
    postSubtitleCandidates([candidate]);
    };
    +
    const inspectTrack = (track) => {
    if (!track) {
    return;
    }
    if (track instanceof HTMLTrackElement) {
    addSubtitleCandidate({
    url: track.src || track.getAttribute("src") || "",
    label: track.label || track.srclang || "External Subtitle",
    language: track.srclang || ""
    });
    return;
    }
    const source = track.src || track.url || "";
    if (source) {
    addSubtitleCandidate({
    url: source,
    label: track.label || track.language || track.kind || "External Subtitle",
    language: track.language || ""
    });
    }
    };
    +
    const inspectTextTracks = (media) => {
    try {
    Array.from(media.textTracks || []).forEach(inspectTrack);
    } catch (_) {}
    try {
    media.querySelectorAll("track").forEach(inspectTrack);
    } catch (_) {}
    };
    +
    const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
    if (afterCount > beforeCount) {
    return;
    102 unmodified lines
    if (!node) {
    return;
    }
    if (node instanceof HTMLTrackElement) {
    inspectTrack(node);
    }
    if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
    postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
    }
    if (node.querySelectorAll) {
    node.querySelectorAll("video, source, track").forEach(inspectMedia);
    }
    if (node instanceof HTMLVideoElement) {
    inspectTextTracks(node);
    }
    };
    +
    19 unmodified lines
    });
    }
    +
    const trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src");
    if (trackSrcDescriptor && trackSrcDescriptor.set) {
    Object.defineProperty(HTMLTrackElement.prototype, "src", {
    get: trackSrcDescriptor.get,
    set(value) {
    addSubtitleCandidate({
    url: value,
    label: this.label || this.srclang || "External Subtitle",
    language: this.srclang || ""
    });
    return trackSrcDescriptor.set.call(this, value);
    }
    });
    }
    +
    const originalSetAttribute = Element.prototype.setAttribute;
    Element.prototype.setAttribute = function(name, value) {
    if (String(name).toLowerCase() === "src") {
    if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) {
    postCandidate(value, this);
    }
    if (this instanceof HTMLTrackElement) {
    addSubtitleCandidate({
    url: value,
    label: this.label || this.srclang || "External Subtitle",
    language: this.srclang || ""
    });
    }
    }
    return originalSetAttribute.call(this, name, value);
    };
    2 unmodified lines
    HTMLMediaElement.prototype.load = function() {
    inspectMedia(this);
    this.querySelectorAll("source").forEach(inspectMedia);
    inspectTextTracks(this);
    return originalLoad.call(this);
    };
    +
    document.addEventListener("addtrack", (event) => {
    inspectTrack(event.track || event.target);
    }, true);
    document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
    document.addEventListener("error", (event) => inspectMedia(event.target), true);
    new MutationObserver((mutations) => {
    7 unmodified lines
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["src", "label", "srclang"]
    });
    +
    inspectMedia(document);
    +

    Related issues or PRs: Beads issue dreamio-3xi.

    +
    + \ No newline at end of file From 87686d16e99680f617a1e7160cb55f81436e36cd Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:56:11 -0400 Subject: [PATCH 13/21] update xcode workspace ui state --- .../UserInterfaceState.xcuserstate | Bin 13273 -> 13916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index 0f24b903f1e5c78ed9279ebcd318d642fa4b4240..dfff5d4d0588b44f442decaf84b8c18bc04230ca 100644 GIT binary patch delta 4258 zcmZWs33wCb*3O*MY?DkDAZwE*lWmerQ`Qy`*`%_zAR^#`Kq;k0v@NA@6_k7fsK_GA zfFg=^14K}urR>Ne`@XMbwWXFSDB?v~@05c7eg2zyp7|zc&N*M+_dRFMkr6B57&eh5JrnnG)618w0M=nCDSJM@5_&78!F_lD58)BYkU~I226d=M12(|`MzA?1V+yv$ zcK9r|$8OjidtguOg?;cP?2iL*Fb=_?n1NY10<&=>=HNISkMH68I02{PG@Onzu;nK> z7w6#uEX75*7+2yd+=;tzH}1i`xDWT^0X&F@@ED%Lb9foAU@cxR-vr~7@^o8GH%oat z`gN^5t#<#FkHm%M1S+|Flevb{|3J2o9b~6KwIV$PDn%@_uHhcZQc9_N)P3p!^^g>j zB2r9BNXG_<@7#iDX4dFl(SoFg z%ibAdquCh+x%tOsj7%rf0}Xm9KWIP;4Cu%rvY0F(-;{y@jL--SWGVTUEF(+BN46;1 zAzmD559wXN%FIRJCd-S!L%tJp?M`v4Jx(3~4uaxddjhT?-;3AnFUr&E&ZmAXc8;Hh zJS2*T<9UoglK3Egmhb8Q&7h?$r5Kt+3$luo6+&XF1G`y5;AN$xG^2 zkUu7~U`&425QC@QtZgv&7(IGS)@TVj&$WY2^+2A5_Rs-3k_xhhtR?H}fjkFY9s^lV zHarHBA&#6lR)&Z2Vq%3Huo`<4a<8gI=Y})vlA3xoLr$) zi4W}^Jn6Pt@!i70VzsuI)_7%4KHjaqhO1&mC&uFKeFnTTC@W{wxCSFdJ#o`NS)VSG zyq)qoJ^u2Pl~-U;Ju9!mYh)|gRt&GhV6vV3DB7F@Vwtk;g~WR7v3esYWg$uTb{f@? z>S_3!Fh-X0EsTV>;BCl(T*!k_kPoAwfb1f>$sV$o>?8Zh0dkNWB8R_)v9d&X2i}Eo zFdp86_hACW$Pv<=93>aXMN%hFCV^@uP|XFZB`Lq*^iuj6FjJON1fP&&MKFsT7u_yL z@?1(;4D;Yqa)O*A=}&*l=de&_E`bH`1$;?Pl2hb#34A3>hC*_NWRtVvC|57nH?aJF ze7=)>R*>_OPo;R?#ab$04Xl+4b{(vT4X_b5!DjdYwup`0Z)&Q^Wl}?~lS`yZoZ@cN zal5R05&TH55bJT+1-qrFJrcrwul5+8l~WKM5zWf)-y>&u);r{?1oEeWsrgwMZ%1>F z$@MEjKfxAex&~ z3}-~bI+~n=^HBL}UPi$iQpHAdvPmuZSzO7wn_Pm+uSs!Tqi0BY|onW_Wuw zL!-nNHX?V4^(>muj25(_O{PaX#-jtBn1C*HOJN>#%DSRotn}_0Y7{6+ppe{qoT#xt z8Hsd)Q}AChA&0>RnJ`hHWaR!6jUGL&T}-Oqwkdh=k4V^}UL^9cUK8=5FJAeCe9`S6 z?C*fjNhN|Eu@iO{s5pU=3zVV+yI?9lFHlN>QVEnn{K{YIPpi+HE>LP>Jt3Pbn}r7OnQWcvk5FK!Ps z#bKB!-VU_GH!v#p<}6r@Z;EN0>)E#+_u|tOxmX}Qf-nz9VLpx)D6>FW1j;HlBLNELl>uJJ~K8 zf|HChakkWTI7^`1ML0*GSgG&gpTSS1#>3BWzCd{d%KuO4;1~Gy|8FZSlqF-aKzT{F zK>5VKgO1E4xa@ztmN$3>9($2)ue=z|exqPOH0xhap_buBS;{w9j;pZ(*Wg-QhwE{J zKm`RVBv4_2;sr_)Jt9y^0@bwqozO~3a~O}v^i+lHFFY!?n}_X}ZrqiDnd~;l2{f zBz$WmY#VSB{(xI?JMNH%{2pn*AHbTpxHw~6o4BEI`EgU?7R9ZPJ0eH9NuDTAmOmqZ zR^CD0NuC~)_m^kLGv!(GZ21CtiTr!{TKQ)AcKLq!8TmPRrTn6zvBIJ7DME@AMLR`% zMMp)dqN}32qNifAVx3~AVxQuG;*jFF;-uoV;;iDD;#b8@#qWwg6n7Q(6%Um%B`7;7 zbCq8ymnl~&%ap5?>y;aoo0VIX7gaKqT1Bg3T9rX%Q#DnksJf`qRDD!^RsB@MRXM76 zRFhR7s-~!>s-~;9srIQ(t149&RhL!Osv1?Ts!nxF^|#unPEd2|ZtC9Z9Q9=Nm+GbJ zmFhC}YV{iRX7v{JHuaC{o$4FvJLrp>!6VO-EyN4xLBm z(*^X$^lW-DT~4p2E9kZK4tg)WpFT(*qfgMM=reRRT|?K>b(;2?o|-gGZ_NvuV$BN8 zO3f-wxn`?on`XOahvul}gyxjytmeGtf~HRMhvvSP(xNs_%V-VSM%pG?R_oISv>`38 zjc8M}ZME&R?X|J4+Wy)>+Nd^9o3Aaj>xSqubeXy=UA8W&8yVBRt;^L-)J@iXsGFjjs++F+L^n$}M>kjZ zsjf)3PIpvSrMss$=vDOU%p773Q_(N^`aOn)zq* zpB9BhWuYxvi_T)OG_o|Ym}3^s60-1?h^48exuvBg+0xq5))KV{mRXkdmb+GmwT-o( zb(D32b+%Qs&a-}I{oMM6b)ofZYmv3Yy2iTBy1}~1`h#_=b-Q(^b+>h&^?>z~^_ERx zv)LlHPPTO0P}^ABB-?b`C$?F(IkvgBg|@G4MYa;#BHI$%K3nXf?KivG?zJb{lkCmx zE$prA?d=`yo$X!h&)d7%2ix=Q)9j1w<@VL~3j137G5aO^ReP1a#$Ic$i&w)qi-*ZlI66ZwcH0KQG zOy_K;=$sdGE_ZHqo^{?%&?h(&TnTJ~FCmZ+Oz4u3mJm%CneevDdGRpY94)wzCk-E{r#R=8De z+O2i#+y-|ex5;gF$Ga2UZg*4nAa|a7ygOFpUhh8azQvl@HtaKOH?}96#`a zHjf>{{)>H=9nVf-XR@|uD9+ijo zXgxZQ+Y|Eeo>b33&j`;1&uq_ko)w-|o^nrxXRT+wr_OWRbI0?y=U&XK@;3I`yzySA z*X8BB&AffQFL_6M-}8R#o#vh4o#~zL{oY&d-RnK(z2Lp%z2d#+i}M+L7N6Ib?Cao* z`f_}q`-**wd`o;weQSI_`gZzu`}X?w`wsdJ`zn1GeV2V#eO10|zMp;9{j}fgXZ=n6 zUHtw1+5V`1tUva?Kjs(wll)Wt)BQ93bNqAtOa0sYXZ%$ol4c5WxPhdaz2i*YBpGu*kLI@l(d5gZ$g1t$hS2!0ftAN(Tt zRj?>n5JfPVj#4QHTm*hz=P;fl$j(t59;Nb*N)#L?}1(VQ5;YIJ7LZ zJoJ5NV`xWccW7VeVCYEbY^XAHDRd=N6}lF>8CHfHg&pBgxJ5WQ+$P*E9P1G77VZ)5 z6;2QL3HJ@>hNp*Dg!hC`hR=l0hcAY&gntU(;bpv>SMeI2;q5%jdwD<4@kxAhz9pZ+ zx8XbUJ^6wBV16h+j33UALQC@8b9H`}hO= zA^r$|j6adXpW@FXDiTu?GZW_|Zch9);)?W+WJdBLlOvx+7DP%SDpR;!h~Hr)<{5D1|dOy^<1l+X;AW-ta!60#%8BS0X4 z2$&LZqZw08@1~d%T4D$>B?RKoLwgis+7l4+=lpMU&dfQZxij;9_q)1B7l)P&oYjYn z-O%?FGK?SsRG@|`P!+0y4ea2CNbms*JOm*IVxbPyg*a#kA44N(0?nW~^n%ZzH}rwN z&=2}U3JidOFbGm14MxCd2m=NQCcs3P1T*1Vm<6-pdsqUwuo70m8dwV(VGHblov;rM zhT#bO0%dR>F2F^&1i!*%_ziBsZMXw}!UKem9+^-TR25Z2PUJ#v#GwGdM z%14pQ&~mf_twgI(0a}gLp(3;u?M8c02|8SK20quQ63;B{S5VYR-_yt??wFbqi94rR zti->FG@?JzhZskAl|Do1YY{bx4~e=(XYAKW^F~~Rx8dy)Dd3h887&`kZed;#@pW3N(1H&1V8Ho!8D5T8 znP_COS2&jA{*&H)A&$~WCUl^-|=$mYl>v=*DS^jsy^8n#AtZn5nWfwzRINJHB;s?R2HubZgmO3jGfO~bXiu$9 z8R?_@WsFWw@i!jPn0V9PUo{@;6Y;t50o22r@aA0j5E|euc&prsNeVY660))Lr?(+} zN@}uk8-L@GZ_$6lTR=x5z5o)SCA5M>Xbo+kEwqF7&;jqjJMk{O8}Gq;@jhIP_u~Tv z(20nF&X5FMpeuBPWath(@Ijn_58)s2Px!J#>Likr$bdu!aS`M7ljgxN^i51da7hjf z!-wS#K2O|8Qj-hmFbW^RCvltip^Smg340!lg-jTSkK$wahdlU#h=cL?I39>k$hp2m zW-?6upEc8zHQ(S<%9_)%k@Y#|fDCgL7QcgeFdwpD0W5??uvl)&4mOnGGx!4j6_?^) zbt5B9)b*~amx81~B_bFt(B zc_>$>#UWCY110#k|4Z6YI8DSahhy*q9ETHd5>DYO_$vM#m*YS1wdF)C`~;=SbC!t3 zE%9}HLwRn>*SKjcT!B9nUBFfN9m?@7d>h}%gKKadZs0#9QZ12Mc}^g)Cft3KqI>u* zc9y|I_zygS$M8f+)KjQ{XHW^x;RU=@V6UN)XpTUp_>!;+KgLh-y|>$FBvOT|;wL}D zKZrpQNcD~wq{EN!{l7^0&)Z@}2IWX(#1H;v1)1Nlf*<~qgsS1!?^-D_k1!M85ykL6 z`*KpSCmV?vrBsjy(I^U6;Aglp4|$OfvG_TDA(0Wdh1^(Um+uF^VZ}G+MdO#)d4`y) z?4<06Vo`NegIFk$1b&TQ$vHy(K;oI4%PD_VE2@h=P=tr#P`pHv5(&Ae9`S=jBKfsY z12sa8t^ zP*@@zc%Vc&af`04QwC%trKXJaH+)PxU zwepBqF_C{OIJ6#Z=-#%|TgkrBQ7+m@bpGeW`y)4@%|v{5Hs;??Y(qQVBuViNQ7(^- zwd_Uv-_W}c6-#7DB4cD<^&a6}updsrIYlxz;V#@)KJu(bxn0`^-y)TdX0LcdXsvKdZ&7~dat@zeNuf^ zeNJ7bzM#IUE>~Yu-%#Ju2%0XMe`{n-mS&-5u_i~8r&*?1p()Ya&^*vQ)Px^tDzv25 zp^egpv~{%~XzObmXj^NOw7s=MwZpX|wCUOm?JDg??OyFcZHe}X_JsD7_D5~0_Pq9r z_L)wtGwW*T;&n;7p}I-B*}5#S#(*(g-Pq9B)Y!q;)tGGTVeDm0 zF%C4Q8iyE%8D|WsqgC zWvt~Z3${$KOtMV3%&>fGnQf6R;a!$nR=bt4`mEKhan=v4^{ox8O{`6=&8-R6R@P3| zWNSa`5ZmXrFKv@;vTdO)$F|nC)3)2T*H&yhU^`?xY&&E7+4hU=yzQdxSKDv4t9Fe& z#-3!y_Nn&i_PO@&?Mv+k?I-Lf?WgUR?04<=?GNpb>`&|!_DcH;`>U|S?65kjI&2Py z!{vx{&<@7ob98W|Ix-zOj;qcHr`OrQ+08k?InMcobG$R`l$;ZtlbutXQ=QYDOPtxx zTxY&>xpSqnz`4e`&biUK$$8j$-g(zWx@<1N73WHD^>PhyrMog*V_cc8uuF1HbWL_m zaTU1sxK6vmmt5CeH(a+|cU*T}PhHPk&s{HFuid2E>5g$XbN6-+a}Rf?xktLcaW8N$ zb}w<~xbxi0-22=|-PhbV+_xf2B2PtDM85V2o*AC?p52~(p8cLfp2MD_o(G;vT0+~)9Px>DHDk>D!D5_P|h^VilmP8dtl`|Sf$JiMc6Ujs|UWR4j znMO=gra6_y5 z##~@7G544XrqWy0%X{OzZM?m`UwXgtV($d+B=5hyUwe0W4|q$wN4&?p=e$?FH@vsJ zcf5DKPrR>vQ9i$~i7(OD)tBt+;p^oa=o{~w;LGym_zHb%ed~S4e5Jn2zH7dRYy?ZO z_1K2&Aa-P!&0xo{ne1eC4m+2f$7Zn$*~RPvJY03IHP@FL z!%g6_xjb$ew}M;66>{sijoc<~3wMeO|HzecXSs7+8Fzsz=dN=%x!YU?SINELUIo+v zbHEdb3U~u-;G@8pz{J3`z&C-Jfmwn1fdzp@fhB?Lz^TA1KFEK>H{%odR(xx|C*Paz z%lGF8@C*1{ejUGw-^%ac_wxJsgM0~pf!rBBgTvM#3o`pvA5Vy93ZBOL&Y>PT^ucDic`hu;tcUyakeOn z--+|Z1>z#{sQ4_JkM10eqt{1Y4Ov5cs7|OwC^^(KG$5278XKAzniZNIniHBE$_eF% uR)h*dg`u^foMAn1= From c59b318d9bae974de5b5c23ab7bd38e1f6fb80dc Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 11:09:53 -0400 Subject: [PATCH 14/21] quiet repeated vlc subtitle reapply logs --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 2 + Dreamio/VLCNativePlaybackBackend.swift | 15 +- ...6-05-25-throttle-vlc-subtitle-reapply.html | 240 ++++++++++++++++++ 4 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b5a0784..97e968d 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -23,3 +23,4 @@ {"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}} {"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}} {"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} +{"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 04ca83c..3726b5a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -16,6 +17,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-h5n","title":"Throttle VLC subtitle reapply during buffering","description":"VLC subtitle auto-selection currently reapplies the same subtitle track on every buffering state notification, producing noisy logs and unnecessary repeated player writes. Limit state-driven reapply to meaningful selection recovery or state transitions while preserving delayed retries after initial auto-selection.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:06:48Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:09:02Z","started_at":"2026-05-25T15:06:55Z","closed_at":"2026-05-25T15:09:02Z","close_reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-c1m","title":"Add captions selection proof logging","description":"Add DEBUG-only logs around the native captions menu and VLC subtitle selection path so subtitle tap actions prove whether the UI fires and whether VLC accepts the selected embedded track index.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:18:06Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:19:19Z","started_at":"2026-05-25T14:18:11Z","closed_at":"2026-05-25T14:19:19Z","close_reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index f23b8bc..4b77cb7 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -284,21 +284,30 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) { [0.3, 1.0, 2.0, 4.0].forEach { delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.reapplyAutoSelectedSubtitleTrackIfNeeded(reason: "delayed-\(String(format: "%.1f", delay))") + self?.reapplyAutoSelectedSubtitleTrackIfNeeded( + reason: "delayed-\(String(format: "%.1f", delay))", + shouldLogNoop: true + ) } } } - private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) { + private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String, shouldLogNoop: Bool = false) { guard !didUserSelectSubtitleTrack, let trackID = autoSelectedSubtitleTrackID, subtitleTracks.contains(where: { $0.id == trackID }) else { return } + let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex + guard selectedTrackID != trackID || shouldLogNoop else { + return + } + mediaPlayer.currentVideoSubTitleIndex = trackID #if DEBUG - print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)") + let action = selectedTrackID == trackID ? "confirm" : "recover" + print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)") #endif } #endif diff --git a/docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html b/docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html new file mode 100644 index 0000000..4c49374 --- /dev/null +++ b/docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html @@ -0,0 +1,240 @@ + + + + + + Throttle VLC Subtitle Reapply + + + +
    +
    +
    Dreamio turn document
    +

    Throttle VLC subtitle reapply

    +

    Reduced noisy VLC subtitle reapply behavior so repeated buffering notifications no longer keep writing the same already-selected subtitle track.

    +
    + May 25, 2026 + Issue dreamio-h5n + Native VLC playback +
    +
    + +
    +

    Summary

    +

    Dreamio was auto-selecting embedded VLC subtitle tracks correctly, but VLC buffering callbacks repeatedly reapplied the same track while it was already selected. The change keeps recovery behavior for real subtitle-selection drift and startup timing, while suppressing repeated no-op reapply writes from player state changes.

    +
    + +
    +

    Changes Made

    +
      +
    • Added a selected-track guard inside reapplyAutoSelectedSubtitleTrackIfNeeded.
    • +
    • Kept delayed startup reapply attempts visible by passing shouldLogNoop: true for the 0.3, 1.0, 2.0, and 4.0 second retries.
    • +
    • Changed debug logging to label reapply events as action=confirm for delayed no-op confirmations or action=recover when VLC had drifted away from the intended subtitle.
    • +
    +
    + +
    +

    Context

    +

    The diagnostic log showed VLC auto-selecting English (SDH), then repeatedly logging reapply subtitle during buffering even though selected=3 never changed. The external OpenSubtitles load failure was non-critical in that trace, and the working subtitle came from the MKV itself.

    +
    + +
    +

    Important Implementation Details

    +

    The VLC state delegate still calls the reapply helper during .buffering and .playing. The helper now checks mediaPlayer.currentVideoSubTitleIndex before writing. If the intended auto-selected track is already active, state-driven calls return without touching VLC or logging. Delayed startup retries intentionally keep their confirmation logging because those are bounded and useful for diagnosing timing-sensitive subtitle attachment.

    +
    + +
    +

    Relevant Diff Snippets

    +
    Dreamio/VLCNativePlaybackBackend.swift
    -3+12
    283 unmodified lines
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    283 unmodified lines
    private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
    [0.3, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    self?.reapplyAutoSelectedSubtitleTrackIfNeeded(reason: "delayed-\(String(format: "%.1f", delay))")
    }
    }
    }
    +
    private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) {
    guard !didUserSelectSubtitleTrack,
    let trackID = autoSelectedSubtitleTrackID,
    subtitleTracks.contains(where: { $0.id == trackID }) else {
    return
    }
    +
    mediaPlayer.currentVideoSubTitleIndex = trackID
    #if DEBUG
    print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    #endif
    }
    #endif
    283 unmodified lines
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    283 unmodified lines
    private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
    [0.3, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    self?.reapplyAutoSelectedSubtitleTrackIfNeeded(
    reason: "delayed-\(String(format: "%.1f", delay))",
    shouldLogNoop: true
    )
    }
    }
    }
    +
    private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String, shouldLogNoop: Bool = false) {
    guard !didUserSelectSubtitleTrack,
    let trackID = autoSelectedSubtitleTrackID,
    subtitleTracks.contains(where: { $0.id == trackID }) else {
    return
    }
    +
    let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
    guard selectedTrackID != trackID || shouldLogNoop else {
    return
    }
    +
    mediaPlayer.currentVideoSubTitleIndex = trackID
    #if DEBUG
    let action = selectedTrackID == trackID ? "confirm" : "recover"
    print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    #endif
    }
    #endif
    +
    + +
    +

    Expected Impact for End-Users

    +

    Playback should behave the same when the embedded subtitle is successfully auto-selected. Debug logs should become much quieter during buffering, making real subtitle failures easier to spot. If VLC drops the subtitle selection, Dreamio will still reapply the intended auto-selected track.

    +
    + +
    +

    Validation

    +
      +
    • Ran xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build.
    • +
    • The build succeeded. Xcode emitted the existing MobileVLCKit run-script output warning and AppIntents metadata skip warning, neither related to this change.
    • +
    +
    + +
    +

    Issues, Limitations, and Mitigations

    +
      +
    • This was validated with a simulator build, not a live Stremio playback session against the exact South Park stream from the provided log.
    • +
    • The OpenSubtitles external subtitle load failure is separate. This change addresses VLC reapply spam after embedded subtitle auto-selection, not remote subtitle download reliability.
    • +
    • Delayed retry logs remain by design, but they are bounded to four scheduled checks.
    • +
    +
    + +
    +

    Follow-up Work

    +

    No follow-up issue is required for this specific buffering log noise. A separate issue would be appropriate if external OpenSubtitles subtitle downloads still fail for streams without embedded subtitles.

    +
    +
    + + \ No newline at end of file From d3c55077637a5494baf92925056cb8de0b2c2729 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 11:33:15 -0400 Subject: [PATCH 15/21] fix stremio subtitle handoff to vlc --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 87 ++- Dreamio/NativePlaybackBackend.swift | 4 - Dreamio/StreamCandidate.swift | 67 +- Dreamio/StreamResolver.swift | 32 +- Dreamio/VLCNativePlaybackBackend.swift | 20 +- Tests/StreamResolverTests.swift | 122 +++- ...fix-stremio-external-subtitle-handoff.html | 659 ++++++++++++++++++ 9 files changed, 951 insertions(+), 42 deletions(-) create mode 100644 docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 97e968d..c5103ea 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -24,3 +24,4 @@ {"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}} {"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} {"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} +{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3726b5a..fbc4139 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 53d417f..b0c7ade 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -57,6 +57,8 @@ final class DreamioWebViewController: UIViewController { private var progressObservation: NSKeyValueObservation? private var userAgent: String? private var lastNativePlaybackURL: URL? + private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:] + private var currentNativePlaybackKey: URL? private weak var currentNativePlayer: NativePlayerViewController? private let streamResolver: StreamResolving = StremioStreamResolver() @@ -587,17 +589,33 @@ final class DreamioWebViewController: UIViewController { let duplicateKey = request.resolverURL ?? request.playbackURL if lastNativePlaybackURL == duplicateKey { + mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey) return } lastNativePlaybackURL = duplicateKey + currentNativePlaybackKey = duplicateKey + mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey) + let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey) #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")") + print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") #endif + let playbackRequest = NativePlaybackRequest( + playbackURL: request.playbackURL, + observedURL: request.observedURL, + resolverURL: request.resolverURL, + pageURL: request.pageURL, + userAgent: request.userAgent, + referer: request.referer, + headers: request.headers, + classification: request.classification, + subtitleCandidates: mergedSubtitleCandidates + ) + Task { [weak self] in - await self?.resolveAndPresentNativePlayback(request) + await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey) } } @@ -606,12 +624,17 @@ final class DreamioWebViewController: UIViewController { return } + let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL + if let streamKey { + mergeSubtitleCandidates(candidates, for: streamKey) + } + #if DEBUG - print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))") + print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))") #endif guard let currentNativePlayer else { #if DEBUG - print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player") + print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)") #endif return } @@ -623,9 +646,10 @@ final class DreamioWebViewController: UIViewController { } @MainActor - private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { + private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async { guard VLCNativePlaybackBackend.isAvailable else { lastNativePlaybackURL = nil + currentNativePlaybackKey = nil showNativePlaybackUnavailableAlert() return } @@ -644,25 +668,72 @@ final class DreamioWebViewController: UIViewController { referer: request.referer, headers: resolved.headers, classification: request.classification, - subtitleCandidates: request.subtitleCandidates + subtitleCandidates: subtitleCandidates(for: streamKey) ) let player = NativePlayerViewController(request: resolvedRequest) - currentNativePlayer = player player.onDismiss = { [weak self] in self?.lastNativePlaybackURL = nil + self?.currentNativePlaybackKey = nil self?.currentNativePlayer = nil + self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey) self?.cleanUpStremioPlayerAfterNativeDismiss() } - present(player, animated: true) + present(player, animated: true) { [weak self, weak player] in + guard let self, let player else { + return + } + self.currentNativePlayer = player + let lateBufferedCandidates = self.subtitleCandidates(for: streamKey) + let forwarded = player.addSubtitleCandidates(lateBufferedCandidates) +#if DEBUG + print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))") +#endif + } } catch { #if DEBUG print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")") #endif lastNativePlaybackURL = nil + currentNativePlaybackKey = nil + pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey) showNativePlaybackResolutionFailure(error) } } + private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) { + guard !candidates.isEmpty else { + return + } + + let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? [] + pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates) + } + + private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] { + pendingSubtitleCandidatesByStreamKey[streamKey] ?? [] + } + + private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] { + var orderedKeys: [String] = [] + var bestByURL: [String: SubtitleCandidate] = [:] + candidates.forEach { candidate in + let key = candidate.url.absoluteString + if bestByURL[key] == nil { + orderedKeys.append(key) + bestByURL[key] = candidate + } else if let current = bestByURL[key], + subtitleCandidateScore(candidate) > subtitleCandidateScore(current) { + bestByURL[key] = candidate + } + } + return orderedKeys.compactMap { bestByURL[$0] } + } + + private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int { + let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent + return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0) + } + private func showNativePlaybackResolutionFailure(_ error: Error) { let alert = UIAlertController( title: "Could not open stream", diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 55a0c06..0648eb0 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject { func stop() } -protocol SubtitleResolving { - func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? -} - enum NativePlaybackError: LocalizedError { case backendUnavailable case startupTimedOut diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 7b2f209..4ddc3c5 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -134,37 +134,58 @@ enum SubtitleCandidateParser { private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"] + private struct CandidateContext { + let label: String? + let language: String? - static func candidates(in payload: Any?) -> [SubtitleCandidate] { - var results: [SubtitleCandidate] = [] - collect(from: payload, into: &results) + func merged(with dictionary: [String: Any]) -> CandidateContext { + let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label + let language = (dictionary["lang"] as? String) + ?? (dictionary["language"] as? String) + ?? self.language + return CandidateContext(label: label, language: language) + } - var seen = Set() - return results.filter { candidate in - let key = candidate.url.absoluteString - guard !seen.contains(key) else { - return false - } - seen.insert(key) - return true + private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? { + fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty } } } - private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) { + static func candidates(in payload: Any?) -> [SubtitleCandidate] { + var results: [SubtitleCandidate] = [] + collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results) + + var orderedKeys: [String] = [] + var bestByURL: [String: SubtitleCandidate] = [:] + results.forEach { candidate in + let key = candidate.url.absoluteString + if bestByURL[key] == nil { + orderedKeys.append(key) + bestByURL[key] = candidate + } else if let current = bestByURL[key], + candidateScore(candidate) > candidateScore(current) { + bestByURL[key] = candidate + } + } + return orderedKeys.compactMap { bestByURL[$0] } + } + + private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) { switch value { case let dictionary as [String: Any]: - if let candidate = candidate(from: dictionary) { + let nextContext = context.merged(with: dictionary) + if let candidate = candidate(from: dictionary, context: nextContext) { results.append(candidate) } - orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) } + orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) } case let array as [Any]: - array.forEach { collect(from: $0, into: &results) } + array.forEach { collect(from: $0, context: context, into: &results) } case let string as String: if let url = subtitleURL(from: string) { - results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language)) } else { extractSubtitleURLs(from: string).forEach { url in - results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language)) } } default: @@ -172,7 +193,7 @@ enum SubtitleCandidateParser { } } - private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? { + private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? { guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else { return nil } @@ -181,11 +202,17 @@ enum SubtitleCandidateParser { let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String) return SubtitleCandidate( url: url, - label: label?.isEmpty == false ? label! : defaultLabel(for: url), - language: language + label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)), + language: language ?? context.language ) } + private static func candidateScore(_ candidate: SubtitleCandidate) -> Int { + let defaultLabel = defaultLabel(for: candidate.url) + let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel + return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0) + } + private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] { let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"] var visitedKeys = Set() diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index c342cfd..6aa7359 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -6,6 +6,10 @@ struct ResolvedNativeStream { let source: String } +protocol SubtitleResolving { + func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? +} + enum StreamResolverError: LocalizedError { case noResolverURL case httpStatus(Int) @@ -47,6 +51,9 @@ final class SubtitleResolver: SubtitleResolving { var request = URLRequest(url: candidate.url) request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept") + StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } do { let (data, response) = try await session.data(for: request) @@ -66,7 +73,12 @@ final class SubtitleResolver: SubtitleResolving { from: data, responseURL: response.url, original: candidate - ) + ).map { resolved in +#if DEBUG + print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))") +#endif + return resolved + } ?? Self.logRejected(candidate, responseURL: response.url, data: data) } catch { #if DEBUG print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") @@ -127,6 +139,24 @@ final class SubtitleResolver: SubtitleResolving { || lowercased.contains("/subtitle") || lowercased.contains("subtitle") } + + private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? { +#if DEBUG + let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none" + let bodyKind: String + if data.isEmpty { + bodyKind = "empty" + } else if (try? JSONSerialization.jsonObject(with: data)) != nil { + bodyKind = "json-without-direct-subtitle" + } else if String(data: data, encoding: .utf8) != nil { + bodyKind = "text-without-direct-subtitle" + } else { + bodyKind = "unreadable" + } + print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)") +#endif + return nil + } } protocol StreamResolving { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 4b77cb7..2e9009f 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -246,12 +246,14 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { guard attachedCount > 0 else { return attachedCount } - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh") - #if DEBUG - self?.logSubtitleTracks(reason: "delayed-refresh") - #endif - self?.onSubtitleTracksChange?() + [0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))") +#if DEBUG + self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))") +#endif + self?.onSubtitleTracksChange?() + } } return attachedCount } @@ -300,11 +302,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex - guard selectedTrackID != trackID || shouldLogNoop else { + guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else { return } - mediaPlayer.currentVideoSubTitleIndex = trackID + if selectedTrackID < 0 { + mediaPlayer.currentVideoSubTitleIndex = trackID + } #if DEBUG let action = selectedTrackID == trackID ? "confirm" : "recover" print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)") diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c14ddc8..2bab41b 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -2,7 +2,7 @@ import Foundation @main struct StreamResolverTests { - static func main() { + static func main() async { testClassifierPrefersObservedDirectFile() testResolverSelectsUnsupportedDirectURLAndHeaders() testResolverRejectsHLSOnlyResponse() @@ -11,7 +11,11 @@ struct StreamResolverTests { testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() testOpenSubtitlesV3DownloadResponseResolution() + await testSubtitleResolverDownloadJSONReturningLink() + await testSubtitleResolverRedirectToDirectSubtitle() + await testSubtitleResolverRejectsNonSubtitleAPIResponse() testSubtitleCandidateDeduplicationPreservesLabels() + testSubtitleCandidateDeduplicationUpgradesLabels() testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") } @@ -143,6 +147,8 @@ struct StreamResolverTests { 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[1].label, "English") + assertEqual(candidates[1].language, "English") assertEqual(candidates[2].label, "spa") assertEqual(candidates[2].language, "spa") assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles") @@ -173,6 +179,62 @@ struct StreamResolverTests { assertEqual(candidate?.language, "eng") } + private static func testSubtitleResolverDownloadJSONReturningLink() async { + MockURLProtocol.handlers = [ + "https://api.opensubtitles.com/api/v1/download/123": ( + 200, + URL(string: "https://api.opensubtitles.com/api/v1/download/123")!, + #"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)! + ) + ] + let resolver = SubtitleResolver(session: mockSession()) + let candidate = await resolver.resolve(SubtitleCandidate( + url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!, + label: "English", + language: "eng" + )) + + assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret") + assertEqual(candidate?.label, "English") + assertEqual(candidate?.language, "eng") + } + + private static func testSubtitleResolverRedirectToDirectSubtitle() async { + MockURLProtocol.handlers = [ + "https://api.opensubtitles.com/api/v1/download/redirect": ( + 200, + URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!, + Data() + ) + ] + let resolver = SubtitleResolver(session: mockSession()) + let candidate = await resolver.resolve(SubtitleCandidate( + url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!, + label: "English", + language: "eng" + )) + + assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1") + } + + private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async { + MockURLProtocol.handlers = [ + "https://api.opensubtitles.com/api/v1/download/not-found": ( + 200, + URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!, + #"{"message":"not found"}"#.data(using: .utf8)! + ) + ] + let resolver = SubtitleResolver(session: mockSession()) + let candidate = await resolver.resolve(SubtitleCandidate( + url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!, + label: "English", + language: "eng" + )) + + assert(candidate == nil, "Expected non-subtitle API response to be rejected") + } + private static func testSubtitleCandidateDeduplicationPreservesLabels() { let payload: [String: Any] = [ "subtitles": [ @@ -197,6 +259,25 @@ struct StreamResolverTests { assertEqual(candidates[0].language, "eng") } + private static func testSubtitleCandidateDeduplicationUpgradesLabels() { + let payload: [String: Any] = [ + "subtitles": [ + "https://opensubtitles.example.test/download/duplicate.srt", + [ + "label": "English SDH", + "lang": "eng", + "url": "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"), @@ -210,4 +291,43 @@ struct StreamResolverTests { private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) } + + private static func mockSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } +} + +private final class MockURLProtocol: URLProtocol { + static var handlers: [String: (status: Int, url: URL, data: Data)] = [:] + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url, + let handler = Self.handlers[url.absoluteString], + let response = HTTPURLResponse( + url: handler.url, + statusCode: handler.status, + httpVersion: "HTTP/1.1", + headerFields: nil + ) + else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: handler.data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} } diff --git a/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html b/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html new file mode 100644 index 0000000..a39b2ab --- /dev/null +++ b/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html @@ -0,0 +1,659 @@ + + + + + + Fix Stremio External Subtitle Handoff To VLC + + + +
    +
    +

    Dreamio turn document · May 25, 2026 · Beads issue dreamio-dow

    +

    Fix Stremio External Subtitle Handoff To VLC

    +

    Made external subtitles part of the native playback handoff instead of a side channel that could disappear before MobileVLCKit was ready.

    +
    +
    PipelineBuffered subtitle candidates by active stream key and forwarded them after native player presentation.
    +
    ParserPreserved OpenSubtitles labels, languages, nested direct files, and stronger duplicate metadata.
    +
    ValidationSwift parser/resolver tests and iOS simulator build passed.
    +
    +
    + +
    +

    Summary

    +

    Dreamio now keeps Stremio and OpenSubtitlesV3 subtitle discoveries tied to the active native playback stream. Candidates found before presentation, inside the stream candidate message, and during playback are merged, deduped, resolved when needed, and attached to VLC as external subtitle slaves.

    +
    + +
    +

    Changes Made

    +
      +
    • Added a stream-keyed subtitle candidate buffer in DreamioWebViewController.
    • +
    • Merged subtitle candidates from the stream message with candidates found before and after native player presentation.
    • +
    • Forwarded buffered candidates after presentation completion so the native player has run viewDidLoad before late additions are resolved.
    • +
    • Moved SubtitleResolving beside the Foundation-based resolver so parser and resolver tests compile without UIKit.
    • +
    • Updated SubtitleCandidateParser so nested file URLs inherit parent label and language metadata.
    • +
    • Changed subtitle deduplication to keep the best metadata for each resolved URL instead of always keeping the first observation.
    • +
    • Added default Stremio-style headers to subtitle resolution requests and clearer debug logging for rejected API payloads.
    • +
    • Scheduled multiple VLC subtitle track refreshes after external subtitle attachment and limited auto-reapply to VLC resetting back to “None”.
    • +
    • Expanded the StreamResolver test harness with OpenSubtitlesV3 parser and resolver coverage.
    • +
    +
    + +
    +

    Context

    +

    Stremio can surface external subtitles from several places: the native stream candidate, OpenSubtitlesV3 API responses, nested file records, and late web requests while the player is opening. Before this change, discoveries that arrived before currentNativePlayer existed were logged but dropped. That meant VLC could successfully open the video while Dreamio’s captions menu never received the corresponding external subtitle track.

    +
    + +
    +

    Important Implementation Details

    +
      +
    • The active stream key is the resolver URL when available, otherwise the observed playback URL. This matches the duplicate native playback guard.
    • +
    • The pending buffer is cleared on native player dismissal or resolver failure so subtitles from one stream do not leak into the next stream.
    • +
    • The parser still accepts broad OpenSubtitles and subtitle-looking URLs, but direct playback attachment remains gated by SubtitleResolver.isDirectSubtitleFile.
    • +
    • VLC auto-selection still happens only when the user has not manually selected a subtitle track. After a manual selection, the backend leaves VLC’s selected track alone.
    • +
    • Auto-reapply now recovers the saved track only when VLC falls back to a negative “None” track, avoiding accidental overrides of another real track.
    • +
    +
    + +
    +

    Relevant Diff Snippets

    +

    Dreamio/DreamioWebViewController.swift

    Dreamio/DreamioWebViewController.swift
    -8+79
    56 unmodified lines
    57
    58
    59
    60
    61
    62
    524 unmodified lines
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    2 unmodified lines
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    5 unmodified lines
    623
    624
    625
    626
    627
    628
    629
    630
    631
    12 unmodified lines
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    56 unmodified lines
    private var progressObservation: NSKeyValueObservation?
    private var userAgent: String?
    private var lastNativePlaybackURL: URL?
    private weak var currentNativePlayer: NativePlayerViewController?
    private let streamResolver: StreamResolving = StremioStreamResolver()
    +
    524 unmodified lines
    +
    let duplicateKey = request.resolverURL ?? request.playbackURL
    if lastNativePlaybackURL == duplicateKey {
    return
    }
    lastNativePlaybackURL = duplicateKey
    +
    #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
    await self?.resolveAndPresentNativePlayback(request)
    }
    }
    +
    2 unmodified lines
    return
    }
    +
    #if DEBUG
    print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
    #endif
    guard let currentNativePlayer else {
    #if DEBUG
    print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
    #endif
    return
    }
    5 unmodified lines
    }
    +
    @MainActor
    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
    guard VLCNativePlaybackBackend.isAvailable else {
    lastNativePlaybackURL = nil
    showNativePlaybackUnavailableAlert()
    return
    }
    12 unmodified lines
    referer: request.referer,
    headers: resolved.headers,
    classification: request.classification,
    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)
    } catch {
    #if DEBUG
    print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
    #endif
    lastNativePlaybackURL = nil
    showNativePlaybackResolutionFailure(error)
    }
    }
    +
    private func showNativePlaybackResolutionFailure(_ error: Error) {
    let alert = UIAlertController(
    title: "Could not open stream",
    56 unmodified lines
    57
    58
    59
    60
    61
    62
    63
    64
    524 unmodified lines
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    2 unmodified lines
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    5 unmodified lines
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    12 unmodified lines
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    701
    702
    703
    704
    705
    706
    707
    708
    709
    710
    711
    712
    713
    714
    715
    716
    717
    718
    719
    720
    721
    722
    723
    724
    725
    726
    727
    728
    729
    730
    731
    732
    733
    734
    735
    736
    737
    738
    739
    56 unmodified lines
    private var progressObservation: NSKeyValueObservation?
    private var userAgent: String?
    private var lastNativePlaybackURL: URL?
    private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
    private var currentNativePlaybackKey: URL?
    private weak var currentNativePlayer: NativePlayerViewController?
    private let streamResolver: StreamResolving = StremioStreamResolver()
    +
    524 unmodified lines
    +
    let duplicateKey = request.resolverURL ?? request.playbackURL
    if lastNativePlaybackURL == duplicateKey {
    mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
    return
    }
    lastNativePlaybackURL = duplicateKey
    currentNativePlaybackKey = duplicateKey
    mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
    let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
    +
    #if DEBUG
    let classification = request.classification
    print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
    #endif
    +
    let playbackRequest = NativePlaybackRequest(
    playbackURL: request.playbackURL,
    observedURL: request.observedURL,
    resolverURL: request.resolverURL,
    pageURL: request.pageURL,
    userAgent: request.userAgent,
    referer: request.referer,
    headers: request.headers,
    classification: request.classification,
    subtitleCandidates: mergedSubtitleCandidates
    )
    +
    Task { [weak self] in
    await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
    }
    }
    +
    2 unmodified lines
    return
    }
    +
    let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
    if let streamKey {
    mergeSubtitleCandidates(candidates, for: streamKey)
    }
    +
    #if DEBUG
    print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
    #endif
    guard let currentNativePlayer else {
    #if DEBUG
    print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
    #endif
    return
    }
    5 unmodified lines
    }
    +
    @MainActor
    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
    guard VLCNativePlaybackBackend.isAvailable else {
    lastNativePlaybackURL = nil
    currentNativePlaybackKey = nil
    showNativePlaybackUnavailableAlert()
    return
    }
    12 unmodified lines
    referer: request.referer,
    headers: resolved.headers,
    classification: request.classification,
    subtitleCandidates: subtitleCandidates(for: streamKey)
    )
    let player = NativePlayerViewController(request: resolvedRequest)
    player.onDismiss = { [weak self] in
    self?.lastNativePlaybackURL = nil
    self?.currentNativePlaybackKey = nil
    self?.currentNativePlayer = nil
    self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
    self?.cleanUpStremioPlayerAfterNativeDismiss()
    }
    present(player, animated: true) { [weak self, weak player] in
    guard let self, let player else {
    return
    }
    self.currentNativePlayer = player
    let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
    let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
    #if DEBUG
    print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
    #endif
    }
    } catch {
    #if DEBUG
    print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
    #endif
    lastNativePlaybackURL = nil
    currentNativePlaybackKey = nil
    pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
    showNativePlaybackResolutionFailure(error)
    }
    }
    +
    private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
    guard !candidates.isEmpty else {
    return
    }
    +
    let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
    pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
    }
    +
    private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
    pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
    }
    +
    private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
    var orderedKeys: [String] = []
    var bestByURL: [String: SubtitleCandidate] = [:]
    candidates.forEach { candidate in
    let key = candidate.url.absoluteString
    if bestByURL[key] == nil {
    orderedKeys.append(key)
    bestByURL[key] = candidate
    } else if let current = bestByURL[key],
    subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
    bestByURL[key] = candidate
    }
    }
    return orderedKeys.compactMap { bestByURL[$0] }
    }
    +
    private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
    let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
    return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
    }
    +
    private func showNativePlaybackResolutionFailure(_ error: Error) {
    let alert = UIAlertController(
    title: "Could not open stream",

    Dreamio/NativePlaybackBackend.swift

    Dreamio/NativePlaybackBackend.swift
    -4
    29 unmodified lines
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    29 unmodified lines
    func stop()
    }
    +
    protocol SubtitleResolving {
    func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
    }
    +
    enum NativePlaybackError: LocalizedError {
    case backendUnavailable
    case startupTimedOut
    29 unmodified lines
    30
    31
    32
    33
    34
    35
    29 unmodified lines
    func stop()
    }
    +
    enum NativePlaybackError: LocalizedError {
    case backendUnavailable
    case startupTimedOut

    Dreamio/StreamCandidate.swift

    Dreamio/StreamCandidate.swift
    -16+43
    133 unmodified lines
    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
    1 unmodified line
    172
    173
    174
    175
    176
    177
    178
    2 unmodified lines
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    133 unmodified lines
    private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
    private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
    private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
    +
    static func candidates(in payload: Any?) -> [SubtitleCandidate] {
    var results: [SubtitleCandidate] = []
    collect(from: payload, into: &results)
    +
    var seen = Set<String>()
    return results.filter { candidate in
    let key = candidate.url.absoluteString
    guard !seen.contains(key) else {
    return false
    }
    seen.insert(key)
    return true
    }
    }
    +
    private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
    switch value {
    case let dictionary as [String: Any]:
    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:
    if let url = subtitleURL(from: string) {
    results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
    } else {
    extractSubtitleURLs(from: string).forEach { url in
    results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
    }
    }
    default:
    1 unmodified line
    }
    }
    +
    private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {
    guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
    return nil
    }
    2 unmodified lines
    let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
    return SubtitleCandidate(
    url: url,
    label: label?.isEmpty == false ? label! : defaultLabel(for: url),
    language: language
    )
    }
    +
    private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
    let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
    var visitedKeys = Set<String>()
    133 unmodified lines
    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
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    1 unmodified line
    193
    194
    195
    196
    197
    198
    199
    2 unmodified lines
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    133 unmodified lines
    private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
    private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
    private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
    private struct CandidateContext {
    let label: String?
    let language: String?
    +
    func merged(with dictionary: [String: Any]) -> CandidateContext {
    let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
    let language = (dictionary["lang"] as? String)
    ?? (dictionary["language"] as? String)
    ?? self.language
    return CandidateContext(label: label, language: language)
    }
    +
    private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
    fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
    }
    }
    +
    static func candidates(in payload: Any?) -> [SubtitleCandidate] {
    var results: [SubtitleCandidate] = []
    collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
    +
    var orderedKeys: [String] = []
    var bestByURL: [String: SubtitleCandidate] = [:]
    results.forEach { candidate in
    let key = candidate.url.absoluteString
    if bestByURL[key] == nil {
    orderedKeys.append(key)
    bestByURL[key] = candidate
    } else if let current = bestByURL[key],
    candidateScore(candidate) > candidateScore(current) {
    bestByURL[key] = candidate
    }
    }
    return orderedKeys.compactMap { bestByURL[$0] }
    }
    +
    private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {
    switch value {
    case let dictionary as [String: Any]:
    let nextContext = context.merged(with: dictionary)
    if let candidate = candidate(from: dictionary, context: nextContext) {
    results.append(candidate)
    }
    orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }
    case let array as [Any]:
    array.forEach { collect(from: $0, context: context, into: &results) }
    case let string as String:
    if let url = subtitleURL(from: string) {
    results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
    } else {
    extractSubtitleURLs(from: string).forEach { url in
    results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
    }
    }
    default:
    1 unmodified line
    }
    }
    +
    private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
    guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
    return nil
    }
    2 unmodified lines
    let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
    return SubtitleCandidate(
    url: url,
    label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
    language: language ?? context.language
    )
    }
    +
    private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {
    let defaultLabel = defaultLabel(for: candidate.url)
    let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel
    return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
    }
    +
    private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
    let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
    var visitedKeys = Set<String>()

    Dreamio/StreamResolver.swift

    Dreamio/StreamResolver.swift
    -1+31
    5 unmodified lines
    6
    7
    8
    9
    10
    11
    35 unmodified lines
    47
    48
    49
    50
    51
    52
    13 unmodified lines
    66
    67
    68
    69
    70
    71
    72
    54 unmodified lines
    127
    128
    129
    130
    131
    132
    5 unmodified lines
    let source: String
    }
    +
    enum StreamResolverError: LocalizedError {
    case noResolverURL
    case httpStatus(Int)
    35 unmodified lines
    +
    var request = URLRequest(url: candidate.url)
    request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
    +
    do {
    let (data, response) = try await session.data(for: request)
    13 unmodified lines
    from: data,
    responseURL: response.url,
    original: candidate
    )
    } catch {
    #if DEBUG
    print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
    54 unmodified lines
    || lowercased.contains("/subtitle")
    || lowercased.contains("subtitle")
    }
    }
    +
    protocol StreamResolving {
    5 unmodified lines
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    35 unmodified lines
    51
    52
    53
    54
    55
    56
    57
    58
    59
    13 unmodified lines
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    54 unmodified lines
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    5 unmodified lines
    let source: String
    }
    +
    protocol SubtitleResolving {
    func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
    }
    +
    enum StreamResolverError: LocalizedError {
    case noResolverURL
    case httpStatus(Int)
    35 unmodified lines
    +
    var request = URLRequest(url: candidate.url)
    request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
    StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in
    request.setValue(value, forHTTPHeaderField: key)
    }
    +
    do {
    let (data, response) = try await session.data(for: request)
    13 unmodified lines
    from: data,
    responseURL: response.url,
    original: candidate
    ).map { resolved in
    #if DEBUG
    print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")
    #endif
    return resolved
    } ?? Self.logRejected(candidate, responseURL: response.url, data: data)
    } catch {
    #if DEBUG
    print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
    54 unmodified lines
    || lowercased.contains("/subtitle")
    || lowercased.contains("subtitle")
    }
    +
    private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
    #if DEBUG
    let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
    let bodyKind: String
    if data.isEmpty {
    bodyKind = "empty"
    } else if (try? JSONSerialization.jsonObject(with: data)) != nil {
    bodyKind = "json-without-direct-subtitle"
    } else if String(data: data, encoding: .utf8) != nil {
    bodyKind = "text-without-direct-subtitle"
    } else {
    bodyKind = "unreadable"
    }
    print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
    #endif
    return nil
    }
    }
    +
    protocol StreamResolving {

    Dreamio/VLCNativePlaybackBackend.swift

    Dreamio/VLCNativePlaybackBackend.swift
    -8+12
    245 unmodified lines
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    42 unmodified lines
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    245 unmodified lines
    guard attachedCount > 0 else {
    return attachedCount
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
    self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh")
    #endif
    self?.onSubtitleTracksChange?()
    }
    return attachedCount
    }
    42 unmodified lines
    }
    +
    let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
    guard selectedTrackID != trackID || shouldLogNoop else {
    return
    }
    +
    mediaPlayer.currentVideoSubTitleIndex = trackID
    #if DEBUG
    let action = selectedTrackID == trackID ? "confirm" : "recover"
    print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
    245 unmodified lines
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    42 unmodified lines
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    245 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?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
    #endif
    self?.onSubtitleTracksChange?()
    }
    }
    return attachedCount
    }
    42 unmodified lines
    }
    +
    let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
    guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
    return
    }
    +
    if selectedTrackID < 0 {
    mediaPlayer.currentVideoSubTitleIndex = trackID
    }
    #if DEBUG
    let action = selectedTrackID == trackID ? "confirm" : "recover"
    print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")

    Tests/StreamResolverTests.swift

    Tests/StreamResolverTests.swift
    -1+121
    1 unmodified line
    2
    3
    4
    5
    6
    7
    8
    2 unmodified lines
    11
    12
    13
    14
    15
    16
    17
    125 unmodified lines
    143
    144
    145
    146
    147
    148
    24 unmodified lines
    173
    174
    175
    176
    177
    178
    18 unmodified lines
    197
    198
    199
    200
    201
    202
    7 unmodified lines
    210
    211
    212
    213
    1 unmodified line
    +
    @main
    struct StreamResolverTests {
    static func main() {
    testClassifierPrefersObservedDirectFile()
    testResolverSelectsUnsupportedDirectURLAndHeaders()
    testResolverRejectsHLSOnlyResponse()
    2 unmodified lines
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesV3DownloadResponseResolution()
    testSubtitleCandidateDeduplicationPreservesLabels()
    testSubtitleOptionMappingIncludesNone()
    print("StreamResolverTests passed")
    }
    125 unmodified lines
    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")
    24 unmodified lines
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testSubtitleCandidateDeduplicationPreservesLabels() {
    let payload: [String: Any] = [
    "subtitles": [
    18 unmodified lines
    assertEqual(candidates[0].language, "eng")
    }
    +
    private static func testSubtitleOptionMappingIncludesNone() {
    let options = SubtitleOptionMapper.options(from: [
    SubtitleTrack(id: 2, name: "English"),
    7 unmodified lines
    private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
    assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
    }
    }
    1 unmodified line
    2
    3
    4
    5
    6
    7
    8
    2 unmodified lines
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    125 unmodified lines
    147
    148
    149
    150
    151
    152
    153
    154
    24 unmodified lines
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    18 unmodified lines
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    7 unmodified lines
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    1 unmodified line
    +
    @main
    struct StreamResolverTests {
    static func main() async {
    testClassifierPrefersObservedDirectFile()
    testResolverSelectsUnsupportedDirectURLAndHeaders()
    testResolverRejectsHLSOnlyResponse()
    2 unmodified lines
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesV3DownloadResponseResolution()
    await testSubtitleResolverDownloadJSONReturningLink()
    await testSubtitleResolverRedirectToDirectSubtitle()
    await testSubtitleResolverRejectsNonSubtitleAPIResponse()
    testSubtitleCandidateDeduplicationPreservesLabels()
    testSubtitleCandidateDeduplicationUpgradesLabels()
    testSubtitleOptionMappingIncludesNone()
    print("StreamResolverTests passed")
    }
    125 unmodified lines
    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[1].label, "English")
    assertEqual(candidates[1].language, "English")
    assertEqual(candidates[2].label, "spa")
    assertEqual(candidates[2].language, "spa")
    assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
    24 unmodified lines
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testSubtitleResolverDownloadJSONReturningLink() async {
    MockURLProtocol.handlers = [
    "https://api.opensubtitles.com/api/v1/download/123": (
    200,
    URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
    #"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)!
    )
    ]
    let resolver = SubtitleResolver(session: mockSession())
    let candidate = await resolver.resolve(SubtitleCandidate(
    url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
    label: "English",
    language: "eng"
    ))
    +
    assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret")
    assertEqual(candidate?.label, "English")
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testSubtitleResolverRedirectToDirectSubtitle() async {
    MockURLProtocol.handlers = [
    "https://api.opensubtitles.com/api/v1/download/redirect": (
    200,
    URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!,
    Data()
    )
    ]
    let resolver = SubtitleResolver(session: mockSession())
    let candidate = await resolver.resolve(SubtitleCandidate(
    url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!,
    label: "English",
    language: "eng"
    ))
    +
    assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1")
    }
    +
    private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
    MockURLProtocol.handlers = [
    "https://api.opensubtitles.com/api/v1/download/not-found": (
    200,
    URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
    #"{"message":"not found"}"#.data(using: .utf8)!
    )
    ]
    let resolver = SubtitleResolver(session: mockSession())
    let candidate = await resolver.resolve(SubtitleCandidate(
    url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
    label: "English",
    language: "eng"
    ))
    +
    assert(candidate == nil, "Expected non-subtitle API response to be rejected")
    }
    +
    private static func testSubtitleCandidateDeduplicationPreservesLabels() {
    let payload: [String: Any] = [
    "subtitles": [
    18 unmodified lines
    assertEqual(candidates[0].language, "eng")
    }
    +
    private static func testSubtitleCandidateDeduplicationUpgradesLabels() {
    let payload: [String: Any] = [
    "subtitles": [
    "https://opensubtitles.example.test/download/duplicate.srt",
    [
    "label": "English SDH",
    "lang": "eng",
    "url": "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"),
    7 unmodified lines
    private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
    assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
    }
    +
    private static func mockSession() -> URLSession {
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [MockURLProtocol.self]
    return URLSession(configuration: configuration)
    }
    }
    +
    private final class MockURLProtocol: URLProtocol {
    static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
    +
    override class func canInit(with request: URLRequest) -> Bool {
    true
    }
    +
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    request
    }
    +
    override func startLoading() {
    guard let url = request.url,
    let handler = Self.handlers[url.absoluteString],
    let response = HTTPURLResponse(
    url: handler.url,
    statusCode: handler.status,
    httpVersion: "HTTP/1.1",
    headerFields: nil
    )
    else {
    client?.urlProtocol(self, didFailWithError: URLError(.badURL))
    return
    }
    +
    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    client?.urlProtocol(self, didLoad: handler.data)
    client?.urlProtocolDidFinishLoading(self)
    }
    +
    override func stopLoading() {}
    }
    +
    + +
    +

    Expected Impact for End-Users

    +

    Users starting native playback from a Stremio stream with OpenSubtitlesV3 external subtitles should see the captions button become available once VLC exposes the subtitle track. Selecting “None” should turn captions off, selecting the external track should turn them back on, and opening a later stream should not inherit subtitle candidates from the previous playback session.

    +
    + +
    +

    Validation

    +
      +
    • Passed: swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests.
    • +
    • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
    • +
    • Not run manually: the full Stremio/OpenSubtitles/VLC device scenario still needs a real playback stream to confirm the exact runtime logs and captions menu behavior end to end.
    • +
    +
    + +
    +

    Issues, Limitations, and Mitigations

    +
    +

    MobileVLCKit exposes external subtitle tracks asynchronously, so the backend now schedules several refreshes after each attachment. This mitigates the common timing gap but does not replace real-device validation against the exact OpenSubtitles stream flow.

    +

    The Xcode build still reports the existing CocoaPods script warning that the MobileVLCKit prepare phase has no declared outputs. The build succeeds, and this change does not alter that script phase.

    +
    +
    + +
    +

    Follow-up Work

    +
      +
    • Run the manual validation scenario against a known OpenSubtitlesV3 stream on a device or simulator with working playback.
    • +
    • Consider a small injectable captions-menu or backend state test seam if future work needs direct unit coverage for UIKit menu refresh behavior.
    • +
    • Watch debug logs for API payloads rejected as json-without-direct-subtitle; those may reveal another OpenSubtitles response shape worth supporting.
    • +
    +
    +
    + + From f34d60af1b7efe838b60c72811cbeebbcf470f60 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 11:54:14 -0400 Subject: [PATCH 16/21] fix opensubtitles native captions --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 40 +- Dreamio/StreamCandidate.swift | 26 +- Dreamio/VLCNativePlaybackBackend.swift | 46 +- Tests/StreamResolverTests.swift | 73 +++ ...-25-fix-opensubtitles-native-captions.html | 495 ++++++++++++++++++ 7 files changed, 666 insertions(+), 16 deletions(-) create mode 100644 docs/turns/2026-05-25-fix-opensubtitles-native-captions.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c5103ea..c9116fb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -25,3 +25,4 @@ {"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} {"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} {"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}} +{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fbc4139..a13d943 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index b0c7ade..95d5e26 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -84,6 +84,19 @@ final class DreamioWebViewController: UIViewController { const postedSubtitleURLs = new Set(); const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i; + const subtitleObjectKeys = [ + "attributes", + "files", + "file_id", + "url", + "download", + "link", + "file", + "file_name", + "filename", + "language", + "lang" + ]; const looksNative = (url) => { if (!url || typeof url !== "string") { @@ -132,10 +145,14 @@ final class DreamioWebViewController: UIViewController { const postSubtitleCandidates = (candidates, debug = {}) => { const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { - if (postedSubtitleURLs.has(candidate.url)) { + const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id); + if (!key) { return false; } - postedSubtitleURLs.add(candidate.url); + if (postedSubtitleURLs.has(String(key))) { + return false; + } + postedSubtitleURLs.add(String(key)); return true; }); if (fresh.length === 0) { @@ -182,9 +199,12 @@ final class DreamioWebViewController: UIViewController { entry.fileUrl || entry.fileURL ); - const url = absoluteURL(rawURL); + let url = absoluteURL(rawURL); + if (!url && entry && entry.file_id) { + url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`; + } subtitleURLPattern.lastIndex = 0; - if (!url || !subtitleURLPattern.test(url)) { + if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) { subtitleURLPattern.lastIndex = 0; return; } @@ -198,7 +218,10 @@ final class DreamioWebViewController: UIViewController { language: entry && (entry.lang || entry.language) || "" }; subtitleCandidates.push(candidate); - postSubtitleCandidates([candidate]); + postSubtitleCandidates([candidate], { + discovered: 1, + totalKnown: subtitleCandidates.length + }); }; const inspectTrack = (track) => { @@ -264,6 +287,13 @@ final class DreamioWebViewController: UIViewController { } if (typeof payload === "object") { addSubtitleCandidate(payload); + const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key)); + if (likelySubtitlePayload) { + postSubtitleCandidates([payload], { + source: "payload-object", + totalKnown: subtitleCandidates.length + }); + } Object.values(payload).forEach(inspectSubtitlePayload); } }; diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 4ddc3c5..3e09d57 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -132,8 +132,8 @@ struct StreamCandidate { enum SubtitleCandidateParser { private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] - private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] - private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"] + private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"] + private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"] private struct CandidateContext { let label: String? let language: String? @@ -194,7 +194,9 @@ enum SubtitleCandidateParser { } private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? { - guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else { + guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first + ?? openSubtitlesDownloadURL(from: dictionary["file_id"]) + else { return nil } @@ -214,7 +216,7 @@ enum SubtitleCandidateParser { } private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] { - let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"] + let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"] var visitedKeys = Set() var values: [Any] = [] @@ -254,6 +256,22 @@ enum SubtitleCandidateParser { return url } + private static func openSubtitlesDownloadURL(from value: Any?) -> URL? { + let id: String? + if let string = value as? String, !string.isEmpty { + id = string + } else if let number = value as? NSNumber { + id = number.stringValue + } else { + id = nil + } + + guard let id else { + return nil + } + return URL(string: "https://api.opensubtitles.com/api/v1/download/\(id)") + } + private static func defaultLabel(for url: URL) -> String { let lastPathComponent = url.deletingPathExtension().lastPathComponent return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 2e9009f..166d59b 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -26,6 +26,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var didAutoSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false private var autoSelectedSubtitleTrackID: Int32? + private var externalSubtitleBaselineTrackIDs = Set() + private var hasPendingExternalSubtitleSelection = false override init() { super.init() @@ -47,6 +49,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { didAutoSelectSubtitleTrack = false didUserSelectSubtitleTrack = false autoSelectedSubtitleTrackID = nil + externalSubtitleBaselineTrackIDs.removeAll() + hasPendingExternalSubtitleSelection = false let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers .map { "\($0.key): \($0.value)" } @@ -225,22 +229,25 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int { var attachedCount = 0 var duplicateCount = 0 + let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id)) candidates.forEach { candidate in guard !attachedSubtitleURLs.contains(candidate.url) else { duplicateCount += 1 return } attachedSubtitleURLs.insert(candidate.url) + externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs) + hasPendingExternalSubtitleSelection = true mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) attachedCount += 1 #if DEBUG - print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())") + print("[DreamioVLC] attach accepted subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased()) visibleBefore=\(baselineTrackIDs.count)") logSubtitleTracks(reason: "after-addPlaybackSlave") #endif } #if DEBUG if !candidates.isEmpty { - print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)") + print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)") } #endif guard attachedCount > 0 else { @@ -248,9 +255,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } [0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))") + self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))") #if DEBUG self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))") + if delay == 4.0 { + self?.logMissingExternalSubtitleTrackIfNeeded() + } #endif self?.onSubtitleTracksChange?() } @@ -266,14 +276,27 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } #endif - private func selectInitialSubtitleTrackIfNeeded(reason: String) { - guard !didUserSelectSubtitleTrack, - !didAutoSelectSubtitleTrack, + private func selectPreferredSubtitleTrackIfNeeded(reason: String) { + guard !didUserSelectSubtitleTrack else { + return + } + + if hasPendingExternalSubtitleSelection, + let externalTrack = subtitleTracks.first(where: { $0.id >= 0 && !externalSubtitleBaselineTrackIDs.contains($0.id) }) { + selectAutoSubtitleTrack(externalTrack, reason: "\(reason)-external") + hasPendingExternalSubtitleSelection = false + return + } + + guard !didAutoSelectSubtitleTrack, mediaPlayer.currentVideoSubTitleIndex < 0, let track = subtitleTracks.first(where: { $0.id >= 0 }) else { return } + selectAutoSubtitleTrack(track, reason: reason) + } + private func selectAutoSubtitleTrack(_ track: SubtitleTrack, reason: String) { didAutoSelectSubtitleTrack = true autoSelectedSubtitleTrackID = track.id #if DEBUG @@ -283,6 +306,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { scheduleAutoSubtitleSelectionReapply(trackID: track.id) } +#if DEBUG + private func logMissingExternalSubtitleTrackIfNeeded() { + guard hasPendingExternalSubtitleSelection else { + return + } + print("[DreamioVLC] attach accepted but no new external subtitle track visible baseline=\(externalSubtitleBaselineTrackIDs.sorted()) visible=\(subtitleTracks.filter { $0.id >= 0 }.map(\.id))") + } +#endif + private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) { [0.3, 1.0, 2.0, 4.0].forEach { delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in @@ -333,7 +365,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { case .paused, .stopped, .ended: onStateChange?() case .esAdded: - selectInitialSubtitleTrackIfNeeded(reason: "esAdded") + selectPreferredSubtitleTrackIfNeeded(reason: "esAdded") #if DEBUG logSubtitleTracks(reason: "esAdded") #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 2bab41b..7137f48 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -10,7 +10,9 @@ struct StreamResolverTests { testPlaybackTimeFormatting() testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() + testOpenSubtitlesNestedAttributesFilesParsing() testOpenSubtitlesV3DownloadResponseResolution() + testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() await testSubtitleResolverRedirectToDirectSubtitle() await testSubtitleResolverRejectsNonSubtitleAPIResponse() @@ -154,6 +156,39 @@ struct StreamResolverTests { assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles") } + private static func testOpenSubtitlesNestedAttributesFilesParsing() { + let payload: [String: Any] = [ + "data": [ + [ + "attributes": [ + "language": "English", + "file_name": "episode.en.srt", + "files": [ + [ + "file_id": 12345, + "file_name": "nested.en.srt" + ], + [ + "link": "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret", + "language": "eng" + ] + ] + ] + ] + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 2) + assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/12345") + assertEqual(candidates[0].label, "nested.en.srt") + assertEqual(candidates[0].language, "English") + assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret") + assertEqual(candidates[1].label, "eng") + assertEqual(candidates[1].language, "eng") + } + private static func testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { @@ -179,6 +214,44 @@ struct StreamResolverTests { assertEqual(candidate?.language, "eng") } + private static func testOpenSubtitlesNestedDownloadResponseResolution() { + let payload = """ + { + "data": { + "attributes": { + "files": [ + { + "file_name": "ignored.txt", + "link": "https://cdn.example.test/ignored.txt" + }, + { + "file_name": "episode.en.ass", + "download": { + "link": "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret" + } + } + ] + } + } + } + """.data(using: .utf8)! + let original = SubtitleCandidate( + url: URL(string: "https://api.opensubtitles.com/api/v1/download/987")!, + label: "English SDH", + language: "eng" + ) + + let candidate = SubtitleResolver.bestPlayableCandidate( + from: payload, + responseURL: original.url, + original: original + ) + + assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret") + assertEqual(candidate?.label, "English SDH") + assertEqual(candidate?.language, "eng") + } + private static func testSubtitleResolverDownloadJSONReturningLink() async { MockURLProtocol.handlers = [ "https://api.opensubtitles.com/api/v1/download/123": ( diff --git a/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html b/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html new file mode 100644 index 0000000..0901e6b --- /dev/null +++ b/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html @@ -0,0 +1,495 @@ + + + + + + Fix OpenSubtitles Native Captions + + + +
    +
    +

    Dreamio turn document · 2026-05-25

    +

    Fix OpenSubtitles Native Captions

    +

    OpenSubtitles candidates now survive more Stremio and OpenSubtitles payload shapes, resolve through nested download responses, attach to VLC with clearer diagnostics, and get preferred when external tracks become visible after playback has already started.

    +
    + Beads: dreamio-hzj + Branch: lavender/opensubtitles + Native player captions +
    +
    + +
    +

    Summary

    +

    Hardened the external subtitle path so OpenSubtitles tracks are more likely to appear as selectable VLC caption tracks alongside embedded MKV subtitles. The change focuses on discovery, candidate parsing, resolver compatibility, VLC visibility timing, and debug output.

    +
    + +
    +

    Changes Made

    +
      +
    • Added Beads bug dreamio-hzj before implementation.
    • +
    • Expanded the web bridge to recognize subtitle objects with attributes, files, file_id, download, link, file_name, and language metadata.
    • +
    • Extended Swift subtitle candidate parsing for nested OpenSubtitles payloads and file_id download candidates.
    • +
    • Kept parent label and language metadata when nested subtitle URLs are selected during resolution.
    • +
    • Changed VLC attachment behavior so newly visible external tracks are preferred over an earlier automatic embedded-track selection when the user has not manually chosen a track.
    • +
    • Added focused tests for nested OpenSubtitles attributes/files payloads and nested API download responses.
    • +
    +
    + +
    +

    Context

    +

    The native captions menu was already able to show embedded VLC subtitle tracks, which narrowed the problem to external subtitle handoff. OpenSubtitles data can arrive as direct file URLs, API download URLs, nested file objects, or delayed network payloads after native playback has started. VLC also exposes subtitle slaves asynchronously, so the first menu refresh can happen before an external track exists.

    +
    + +
    +

    Important Implementation Details

    +
      +
    • The browser bridge now posts likely subtitle-shaped objects even when they are not immediately reduced to a direct URL. Swift performs the final recursive parsing.
    • +
    • SubtitleCandidateParser now walks attributes, data, and results early, which better matches OpenSubtitles API structures.
    • +
    • file_id values are converted into OpenSubtitles download API candidates so the existing resolver path can try to turn them into direct subtitle files.
    • +
    • VLC records the subtitle track IDs visible before external subtitle attachment. Delayed refreshes use that baseline to identify a newly visible external track.
    • +
    • If VLC accepts a subtitle slave but no new track appears by the final delayed refresh, the debug log now says so explicitly with baseline and visible track counts.
    • +
    +
    + +
    +

    Relevant Diff Snippets

    +

    Rendered with @pierre/diffs/ssr from the working tree diff.

    +
    +
    Dreamio/DreamioWebViewController.swift
    -5+35
    83 unmodified lines
    84
    85
    86
    87
    88
    89
    42 unmodified lines
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    40 unmodified lines
    182
    183
    184
    185
    186
    187
    188
    189
    190
    7 unmodified lines
    198
    199
    200
    201
    202
    203
    204
    59 unmodified lines
    264
    265
    266
    267
    268
    269
    83 unmodified lines
    const postedSubtitleURLs = new Set();
    const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
    const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
    +
    const looksNative = (url) => {
    if (!url || typeof url !== "string") {
    42 unmodified lines
    const postSubtitleCandidates = (candidates, debug = {}) => {
    const discoveredCount = candidates.length;
    const fresh = candidates.filter((candidate) => {
    if (postedSubtitleURLs.has(candidate.url)) {
    return false;
    }
    postedSubtitleURLs.add(candidate.url);
    return true;
    });
    if (fresh.length === 0) {
    40 unmodified lines
    entry.fileUrl ||
    entry.fileURL
    );
    const url = absoluteURL(rawURL);
    subtitleURLPattern.lastIndex = 0;
    if (!url || !subtitleURLPattern.test(url)) {
    subtitleURLPattern.lastIndex = 0;
    return;
    }
    7 unmodified lines
    language: entry && (entry.lang || entry.language) || ""
    };
    subtitleCandidates.push(candidate);
    postSubtitleCandidates([candidate]);
    };
    +
    const inspectTrack = (track) => {
    59 unmodified lines
    }
    if (typeof payload === "object") {
    addSubtitleCandidate(payload);
    Object.values(payload).forEach(inspectSubtitlePayload);
    }
    };
    83 unmodified lines
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    42 unmodified lines
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    40 unmodified lines
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    7 unmodified lines
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    59 unmodified lines
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    83 unmodified lines
    const postedSubtitleURLs = new Set();
    const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
    const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
    const subtitleObjectKeys = [
    "attributes",
    "files",
    "file_id",
    "url",
    "download",
    "link",
    "file",
    "file_name",
    "filename",
    "language",
    "lang"
    ];
    +
    const looksNative = (url) => {
    if (!url || typeof url !== "string") {
    42 unmodified lines
    const postSubtitleCandidates = (candidates, debug = {}) => {
    const discoveredCount = candidates.length;
    const fresh = candidates.filter((candidate) => {
    const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id);
    if (!key) {
    return false;
    }
    if (postedSubtitleURLs.has(String(key))) {
    return false;
    }
    postedSubtitleURLs.add(String(key));
    return true;
    });
    if (fresh.length === 0) {
    40 unmodified lines
    entry.fileUrl ||
    entry.fileURL
    );
    let url = absoluteURL(rawURL);
    if (!url && entry && entry.file_id) {
    url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
    }
    subtitleURLPattern.lastIndex = 0;
    if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) {
    subtitleURLPattern.lastIndex = 0;
    return;
    }
    7 unmodified lines
    language: entry && (entry.lang || entry.language) || ""
    };
    subtitleCandidates.push(candidate);
    postSubtitleCandidates([candidate], {
    discovered: 1,
    totalKnown: subtitleCandidates.length
    });
    };
    +
    const inspectTrack = (track) => {
    59 unmodified lines
    }
    if (typeof payload === "object") {
    addSubtitleCandidate(payload);
    const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
    if (likelySubtitlePayload) {
    postSubtitleCandidates([payload], {
    source: "payload-object",
    totalKnown: subtitleCandidates.length
    });
    }
    Object.values(payload).forEach(inspectSubtitlePayload);
    }
    };
    +
    Dreamio/StreamCandidate.swift
    -4+22
    131 unmodified lines
    132
    133
    134
    135
    136
    137
    138
    139
    54 unmodified lines
    194
    195
    196
    197
    198
    199
    200
    13 unmodified lines
    214
    215
    216
    217
    218
    219
    220
    33 unmodified lines
    254
    255
    256
    257
    258
    259
    131 unmodified lines
    +
    enum SubtitleCandidateParser {
    private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
    private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
    private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
    private struct CandidateContext {
    let label: String?
    let language: String?
    54 unmodified lines
    }
    +
    private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
    guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
    return nil
    }
    +
    13 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] = []
    +
    33 unmodified lines
    return url
    }
    +
    private static func defaultLabel(for url: URL) -> String {
    let lastPathComponent = url.deletingPathExtension().lastPathComponent
    return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent
    131 unmodified lines
    132
    133
    134
    135
    136
    137
    138
    139
    54 unmodified lines
    194
    195
    196
    197
    198
    199
    200
    201
    202
    13 unmodified lines
    216
    217
    218
    219
    220
    221
    222
    33 unmodified lines
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    131 unmodified lines
    +
    enum SubtitleCandidateParser {
    private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
    private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
    private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
    private struct CandidateContext {
    let label: String?
    let language: String?
    54 unmodified lines
    }
    +
    private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
    guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first
    ?? openSubtitlesDownloadURL(from: dictionary["file_id"])
    else {
    return nil
    }
    +
    13 unmodified lines
    }
    +
    private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
    let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"]
    var visitedKeys = Set<String>()
    var values: [Any] = []
    +
    33 unmodified lines
    return url
    }
    +
    private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
    let id: String?
    if let string = value as? String, !string.isEmpty {
    id = string
    } else if let number = value as? NSNumber {
    id = number.stringValue
    } else {
    id = nil
    }
    +
    guard let id else {
    return nil
    }
    return URL(string: "https://api.opensubtitles.com/api/v1/download/\(id)")
    }
    +
    private static func defaultLabel(for url: URL) -> String {
    let lastPathComponent = url.deletingPathExtension().lastPathComponent
    return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent
    +
    Dreamio/VLCNativePlaybackBackend.swift
    -7+39
    25 unmodified lines
    26
    27
    28
    29
    30
    31
    15 unmodified lines
    47
    48
    49
    50
    51
    52
    172 unmodified lines
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    1 unmodified line
    248
    249
    250
    251
    252
    253
    254
    255
    256
    9 unmodified lines
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    3 unmodified lines
    283
    284
    285
    286
    287
    288
    44 unmodified lines
    333
    334
    335
    336
    337
    338
    339
    25 unmodified lines
    private var didAutoSelectSubtitleTrack = false
    private var didUserSelectSubtitleTrack = false
    private var autoSelectedSubtitleTrackID: Int32?
    +
    override init() {
    super.init()
    15 unmodified lines
    didAutoSelectSubtitleTrack = false
    didUserSelectSubtitleTrack = false
    autoSelectedSubtitleTrackID = nil
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    172 unmodified lines
    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] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")
    logSubtitleTracks(reason: "after-addPlaybackSlave")
    #endif
    }
    #if DEBUG
    if !candidates.isEmpty {
    print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
    }
    #endif
    guard attachedCount > 0 else {
    1 unmodified line
    }
    [0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
    #endif
    self?.onSubtitleTracksChange?()
    }
    9 unmodified lines
    }
    #endif
    +
    private func selectInitialSubtitleTrackIfNeeded(reason: String) {
    guard !didUserSelectSubtitleTrack,
    !didAutoSelectSubtitleTrack,
    mediaPlayer.currentVideoSubTitleIndex < 0,
    let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
    return
    }
    +
    didAutoSelectSubtitleTrack = true
    autoSelectedSubtitleTrackID = track.id
    #if DEBUG
    3 unmodified lines
    scheduleAutoSubtitleSelectionReapply(trackID: track.id)
    }
    +
    private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
    [0.3, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    44 unmodified lines
    case .paused, .stopped, .ended:
    onStateChange?()
    case .esAdded:
    selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
    #if DEBUG
    logSubtitleTracks(reason: "esAdded")
    #endif
    25 unmodified lines
    26
    27
    28
    29
    30
    31
    32
    33
    15 unmodified lines
    49
    50
    51
    52
    53
    54
    55
    56
    172 unmodified lines
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    1 unmodified line
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    9 unmodified lines
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    3 unmodified lines
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    44 unmodified lines
    365
    366
    367
    368
    369
    370
    371
    25 unmodified lines
    private var didAutoSelectSubtitleTrack = false
    private var didUserSelectSubtitleTrack = false
    private var autoSelectedSubtitleTrackID: Int32?
    private var externalSubtitleBaselineTrackIDs = Set<Int32>()
    private var hasPendingExternalSubtitleSelection = false
    +
    override init() {
    super.init()
    15 unmodified lines
    didAutoSelectSubtitleTrack = false
    didUserSelectSubtitleTrack = false
    autoSelectedSubtitleTrackID = nil
    externalSubtitleBaselineTrackIDs.removeAll()
    hasPendingExternalSubtitleSelection = false
    let media = VLCMedia(url: request.playbackURL)
    let headerValue = request.headers
    .map { "\($0.key): \($0.value)" }
    172 unmodified lines
    private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
    var attachedCount = 0
    var duplicateCount = 0
    let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
    candidates.forEach { candidate in
    guard !attachedSubtitleURLs.contains(candidate.url) else {
    duplicateCount += 1
    return
    }
    attachedSubtitleURLs.insert(candidate.url)
    externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
    hasPendingExternalSubtitleSelection = true
    mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
    attachedCount += 1
    #if DEBUG
    print("[DreamioVLC] attach accepted subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased()) visibleBefore=\(baselineTrackIDs.count)")
    logSubtitleTracks(reason: "after-addPlaybackSlave")
    #endif
    }
    #if DEBUG
    if !candidates.isEmpty {
    print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)")
    }
    #endif
    guard attachedCount > 0 else {
    1 unmodified line
    }
    [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))")
    #if DEBUG
    self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
    if delay == 4.0 {
    self?.logMissingExternalSubtitleTrackIfNeeded()
    }
    #endif
    self?.onSubtitleTracksChange?()
    }
    9 unmodified lines
    }
    #endif
    +
    private func selectPreferredSubtitleTrackIfNeeded(reason: String) {
    guard !didUserSelectSubtitleTrack else {
    return
    }
    +
    if hasPendingExternalSubtitleSelection,
    let externalTrack = subtitleTracks.first(where: { $0.id >= 0 && !externalSubtitleBaselineTrackIDs.contains($0.id) }) {
    selectAutoSubtitleTrack(externalTrack, reason: "\(reason)-external")
    hasPendingExternalSubtitleSelection = false
    return
    }
    +
    guard !didAutoSelectSubtitleTrack,
    mediaPlayer.currentVideoSubTitleIndex < 0,
    let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
    return
    }
    selectAutoSubtitleTrack(track, reason: reason)
    }
    +
    private func selectAutoSubtitleTrack(_ track: SubtitleTrack, reason: String) {
    didAutoSelectSubtitleTrack = true
    autoSelectedSubtitleTrackID = track.id
    #if DEBUG
    3 unmodified lines
    scheduleAutoSubtitleSelectionReapply(trackID: track.id)
    }
    +
    #if DEBUG
    private func logMissingExternalSubtitleTrackIfNeeded() {
    guard hasPendingExternalSubtitleSelection else {
    return
    }
    print("[DreamioVLC] attach accepted but no new external subtitle track visible baseline=\(externalSubtitleBaselineTrackIDs.sorted()) visible=\(subtitleTracks.filter { $0.id >= 0 }.map(\.id))")
    }
    #endif
    +
    private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
    [0.3, 1.0, 2.0, 4.0].forEach { delay in
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
    44 unmodified lines
    case .paused, .stopped, .ended:
    onStateChange?()
    case .esAdded:
    selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
    #if DEBUG
    logSubtitleTracks(reason: "esAdded")
    #endif
    +
    Tests/StreamResolverTests.swift
    +73
    9 unmodified lines
    10
    11
    12
    13
    14
    15
    16
    137 unmodified lines
    154
    155
    156
    157
    158
    159
    19 unmodified lines
    179
    180
    181
    182
    183
    184
    9 unmodified lines
    testPlaybackTimeFormatting()
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesV3DownloadResponseResolution()
    await testSubtitleResolverDownloadJSONReturningLink()
    await testSubtitleResolverRedirectToDirectSubtitle()
    await testSubtitleResolverRejectsNonSubtitleAPIResponse()
    137 unmodified lines
    assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
    }
    +
    private static func testOpenSubtitlesV3DownloadResponseResolution() {
    let payload = """
    {
    19 unmodified lines
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testSubtitleResolverDownloadJSONReturningLink() async {
    MockURLProtocol.handlers = [
    "https://api.opensubtitles.com/api/v1/download/123": (
    9 unmodified lines
    10
    11
    12
    13
    14
    15
    16
    17
    18
    137 unmodified lines
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    19 unmodified lines
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    9 unmodified lines
    testPlaybackTimeFormatting()
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesNestedAttributesFilesParsing()
    testOpenSubtitlesV3DownloadResponseResolution()
    testOpenSubtitlesNestedDownloadResponseResolution()
    await testSubtitleResolverDownloadJSONReturningLink()
    await testSubtitleResolverRedirectToDirectSubtitle()
    await testSubtitleResolverRejectsNonSubtitleAPIResponse()
    137 unmodified lines
    assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
    }
    +
    private static func testOpenSubtitlesNestedAttributesFilesParsing() {
    let payload: [String: Any] = [
    "data": [
    [
    "attributes": [
    "language": "English",
    "file_name": "episode.en.srt",
    "files": [
    [
    "file_id": 12345,
    "file_name": "nested.en.srt"
    ],
    [
    "link": "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret",
    "language": "eng"
    ]
    ]
    ]
    ]
    ]
    ]
    +
    let candidates = SubtitleCandidateParser.candidates(in: payload)
    +
    assertEqual(candidates.count, 2)
    assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/12345")
    assertEqual(candidates[0].label, "nested.en.srt")
    assertEqual(candidates[0].language, "English")
    assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret")
    assertEqual(candidates[1].label, "eng")
    assertEqual(candidates[1].language, "eng")
    }
    +
    private static func testOpenSubtitlesV3DownloadResponseResolution() {
    let payload = """
    {
    19 unmodified lines
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testOpenSubtitlesNestedDownloadResponseResolution() {
    let payload = """
    {
    "data": {
    "attributes": {
    "files": [
    {
    "file_name": "ignored.txt",
    "link": "https://cdn.example.test/ignored.txt"
    },
    {
    "file_name": "episode.en.ass",
    "download": {
    "link": "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret"
    }
    }
    ]
    }
    }
    }
    """.data(using: .utf8)!
    let original = SubtitleCandidate(
    url: URL(string: "https://api.opensubtitles.com/api/v1/download/987")!,
    label: "English SDH",
    language: "eng"
    )
    +
    let candidate = SubtitleResolver.bestPlayableCandidate(
    from: payload,
    responseURL: original.url,
    original: original
    )
    +
    assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret")
    assertEqual(candidate?.label, "English SDH")
    assertEqual(candidate?.language, "eng")
    }
    +
    private static func testSubtitleResolverDownloadJSONReturningLink() async {
    MockURLProtocol.handlers = [
    "https://api.opensubtitles.com/api/v1/download/123": (
    +
    +
    + +
    +

    Expected Impact for End-Users

    +

    When OpenSubtitles provides usable captions, the native captions menu should show external OpenSubtitles options in addition to None and embedded subtitle tracks. If an embedded track appears first, Dreamio can still switch to the external track automatically once VLC surfaces it, unless the user already made a manual caption choice.

    +
    + +
    +

    Validation

    +
      +
    • Passed swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests.
    • +
    • Passed swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift.
    • +
    • Passed DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build.
    • +
    • Manual device validation was not performed in this turn. The next device run should verify that OpenSubtitles options appear in the captions menu for a title with OpenSubtitles enabled.
    • +
    +
    + +
    +

    Issues, Limitations, and Mitigations

    +
      +
    • OpenSubtitles API download URLs may still require provider-specific authorization. This change preserves and resolves more candidate shapes, but it does not add new API credentials.
    • +
    • VLC subtitle slave exposure is asynchronous and backend-dependent. Delayed refreshes and explicit missing-track logs make that timing easier to diagnose.
    • +
    • The existing dirty Xcode workspace user-state file was present before this work and was not intentionally edited.
    • +
    +
    + +
    +

    Follow-up Work

    +
      +
    • Run on a physical device with OpenSubtitles enabled and confirm the menu shows external OpenSubtitles tracks plus embedded tracks.
    • +
    • If device logs still show accepted slaves with no visible VLC track, capture the URL shape and VLC track arrays from the new diagnostics.
    • +
    • Consider exposing external track provenance in the menu if VLC track names remain too generic.
    • +
    +
    +
    + + From 07741bae96545253224857b95394fa0a8f0b5a17 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:05:12 -0400 Subject: [PATCH 17/21] capture opensubtitles candidates from stremio messages --- .beads/interactions.jsonl | 4 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 82 ++++++++ ...s-discovery-after-native-interception.html | 195 ++++++++++++++++++ 4 files changed, 282 insertions(+) create mode 100644 docs/turns/2026-05-25-fix-opensubtitles-discovery-after-native-interception.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c9116fb..63c41ef 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -26,3 +26,7 @@ {"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} {"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}} {"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}} +{"id":"int-95ad98d5","kind":"field_change","created_at":"2026-05-25T16:00:18.70354Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-323d3a68","kind":"field_change","created_at":"2026-05-25T16:02:09.791701Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} +{"id":"int-6e411a6a","kind":"field_change","created_at":"2026-05-25T16:03:23.023525Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a13d943..8073c1f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 95d5e26..78ffbcd 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -304,6 +304,12 @@ final class DreamioWebViewController: UIViewController { postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0); }; + const inspectMessagePayload = (source, payload) => { + const beforeCount = subtitleCandidates.length; + inspectSubtitlePayload(payload); + postSubtitleInspection(source, "", beforeCount, subtitleCandidates.length, 0); + }; + const originalFetch = window.fetch; if (originalFetch) { window.fetch = async (...args) => { @@ -349,6 +355,82 @@ final class DreamioWebViewController: UIViewController { return originalXHRSend.apply(this, args); }; + const originalWindowPostMessage = window.postMessage; + if (originalWindowPostMessage) { + window.postMessage = function(message, targetOrigin, transfer) { + try { inspectMessagePayload("window.postMessage", message); } catch (_) {} + return originalWindowPostMessage.apply(this, arguments); + }; + } + window.addEventListener("message", (event) => { + try { inspectMessagePayload("window.message", event.data); } catch (_) {} + }, true); + + const OriginalWorker = window.Worker; + if (OriginalWorker) { + window.Worker = function(...args) { + const worker = new OriginalWorker(...args); + try { + const originalWorkerPostMessage = worker.postMessage; + worker.postMessage = function(message, transfer) { + try { inspectMessagePayload("worker.postMessage", message); } catch (_) {} + return originalWorkerPostMessage.apply(this, arguments); + }; + worker.addEventListener("message", (event) => { + try { inspectMessagePayload("worker.message", event.data); } catch (_) {} + }, true); + } catch (_) {} + return worker; + }; + try { + window.Worker.prototype = OriginalWorker.prototype; + } catch (_) {} + } + + if (window.MessagePort && window.MessagePort.prototype) { + const originalPortPostMessage = window.MessagePort.prototype.postMessage; + if (originalPortPostMessage) { + window.MessagePort.prototype.postMessage = function(message, transfer) { + try { inspectMessagePayload("message-port.postMessage", message); } catch (_) {} + return originalPortPostMessage.apply(this, arguments); + }; + } + const originalPortAddEventListener = window.MessagePort.prototype.addEventListener; + if (originalPortAddEventListener) { + window.MessagePort.prototype.addEventListener = function(type, listener, options) { + if (type === "message" && typeof listener === "function") { + const wrapped = function(event) { + try { inspectMessagePayload("message-port.message", event && event.data); } catch (_) {} + return listener.apply(this, arguments); + }; + return originalPortAddEventListener.call(this, type, wrapped, options); + } + return originalPortAddEventListener.apply(this, arguments); + }; + } + } + + const OriginalBroadcastChannel = window.BroadcastChannel; + if (OriginalBroadcastChannel) { + window.BroadcastChannel = function(...args) { + const channel = new OriginalBroadcastChannel(...args); + try { + const originalBroadcastPostMessage = channel.postMessage; + channel.postMessage = function(message) { + try { inspectMessagePayload("broadcast-channel.postMessage", message); } catch (_) {} + return originalBroadcastPostMessage.apply(this, arguments); + }; + channel.addEventListener("message", (event) => { + try { inspectMessagePayload("broadcast-channel.message", event.data); } catch (_) {} + }, true); + } catch (_) {} + return channel; + }; + try { + window.BroadcastChannel.prototype = OriginalBroadcastChannel.prototype; + } catch (_) {} + } + const stopNativeHandledMedia = (element) => { const media = element instanceof HTMLVideoElement ? element diff --git a/docs/turns/2026-05-25-fix-opensubtitles-discovery-after-native-interception.html b/docs/turns/2026-05-25-fix-opensubtitles-discovery-after-native-interception.html new file mode 100644 index 0000000..79cc517 --- /dev/null +++ b/docs/turns/2026-05-25-fix-opensubtitles-discovery-after-native-interception.html @@ -0,0 +1,195 @@ + + + + + + Capture OpenSubtitles Candidates from Stremio Messages + + + +
    +
    +
    Dreamio turn document
    +

    Capture OpenSubtitles candidates from Stremio messages

    +

    Dreamio now inspects Stremio app-state and worker messages for subtitle objects, so OpenSubtitlesV3 entries that are already loaded in Stremio can become native subtitle candidates before VLC opens.

    +
    + Date: 2026-05-25 + Issue: dreamio-656 + Scope: native subtitle handoff +
    +
    + +
    +

    Summary

    +

    The failure is not embedded subtitle rendering and is probably not a native-player delay. Stremio can show OpenSubtitlesV3 as loaded before Dreamio launches VLC, while Dreamio still forwards zero external subtitle candidates. The bridge now watches message surfaces where Stremio is likely moving already-loaded subtitle state: window.postMessage, window message events, Worker messages, MessagePort messages, and BroadcastChannel messages.

    +
    + +
    +

    Changes Made

    +
      +
    • Added inspectMessagePayload, which sends arbitrary app-state/message payloads through the existing subtitle payload parser.
    • +
    • Wrapped window.postMessage and listened for window message events.
    • +
    • Wrapped constructed Worker instances so messages to and from Stremio workers are inspected.
    • +
    • Wrapped MessagePort.postMessage and message listeners for channel-based state transport.
    • +
    • Wrapped BroadcastChannel construction to inspect broadcasted state messages.
    • +
    • Removed the earlier delayed-cleanup hypothesis; native-handled media cleanup remains immediate.
    • +
    +
    + +
    +

    Context

    +

    The observed embedded-subtitle logs showed VLC successfully listing and selecting an embedded MKV subtitle track. The OpenSubtitles path is separate: Dreamio’s native player had subtitle candidates=0, meaning no external subtitle candidate reached the Swift attachment/resolution layer.

    +

    Stremio showing OpenSubtitlesV3 as loaded means the data likely exists in the web app before native launch. If that data moves through a worker or message channel rather than main-window fetch or DOM tracks, the old bridge would never see it.

    +
    + +
    +

    Important Implementation Details

    +
      +
    • The new hooks reuse inspectSubtitlePayload, so they support the same URL fields, nested objects, and OpenSubtitles file_id handling as the fetch/XHR path.
    • +
    • The hooks inspect messages passively and then call the original browser APIs, preserving Stremio behavior.
    • +
    • Debug logs should now identify message-derived inspection via sources like worker.message, message-port.message, or broadcast-channel.message.
    • +
    • If candidates are discovered, the existing Swift path still resolves OpenSubtitles API/download URLs into direct subtitle files before attaching them to VLC.
    • +
    +
    + +
    +

    Relevant Diff Snippets

    +
    Dreamio/DreamioWebViewController.swift
    +82
    303 unmodified lines
    304
    305
    306
    307
    308
    309
    39 unmodified lines
    349
    350
    351
    352
    353
    354
    303 unmodified lines
    postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
    };
    +
    const originalFetch = window.fetch;
    if (originalFetch) {
    window.fetch = async (...args) => {
    39 unmodified lines
    return originalXHRSend.apply(this, args);
    };
    +
    const stopNativeHandledMedia = (element) => {
    const media = element instanceof HTMLVideoElement
    ? element
    303 unmodified lines
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    39 unmodified lines
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    303 unmodified lines
    postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
    };
    +
    const inspectMessagePayload = (source, payload) => {
    const beforeCount = subtitleCandidates.length;
    inspectSubtitlePayload(payload);
    postSubtitleInspection(source, "", beforeCount, subtitleCandidates.length, 0);
    };
    +
    const originalFetch = window.fetch;
    if (originalFetch) {
    window.fetch = async (...args) => {
    39 unmodified lines
    return originalXHRSend.apply(this, args);
    };
    +
    const originalWindowPostMessage = window.postMessage;
    if (originalWindowPostMessage) {
    window.postMessage = function(message, targetOrigin, transfer) {
    try { inspectMessagePayload("window.postMessage", message); } catch (_) {}
    return originalWindowPostMessage.apply(this, arguments);
    };
    }
    window.addEventListener("message", (event) => {
    try { inspectMessagePayload("window.message", event.data); } catch (_) {}
    }, true);
    +
    const OriginalWorker = window.Worker;
    if (OriginalWorker) {
    window.Worker = function(...args) {
    const worker = new OriginalWorker(...args);
    try {
    const originalWorkerPostMessage = worker.postMessage;
    worker.postMessage = function(message, transfer) {
    try { inspectMessagePayload("worker.postMessage", message); } catch (_) {}
    return originalWorkerPostMessage.apply(this, arguments);
    };
    worker.addEventListener("message", (event) => {
    try { inspectMessagePayload("worker.message", event.data); } catch (_) {}
    }, true);
    } catch (_) {}
    return worker;
    };
    try {
    window.Worker.prototype = OriginalWorker.prototype;
    } catch (_) {}
    }
    +
    if (window.MessagePort && window.MessagePort.prototype) {
    const originalPortPostMessage = window.MessagePort.prototype.postMessage;
    if (originalPortPostMessage) {
    window.MessagePort.prototype.postMessage = function(message, transfer) {
    try { inspectMessagePayload("message-port.postMessage", message); } catch (_) {}
    return originalPortPostMessage.apply(this, arguments);
    };
    }
    const originalPortAddEventListener = window.MessagePort.prototype.addEventListener;
    if (originalPortAddEventListener) {
    window.MessagePort.prototype.addEventListener = function(type, listener, options) {
    if (type === "message" && typeof listener === "function") {
    const wrapped = function(event) {
    try { inspectMessagePayload("message-port.message", event && event.data); } catch (_) {}
    return listener.apply(this, arguments);
    };
    return originalPortAddEventListener.call(this, type, wrapped, options);
    }
    return originalPortAddEventListener.apply(this, arguments);
    };
    }
    }
    +
    const OriginalBroadcastChannel = window.BroadcastChannel;
    if (OriginalBroadcastChannel) {
    window.BroadcastChannel = function(...args) {
    const channel = new OriginalBroadcastChannel(...args);
    try {
    const originalBroadcastPostMessage = channel.postMessage;
    channel.postMessage = function(message) {
    try { inspectMessagePayload("broadcast-channel.postMessage", message); } catch (_) {}
    return originalBroadcastPostMessage.apply(this, arguments);
    };
    channel.addEventListener("message", (event) => {
    try { inspectMessagePayload("broadcast-channel.message", event.data); } catch (_) {}
    }, true);
    } catch (_) {}
    return channel;
    };
    try {
    window.BroadcastChannel.prototype = OriginalBroadcastChannel.prototype;
    } catch (_) {}
    }
    +
    const stopNativeHandledMedia = (element) => {
    const media = element instanceof HTMLVideoElement
    ? element
    +
    + +
    +

    Expected Impact for End-Users

    +

    Streams where Stremio has already loaded OpenSubtitlesV3 should have a better chance of handing those subtitles to native playback. The expected visible result is that the native captions menu gains external OpenSubtitles options instead of showing no external candidates.

    +
    + +
    +

    Validation

    +
      +
    • Passed: swiftc -parse-as-library Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-resolver-tests && /tmp/dreamio-stream-resolver-tests
    • +
    • Passed: xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build
    • +
    • Not manually confirmed: a real OpenSubtitlesV3 stream still needs to verify that bridge logs show nonzero candidates from one of the message sources.
    • +
    +
    + +
    +

    Issues, Limitations, and Mitigations

    +
    +

    This fix assumes Stremio exposes loaded subtitle objects through main-window or worker messaging. If OpenSubtitles still stays at zero candidates, the next likely gap is a storage-backed state path, such as IndexedDB or a framework store that never crosses an intercepted message boundary. The debug source labels should make that next step clearer.

    +
    +
    + +
    +

    Follow-up Work

    +
      +
    • Run the exact OpenSubtitlesV3 scenario and look for [DreamioSubtitles] bridge source=worker.message, message-port.message, or broadcast-channel.message with parsed above zero.
    • +
    • If message hooks still do not see candidates, inspect Stremio storage/state immediately before native launch.
    • +
    • Add a debug-only dump of subtitle-looking message keys if the next real run still shows zero candidates.
    • +
    +
    +
    + + From 6008272d0a0faf3a529e78d6a0ee459b8b9100d8 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:18:45 -0400 Subject: [PATCH 18/21] fix opensubtitles manifest subtitle urls --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 27 ++- Dreamio/StreamCandidate.swift | 11 + Tests/StreamResolverTests.swift | 24 ++ ...olve-opensubtitles-subtitle-downloads.html | 216 ++++++++++++++++++ 6 files changed, 275 insertions(+), 5 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 63c41ef..1417109 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -30,3 +30,4 @@ {"id":"int-323d3a68","kind":"field_change","created_at":"2026-05-25T16:02:09.791701Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} {"id":"int-6e411a6a","kind":"field_change","created_at":"2026-05-25T16:03:23.023525Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} {"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} +{"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8073c1f..2260149 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-urs","title":"Fix OpenSubtitles manifest-style subtitle URLs","description":"OpenSubtitles subtitle candidates discovered from Stremio are being resolved as manifest.json_N URLs, producing 404s and leaving only embedded subtitles visible. Preserve and resolve real subtitle URLs so external subtitle tracks can attach in the native player.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:16:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:18:29Z","started_at":"2026-05-25T16:16:57Z","closed_at":"2026-05-25T16:18:29Z","close_reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 78ffbcd..daa6544 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -115,6 +115,26 @@ final class DreamioWebViewController: UIViewController { } }; + const isOpenSubtitlesManifestID = (url) => { + try { + const parsed = new URL(url, window.location.href); + return /opensubtitles/i.test(parsed.hostname) + && /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname); + } catch (_) { + return false; + } + }; + + const isSubtitleURL = (url) => { + if (!url || isOpenSubtitlesManifestID(url)) { + return false; + } + subtitleURLPattern.lastIndex = 0; + const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url); + subtitleURLPattern.lastIndex = 0; + return matches; + }; + const findResolverURL = () => { const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); const match = links @@ -200,15 +220,12 @@ final class DreamioWebViewController: UIViewController { entry.fileURL ); let url = absoluteURL(rawURL); - if (!url && entry && entry.file_id) { + if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) { url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`; } - subtitleURLPattern.lastIndex = 0; - if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) { - subtitleURLPattern.lastIndex = 0; + if (!isSubtitleURL(url)) { return; } - subtitleURLPattern.lastIndex = 0; if (subtitleCandidates.some((candidate) => candidate.url === url)) { return; } diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 3e09d57..99f971c 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -245,6 +245,9 @@ enum SubtitleCandidateParser { } let lowercased = url.absoluteString.lowercased() + if isOpenSubtitlesManifestIdentifier(url) { + return nil + } guard supportedExtensions.contains(url.pathExtension.lowercased()) || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) || lowercased.contains("subtitle") @@ -256,6 +259,14 @@ enum SubtitleCandidateParser { return url } + private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool { + guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else { + return false + } + let path = url.path.lowercased() + return path == "/manifest.json" || path.range(of: #"/manifest\.json_\d+$"#, options: .regularExpression) != nil + } + private static func openSubtitlesDownloadURL(from value: Any?) -> URL? { let id: String? if let string = value as? String, !string.isEmpty { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 7137f48..330bf53 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -11,6 +11,7 @@ struct StreamResolverTests { testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() testOpenSubtitlesNestedAttributesFilesParsing() + testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() @@ -189,6 +190,29 @@ struct StreamResolverTests { assertEqual(candidates[1].language, "eng") } + private static func testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "url": "https://opensubtitles-v3.strem.io/manifest.json_14", + "file_id": 98765, + "lang": "eng" + ], + [ + "url": "https://opensubtitles-v3.strem.io/manifest.json_15", + "lang": "spa" + ], + "https://opensubtitles-v3.strem.io/manifest.json_16" + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 1) + assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/98765") + assertEqual(candidates[0].language, "eng") + } + private static func testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { diff --git a/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html index 2431244..be19e88 100644 --- a/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html +++ b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html @@ -624,6 +624,222 @@ + +
    +

    New Changes as of May 25, 2026 at 12:18 PM EDT

    +

    Summary of changes

    +

    Dreamio now rejects OpenSubtitles addon manifest identifiers such as manifest.json_14 as playable subtitle URLs. When the same payload includes a real OpenSubtitles file_id, Dreamio promotes that ID to the API download endpoint instead.

    +

    Why this change was made

    +

    Live debug logs showed twenty OpenSubtitles candidates reaching the native player, but every candidate resolved as https://opensubtitles-v3.strem.io/manifest.json_N and returned HTTP 404. That left VLC with only the embedded MKV subtitle track visible in the UI.

    +

    Code diffs

    +

    DreamioWebViewController.swift

    Dreamio/DreamioWebViewController.swift
    -5+22
    114 unmodified lines
    115
    116
    117
    118
    119
    120
    79 unmodified lines
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    114 unmodified lines
    }
    };
    +
    const findResolverURL = () => {
    const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
    const match = links
    79 unmodified lines
    entry.fileURL
    );
    let url = absoluteURL(rawURL);
    if (!url && entry && entry.file_id) {
    url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
    }
    subtitleURLPattern.lastIndex = 0;
    if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) {
    subtitleURLPattern.lastIndex = 0;
    return;
    }
    subtitleURLPattern.lastIndex = 0;
    if (subtitleCandidates.some((candidate) => candidate.url === url)) {
    return;
    }
    114 unmodified lines
    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
    79 unmodified lines
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    114 unmodified lines
    }
    };
    +
    const isOpenSubtitlesManifestID = (url) => {
    try {
    const parsed = new URL(url, window.location.href);
    return /opensubtitles/i.test(parsed.hostname)
    && /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname);
    } catch (_) {
    return false;
    }
    };
    +
    const isSubtitleURL = (url) => {
    if (!url || isOpenSubtitlesManifestID(url)) {
    return false;
    }
    subtitleURLPattern.lastIndex = 0;
    const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url);
    subtitleURLPattern.lastIndex = 0;
    return matches;
    };
    +
    const findResolverURL = () => {
    const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
    const match = links
    79 unmodified lines
    entry.fileURL
    );
    let url = absoluteURL(rawURL);
    if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) {
    url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
    }
    if (!isSubtitleURL(url)) {
    return;
    }
    if (subtitleCandidates.some((candidate) => candidate.url === url)) {
    return;
    }
    +

    StreamCandidate.swift

    Dreamio/StreamCandidate.swift
    +11
    244 unmodified lines
    245
    246
    247
    248
    249
    250
    5 unmodified lines
    256
    257
    258
    259
    260
    261
    244 unmodified lines
    }
    +
    let lowercased = url.absoluteString.lowercased()
    guard supportedExtensions.contains(url.pathExtension.lowercased())
    || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
    || lowercased.contains("subtitle")
    5 unmodified lines
    return url
    }
    +
    private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
    let id: String?
    if let string = value as? String, !string.isEmpty {
    244 unmodified lines
    245
    246
    247
    248
    249
    250
    251
    252
    253
    5 unmodified lines
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    244 unmodified lines
    }
    +
    let lowercased = url.absoluteString.lowercased()
    if isOpenSubtitlesManifestIdentifier(url) {
    return nil
    }
    guard supportedExtensions.contains(url.pathExtension.lowercased())
    || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
    || lowercased.contains("subtitle")
    5 unmodified lines
    return url
    }
    +
    private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
    guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
    return false
    }
    let path = url.path.lowercased()
    return path == "/manifest.json" || path.range(of: #"/manifest\.json_\d+$"#, options: .regularExpression) != nil
    }
    +
    private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
    let id: String?
    if let string = value as? String, !string.isEmpty {
    +

    StreamResolverTests.swift

    Tests/StreamResolverTests.swift
    +24
    10 unmodified lines
    11
    12
    13
    14
    15
    16
    172 unmodified lines
    189
    190
    191
    192
    193
    194
    10 unmodified lines
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesNestedAttributesFilesParsing()
    testOpenSubtitlesV3DownloadResponseResolution()
    testOpenSubtitlesNestedDownloadResponseResolution()
    await testSubtitleResolverDownloadJSONReturningLink()
    172 unmodified lines
    assertEqual(candidates[1].language, "eng")
    }
    +
    private static func testOpenSubtitlesV3DownloadResponseResolution() {
    let payload = """
    {
    10 unmodified lines
    11
    12
    13
    14
    15
    16
    17
    172 unmodified lines
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    10 unmodified lines
    testSubtitleCandidateParsing()
    testOpenSubtitlesV3CandidateParsing()
    testOpenSubtitlesNestedAttributesFilesParsing()
    testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
    testOpenSubtitlesV3DownloadResponseResolution()
    testOpenSubtitlesNestedDownloadResponseResolution()
    await testSubtitleResolverDownloadJSONReturningLink()
    172 unmodified lines
    assertEqual(candidates[1].language, "eng")
    }
    +
    private static func testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() {
    let payload: [String: Any] = [
    "subtitles": [
    [
    "url": "https://opensubtitles-v3.strem.io/manifest.json_14",
    "file_id": 98765,
    "lang": "eng"
    ],
    [
    "url": "https://opensubtitles-v3.strem.io/manifest.json_15",
    "lang": "spa"
    ],
    "https://opensubtitles-v3.strem.io/manifest.json_16"
    ]
    ]
    +
    let candidates = SubtitleCandidateParser.candidates(in: payload)
    +
    assertEqual(candidates.count, 1)
    assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/98765")
    assertEqual(candidates[0].language, "eng")
    }
    +
    private static func testOpenSubtitlesV3DownloadResponseResolution() {
    let payload = """
    {
    + +

    Related issues or PRs

    +

    Related Beads issue: dreamio-urs.

    +
    +

    Follow-up Work

      From 11ed3640940eda31dbf6ba349a8ea713033edebb Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:22:58 -0400 Subject: [PATCH 19/21] filter false opensubtitles subtitle candidates --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 50 ++- Dreamio/StreamCandidate.swift | 33 +- Tests/StreamResolverTests.swift | 27 ++ ...filter-false-opensubtitles-candidates.html | 362 ++++++++++++++++++ 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 1417109..f943500 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -31,3 +31,4 @@ {"id":"int-6e411a6a","kind":"field_change","created_at":"2026-05-25T16:03:23.023525Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} {"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} {"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}} +{"id":"int-569ee372","kind":"field_change","created_at":"2026-05-25T16:22:50.024736Z","actor":"dirtydishes","issue_id":"dreamio-433","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2260149..16c6888 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-433","title":"Filter false OpenSubtitles subtitle candidates","description":"Dreamio is treating addon artwork and OpenSubtitles addon endpoints as external subtitle candidates, which causes the native player UI to show only embedded subtitles. Tighten subtitle URL detection in the web bridge and Swift parser, and add regression coverage for the logged false positives.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:20:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:22:50Z","started_at":"2026-05-25T16:20:50Z","closed_at":"2026-05-25T16:22:50Z","close_reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-urs","title":"Fix OpenSubtitles manifest-style subtitle URLs","description":"OpenSubtitles subtitle candidates discovered from Stremio are being resolved as manifest.json_N URLs, producing 404s and leaving only embedded subtitles visible. Preserve and resolve real subtitle URLs so external subtitle tracks can attach in the native player.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:16:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:18:29Z","started_at":"2026-05-25T16:16:57Z","closed_at":"2026-05-25T16:18:29Z","close_reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index daa6544..c45e1d5 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -84,6 +84,11 @@ final class DreamioWebViewController: UIViewController { const postedSubtitleURLs = new Set(); const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i; + const subtitleExtensions = new Set(["srt", "vtt", "ass", "ssa", "sub"]); + const nonSubtitleExtensions = new Set([ + "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json", + "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp" + ]); const subtitleObjectKeys = [ "attributes", "files", @@ -125,14 +130,51 @@ final class DreamioWebViewController: UIViewController { } }; + const isDirectSubtitleFileURL = (url) => { + try { + const parsed = new URL(url, window.location.href); + const extension = parsed.pathname.split(".").pop().toLowerCase(); + return subtitleExtensions.has(extension) + || Array.from(subtitleExtensions).some((ext) => parsed.href.toLowerCase().includes(`.${ext}?`) || parsed.href.toLowerCase().includes(`.${ext}&`)); + } catch (_) { + return false; + } + }; + + const isProbablyNonSubtitleAssetURL = (url) => { + try { + const extension = new URL(url, window.location.href).pathname.split(".").pop().toLowerCase(); + return nonSubtitleExtensions.has(extension); + } catch (_) { + return false; + } + }; + + const isOpenSubtitlesDownloadURL = (url) => { + try { + const parsed = new URL(url, window.location.href); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if (!host.includes("opensubtitles")) { + return false; + } + if (/\/manifest\.json(?:_\d+)?$/i.test(path)) { + return false; + } + return /\/api\/v1\/download(?:\/|$)/i.test(path) + || /\/download(?:\/|$)/i.test(path) + || /\/subtitles?(?:\/|$)/i.test(path); + } catch (_) { + return false; + } + }; + const isSubtitleURL = (url) => { if (!url || isOpenSubtitlesManifestID(url)) { return false; } - subtitleURLPattern.lastIndex = 0; - const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url); - subtitleURLPattern.lastIndex = 0; - return matches; + return !isProbablyNonSubtitleAssetURL(url) + && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url)); }; const findResolverURL = () => { diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 99f971c..e735f88 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -132,6 +132,10 @@ struct StreamCandidate { enum SubtitleCandidateParser { private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] + private static let nonSubtitleExtensions = [ + "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json", + "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp" + ] private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"] private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"] private struct CandidateContext { @@ -244,14 +248,14 @@ enum SubtitleCandidateParser { return nil } - let lowercased = url.absoluteString.lowercased() if isOpenSubtitlesManifestIdentifier(url) { return nil } - guard supportedExtensions.contains(url.pathExtension.lowercased()) - || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) - || lowercased.contains("subtitle") - || lowercased.contains("opensubtitles") + guard !nonSubtitleExtensions.contains(url.pathExtension.lowercased()) else { + return nil + } + guard isDirectSubtitleFile(url) + || isOpenSubtitlesDownloadURL(url) else { return nil } @@ -259,6 +263,25 @@ enum SubtitleCandidateParser { return url } + private static func isDirectSubtitleFile(_ url: URL) -> Bool { + let lowercased = url.absoluteString.lowercased() + return supportedExtensions.contains(url.pathExtension.lowercased()) + || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) + } + + private static func isOpenSubtitlesDownloadURL(_ url: URL) -> Bool { + guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else { + return false + } + let path = url.path.lowercased() + guard !isOpenSubtitlesManifestIdentifier(url) else { + return false + } + return path.range(of: #"(^|/)api/v1/download(/|$)"#, options: .regularExpression) != nil + || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil + || path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil + } + private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool { guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else { return false diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 330bf53..39854a8 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -12,6 +12,7 @@ struct StreamResolverTests { testOpenSubtitlesV3CandidateParsing() testOpenSubtitlesNestedAttributesFilesParsing() testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() + testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored() testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() @@ -213,6 +214,32 @@ struct StreamResolverTests { assertEqual(candidates[0].language, "eng") } + private static func testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "label": "External Subtitle", + "url": "http://www.strem.io/images/addons/opensubtitles-logo.png" + ], + [ + "label": "External Subtitle", + "url": "https://opensubtitles.strem.io/stremio/v1" + ], + [ + "label": "English", + "url": "https://opensubtitles.example.test/subtitles/movie.en.srt" + ] + ], + "body": "metadata https://www.strem.io/images/addons/opensubtitles-logo.png" + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 1) + assertEqual(candidates[0].url.absoluteString, "https://opensubtitles.example.test/subtitles/movie.en.srt") + assertEqual(candidates[0].label, "English") + } + private static func testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { diff --git a/docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html b/docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html new file mode 100644 index 0000000..fcf70f5 --- /dev/null +++ b/docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html @@ -0,0 +1,362 @@ + + + + + + Filter False OpenSubtitles Subtitle Candidates + + + +
      +
      +

      Filter False OpenSubtitles Subtitle Candidates

      +

      Dreamio now stops treating OpenSubtitles addon artwork and base addon endpoints as native subtitle files, so VLC is no longer asked to resolve junk candidates before external subtitle tracks can appear.

      +
      + +
      +

      Summary

      +

      The pasted runtime log showed two false external subtitle candidates: an opensubtitles-logo.png image and https://opensubtitles.strem.io/stremio/v1. Both were being buffered and resolved as if they were subtitles, then rejected downstream. This change tightens subtitle candidate detection at both the injected JavaScript bridge and Swift parser layers.

      +
      + +
      +

      Changes Made

      +
        +
      • Added explicit subtitle extension and non-subtitle asset filtering in the web bridge.
      • +
      • Restricted OpenSubtitles URL acceptance to direct subtitle files, OpenSubtitles download API URLs, and subtitle/download paths on OpenSubtitles hosts.
      • +
      • Mirrored the same filtering in SubtitleCandidateParser so noisy bridge payloads cannot reintroduce bad candidates.
      • +
      • Added a regression test for the logged PNG artwork URL and addon base endpoint.
      • +
      +
      + +
      +

      Context

      +

      VLC was correctly detecting and selecting the embedded MKV subtitle track. The failure was earlier: Dreamio’s bridge discovered two candidates, but neither was an actual external subtitle file. The native resolver then rejected both, leaving the UI with only embedded subtitles.

      +
      + +
      +

      Important Implementation Details

      +

      The previous heuristic accepted any URL containing opensubtitles or subtitle. That was too broad because addon logos, metadata endpoints, and app API routes can contain those words. The new logic keeps permissive support for real subtitle files and known OpenSubtitles download flows while rejecting common media, image, script, and manifest-style assets.

      +
      + +
      +

      Relevant Diff Snippets

      +

      Dreamio/DreamioWebViewController.swift

      Dreamio/DreamioWebViewController.swift
      -4+46
      83 unmodified lines
      84
      85
      86
      87
      88
      89
      35 unmodified lines
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      83 unmodified lines
      const postedSubtitleURLs = new Set();
      const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
      const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
      const subtitleObjectKeys = [
      "attributes",
      "files",
      35 unmodified lines
      }
      };
      +
      const isSubtitleURL = (url) => {
      if (!url || isOpenSubtitlesManifestID(url)) {
      return false;
      }
      subtitleURLPattern.lastIndex = 0;
      const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url);
      subtitleURLPattern.lastIndex = 0;
      return matches;
      };
      +
      const findResolverURL = () => {
      83 unmodified lines
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      35 unmodified lines
      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
      177
      178
      179
      180
      83 unmodified lines
      const postedSubtitleURLs = new Set();
      const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
      const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
      const subtitleExtensions = new Set(["srt", "vtt", "ass", "ssa", "sub"]);
      const nonSubtitleExtensions = new Set([
      "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json",
      "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp"
      ]);
      const subtitleObjectKeys = [
      "attributes",
      "files",
      35 unmodified lines
      }
      };
      +
      const isDirectSubtitleFileURL = (url) => {
      try {
      const parsed = new URL(url, window.location.href);
      const extension = parsed.pathname.split(".").pop().toLowerCase();
      return subtitleExtensions.has(extension)
      || Array.from(subtitleExtensions).some((ext) => parsed.href.toLowerCase().includes(`.${ext}?`) || parsed.href.toLowerCase().includes(`.${ext}&`));
      } catch (_) {
      return false;
      }
      };
      +
      const isProbablyNonSubtitleAssetURL = (url) => {
      try {
      const extension = new URL(url, window.location.href).pathname.split(".").pop().toLowerCase();
      return nonSubtitleExtensions.has(extension);
      } catch (_) {
      return false;
      }
      };
      +
      const isOpenSubtitlesDownloadURL = (url) => {
      try {
      const parsed = new URL(url, window.location.href);
      const host = parsed.hostname.toLowerCase();
      const path = parsed.pathname.toLowerCase();
      if (!host.includes("opensubtitles")) {
      return false;
      }
      if (/\/manifest\.json(?:_\d+)?$/i.test(path)) {
      return false;
      }
      return /\/api\/v1\/download(?:\/|$)/i.test(path)
      || /\/download(?:\/|$)/i.test(path)
      || /\/subtitles?(?:\/|$)/i.test(path);
      } catch (_) {
      return false;
      }
      };
      +
      const isSubtitleURL = (url) => {
      if (!url || isOpenSubtitlesManifestID(url)) {
      return false;
      }
      return !isProbablyNonSubtitleAssetURL(url)
      && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url));
      };
      +
      const findResolverURL = () => {
      +

      Dreamio/StreamCandidate.swift

      Dreamio/StreamCandidate.swift
      -5+28
      131 unmodified lines
      132
      133
      134
      135
      136
      137
      106 unmodified lines
      244
      245
      246
      247
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      1 unmodified line
      259
      260
      261
      262
      263
      264
      131 unmodified lines
      +
      enum SubtitleCandidateParser {
      private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
      private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
      private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
      private struct CandidateContext {
      106 unmodified lines
      return nil
      }
      +
      let lowercased = url.absoluteString.lowercased()
      if isOpenSubtitlesManifestIdentifier(url) {
      return nil
      }
      guard supportedExtensions.contains(url.pathExtension.lowercased())
      || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
      || lowercased.contains("subtitle")
      || lowercased.contains("opensubtitles")
      else {
      return nil
      }
      1 unmodified line
      return url
      }
      +
      private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
      guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
      return false
      131 unmodified lines
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      106 unmodified lines
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      258
      259
      260
      261
      1 unmodified line
      263
      264
      265
      266
      267
      268
      269
      270
      271
      272
      273
      274
      275
      276
      277
      278
      279
      280
      281
      282
      283
      284
      285
      286
      287
      131 unmodified lines
      +
      enum SubtitleCandidateParser {
      private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
      private static let nonSubtitleExtensions = [
      "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json",
      "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp"
      ]
      private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
      private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
      private struct CandidateContext {
      106 unmodified lines
      return nil
      }
      +
      if isOpenSubtitlesManifestIdentifier(url) {
      return nil
      }
      guard !nonSubtitleExtensions.contains(url.pathExtension.lowercased()) else {
      return nil
      }
      guard isDirectSubtitleFile(url)
      || isOpenSubtitlesDownloadURL(url)
      else {
      return nil
      }
      1 unmodified line
      return url
      }
      +
      private static func isDirectSubtitleFile(_ url: URL) -> Bool {
      let lowercased = url.absoluteString.lowercased()
      return supportedExtensions.contains(url.pathExtension.lowercased())
      || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
      }
      +
      private static func isOpenSubtitlesDownloadURL(_ url: URL) -> Bool {
      guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
      return false
      }
      let path = url.path.lowercased()
      guard !isOpenSubtitlesManifestIdentifier(url) else {
      return false
      }
      return path.range(of: #"(^|/)api/v1/download(/|$)"#, options: .regularExpression) != nil
      || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
      || path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
      }
      +
      private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
      guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
      return false
      +

      Tests/StreamResolverTests.swift

      Tests/StreamResolverTests.swift
      +27
      11 unmodified lines
      12
      13
      14
      15
      16
      17
      195 unmodified lines
      213
      214
      215
      216
      217
      218
      11 unmodified lines
      testOpenSubtitlesV3CandidateParsing()
      testOpenSubtitlesNestedAttributesFilesParsing()
      testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
      testOpenSubtitlesV3DownloadResponseResolution()
      testOpenSubtitlesNestedDownloadResponseResolution()
      await testSubtitleResolverDownloadJSONReturningLink()
      195 unmodified lines
      assertEqual(candidates[0].language, "eng")
      }
      +
      private static func testOpenSubtitlesV3DownloadResponseResolution() {
      let payload = """
      {
      11 unmodified lines
      12
      13
      14
      15
      16
      17
      18
      195 unmodified lines
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      236
      237
      238
      239
      240
      241
      242
      243
      244
      245
      11 unmodified lines
      testOpenSubtitlesV3CandidateParsing()
      testOpenSubtitlesNestedAttributesFilesParsing()
      testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
      testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
      testOpenSubtitlesV3DownloadResponseResolution()
      testOpenSubtitlesNestedDownloadResponseResolution()
      await testSubtitleResolverDownloadJSONReturningLink()
      195 unmodified lines
      assertEqual(candidates[0].language, "eng")
      }
      +
      private static func testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored() {
      let payload: [String: Any] = [
      "subtitles": [
      [
      "label": "External Subtitle",
      "url": "http://www.strem.io/images/addons/opensubtitles-logo.png"
      ],
      [
      "label": "External Subtitle",
      "url": "https://opensubtitles.strem.io/stremio/v1"
      ],
      [
      "label": "English",
      "url": "https://opensubtitles.example.test/subtitles/movie.en.srt"
      ]
      ],
      "body": "metadata https://www.strem.io/images/addons/opensubtitles-logo.png"
      ]
      +
      let candidates = SubtitleCandidateParser.candidates(in: payload)
      +
      assertEqual(candidates.count, 1)
      assertEqual(candidates[0].url.absoluteString, "https://opensubtitles.example.test/subtitles/movie.en.srt")
      assertEqual(candidates[0].label, "English")
      }
      +
      private static func testOpenSubtitlesV3DownloadResponseResolution() {
      let payload = """
      {
      +
      + +
      +

      Expected Impact for End-Users

      +

      External subtitle discovery should stop burning time on addon images and base endpoints. In the exact logged scenario, Dreamio should no longer buffer the PNG or /stremio/v1 endpoint as external subtitles. Real OpenSubtitles download candidates remain eligible for resolution and attachment.

      +
      + +
      +

      Validation

      +
        +
      • Passed: swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests
      • +
      • Passed: xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build
      • +
      • bd dolt push was run after closing dreamio-433; Beads reported that no Dolt remote is configured, so there was nothing to push.
      • +
      +
      + +
      +

      Issues, Limitations, and Mitigations

      +
      +

      This fix removes the false positives visible in the log, but it may not by itself surface the actual OpenSubtitles external file if Stremio is hiding it behind a different internal payload shape. The mitigation is that future logs should now be cleaner: if external subtitles are still missing, the remaining bridge messages should point at the real undiscovered payload instead of the addon logo noise.

      +
      +
      + +
      +

      Follow-up Work

      +

      No new Beads follow-up was filed. The next useful manual check is to replay the same OpenSubtitlesV3 stream and confirm the bridge no longer logs candidates with ext=png or ext=none for the addon base endpoint.

      +
      +
      + + From 11b9c6a12ac9dd39aa3e0b50b521fea36a6d4d42 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:34:06 -0400 Subject: [PATCH 20/21] accept stremio subtitle download urls --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 15 +- Dreamio/StreamCandidate.swift | 13 + Dreamio/StreamResolver.swift | 14 + Tests/StreamResolverTests.swift | 25 + ...accept-stremio-subtitle-download-urls.html | 472 ++++++++++++++++++ 7 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index f943500..072f8e3 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -32,3 +32,4 @@ {"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} {"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}} {"id":"int-569ee372","kind":"field_change","created_at":"2026-05-25T16:22:50.024736Z","actor":"dirtydishes","issue_id":"dreamio-433","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives."}} +{"id":"int-eca1f7f8","kind":"field_change","created_at":"2026-05-25T16:33:55.331041Z","actor":"dirtydishes","issue_id":"dreamio-9sp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 16c6888..179f5e5 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-9sp","title":"Accept Stremio subtitle download URLs","description":"Runtime logs show Stremio external subtitle tracks using subs5.strem.io /en/download URLs. The subtitle bridge and Swift parser currently reject those URLs because they do not have a subtitle file extension and are not on an OpenSubtitles host, so native playback receives zero external subtitle candidates.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:32:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:33:55Z","started_at":"2026-05-25T16:32:10Z","closed_at":"2026-05-25T16:33:55Z","close_reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-433","title":"Filter false OpenSubtitles subtitle candidates","description":"Dreamio is treating addon artwork and OpenSubtitles addon endpoints as external subtitle candidates, which causes the native player UI to show only embedded subtitles. Tighten subtitle URL detection in the web bridge and Swift parser, and add regression coverage for the logged false positives.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:20:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:22:50Z","started_at":"2026-05-25T16:20:50Z","closed_at":"2026-05-25T16:22:50Z","close_reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-urs","title":"Fix OpenSubtitles manifest-style subtitle URLs","description":"OpenSubtitles subtitle candidates discovered from Stremio are being resolved as manifest.json_N URLs, producing 404s and leaving only embedded subtitles visible. Preserve and resolve real subtitle URLs so external subtitle tracks can attach in the native player.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:16:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:18:29Z","started_at":"2026-05-25T16:16:57Z","closed_at":"2026-05-25T16:18:29Z","close_reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index c45e1d5..7ba97c4 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -169,12 +169,25 @@ final class DreamioWebViewController: UIViewController { } }; + const isStremioSubtitleDownloadURL = (url) => { + try { + const parsed = new URL(url, window.location.href); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + return host === "strem.io" || host.endsWith(".strem.io") + ? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path) + : false; + } catch (_) { + return false; + } + }; + const isSubtitleURL = (url) => { if (!url || isOpenSubtitlesManifestID(url)) { return false; } return !isProbablyNonSubtitleAssetURL(url) - && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url)); + && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url)); }; const findResolverURL = () => { diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index e735f88..b2345a4 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -256,6 +256,7 @@ enum SubtitleCandidateParser { } guard isDirectSubtitleFile(url) || isOpenSubtitlesDownloadURL(url) + || isStremioSubtitleDownloadURL(url) else { return nil } @@ -282,6 +283,18 @@ enum SubtitleCandidateParser { || path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil } + private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased(), + host == "strem.io" || host.hasSuffix(".strem.io") + else { + return false + } + + let path = url.path.lowercased() + return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil + || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil + } + private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool { guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else { return false diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index 6aa7359..ffba9fd 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -131,6 +131,7 @@ final class SubtitleResolver: SubtitleResolving { 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 { @@ -138,6 +139,19 @@ final class SubtitleResolver: SubtitleResolving { return lowercased.contains("opensubtitles") || lowercased.contains("/subtitle") || lowercased.contains("subtitle") + || isStremioSubtitleDownloadURL(url) + } + + private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased(), + host == "strem.io" || host.hasSuffix(".strem.io") + else { + return false + } + + let path = url.path.lowercased() + return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil + || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil } private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 39854a8..8fc7c48 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -13,6 +13,7 @@ struct StreamResolverTests { testOpenSubtitlesNestedAttributesFilesParsing() testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored() + testStremioSubtitleDownloadURLParsing() testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() @@ -240,6 +241,30 @@ struct StreamResolverTests { assertEqual(candidates[0].label, "English") } + private static func testStremioSubtitleDownloadURLParsing() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "label": "English", + "lang": "eng", + "url": "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941" + ], + [ + "label": "Not a subtitle", + "url": "https://www.strem.io/images/addons/opensubtitles-logo.png" + ] + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 1) + 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 testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { diff --git a/docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html b/docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html new file mode 100644 index 0000000..378cacd --- /dev/null +++ b/docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html @@ -0,0 +1,472 @@ + + + + + + Accept Stremio Subtitle Download URLs + + + +
      +
      +

      Dreamio turn document

      +

      Accept Stremio subtitle download URLs

      +

      External subtitles from Stremio can arrive as subs*.strem.io/en/download/... URLs with no subtitle file extension. Dreamio now recognizes that shape in the injected bridge, Swift parser, and native subtitle resolver so those tracks can reach VLC instead of disappearing before playback.

      +
      + Date: 2026-05-25 + Issue: dreamio-9sp + Scope: subtitles, native playback +
      +
      + +
      +

      Summary

      +

      The pasted logs showed Stremio reporting a failed external subtitle track at https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941, while Dreamio logged zero parsed subtitle candidates. The fix adds Stremio subtitle download URL recognition so those URLs are accepted as real external subtitle candidates.

      +
      + +
      +

      Changes Made

      +
        +
      • Updated the injected WebKit subtitle bridge to treat strem.io and *.strem.io download paths as subtitle URLs.
      • +
      • Updated SubtitleCandidateParser to parse the same Stremio download URL shape from Stremio payloads.
      • +
      • Updated SubtitleResolver so Stremio subtitle download URLs are considered directly attachable instead of requiring a second resolver response.
      • +
      • Added a focused regression test for the exact subs5.strem.io/en/download/... form from the runtime log.
      • +
      +
      + +
      +

      Context

      +

      Before this change, Dreamio only accepted direct subtitle file extensions or OpenSubtitles-looking download endpoints. Stremio’s web player can expose external subtitles through a host like subs5.strem.io, where the path identifies a subtitle download but the URL does not end in .srt, .vtt, or similar.

      +

      That mismatch explains the log pattern: bridge inspection saw likely payload objects, but Swift parsed zero usable candidates and the native player only saw embedded VLC subtitle tracks.

      +
      + +
      +

      Important Implementation Details

      +
        +
      • The accepted Stremio pattern is intentionally narrow: hosts must be strem.io or end in .strem.io, and paths must include /download, with language-prefixed forms such as /en/download/... supported.
      • +
      • Image and addon endpoints such as www.strem.io/images/addons/opensubtitles-logo.png are still rejected by the non-subtitle extension filter.
      • +
      • Marking these URLs as direct subtitle files lets VLC receive the URL directly through addPlaybackSlave, which matches the way Stremio labels the track URL.
      • +
      +
      + +
      +

      Relevant Diff Snippets

      +

      Rendered with @pierre/diffs/ssr using one patch per changed file.

      +

      Dreamio/DreamioWebViewController.swift

      Dreamio/DreamioWebViewController.swift
      -1+14
      168 unmodified lines
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      168 unmodified lines
      }
      };
      +
      const isSubtitleURL = (url) => {
      if (!url || isOpenSubtitlesManifestID(url)) {
      return false;
      }
      return !isProbablyNonSubtitleAssetURL(url)
      && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url));
      };
      +
      const findResolverURL = () => {
      168 unmodified lines
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      168 unmodified lines
      }
      };
      +
      const isStremioSubtitleDownloadURL = (url) => {
      try {
      const parsed = new URL(url, window.location.href);
      const host = parsed.hostname.toLowerCase();
      const path = parsed.pathname.toLowerCase();
      return host === "strem.io" || host.endsWith(".strem.io")
      ? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path)
      : false;
      } catch (_) {
      return false;
      }
      };
      +
      const isSubtitleURL = (url) => {
      if (!url || isOpenSubtitlesManifestID(url)) {
      return false;
      }
      return !isProbablyNonSubtitleAssetURL(url)
      && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url));
      };
      +
      const findResolverURL = () => {
      +

      Dreamio/StreamCandidate.swift

      Dreamio/StreamCandidate.swift
      +13
      255 unmodified lines
      256
      257
      258
      259
      260
      261
      20 unmodified lines
      282
      283
      284
      285
      286
      287
      255 unmodified lines
      }
      guard isDirectSubtitleFile(url)
      || isOpenSubtitlesDownloadURL(url)
      else {
      return nil
      }
      20 unmodified lines
      || path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
      }
      +
      private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
      guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
      return false
      255 unmodified lines
      256
      257
      258
      259
      260
      261
      262
      20 unmodified lines
      283
      284
      285
      286
      287
      288
      289
      290
      291
      292
      293
      294
      295
      296
      297
      298
      299
      300
      255 unmodified lines
      }
      guard isDirectSubtitleFile(url)
      || isOpenSubtitlesDownloadURL(url)
      || isStremioSubtitleDownloadURL(url)
      else {
      return nil
      }
      20 unmodified lines
      || path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
      }
      +
      private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
      guard let host = url.host?.lowercased(),
      host == "strem.io" || host.hasSuffix(".strem.io")
      else {
      return false
      }
      +
      let path = url.path.lowercased()
      return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
      || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
      }
      +
      private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
      guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
      return false
      +

      Dreamio/StreamResolver.swift

      Dreamio/StreamResolver.swift
      +14
      130 unmodified lines
      131
      132
      133
      134
      135
      136
      1 unmodified line
      138
      139
      140
      141
      142
      143
      130 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 {
      1 unmodified line
      return lowercased.contains("opensubtitles")
      || lowercased.contains("/subtitle")
      || lowercased.contains("subtitle")
      }
      +
      private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
      130 unmodified lines
      131
      132
      133
      134
      135
      136
      137
      1 unmodified line
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      130 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 {
      1 unmodified line
      return lowercased.contains("opensubtitles")
      || lowercased.contains("/subtitle")
      || lowercased.contains("subtitle")
      || isStremioSubtitleDownloadURL(url)
      }
      +
      private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
      guard let host = url.host?.lowercased(),
      host == "strem.io" || host.hasSuffix(".strem.io")
      else {
      return false
      }
      +
      let path = url.path.lowercased()
      return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
      || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
      }
      +
      private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
      +

      Tests/StreamResolverTests.swift

      Tests/StreamResolverTests.swift
      +25
      12 unmodified lines
      13
      14
      15
      16
      17
      18
      221 unmodified lines
      240
      241
      242
      243
      244
      245
      12 unmodified lines
      testOpenSubtitlesNestedAttributesFilesParsing()
      testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
      testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
      testOpenSubtitlesV3DownloadResponseResolution()
      testOpenSubtitlesNestedDownloadResponseResolution()
      await testSubtitleResolverDownloadJSONReturningLink()
      221 unmodified lines
      assertEqual(candidates[0].label, "English")
      }
      +
      private static func testOpenSubtitlesV3DownloadResponseResolution() {
      let payload = """
      {
      12 unmodified lines
      13
      14
      15
      16
      17
      18
      19
      221 unmodified lines
      241
      242
      243
      244
      245
      246
      247
      248
      249
      250
      251
      252
      253
      254
      255
      256
      257
      258
      259
      260
      261
      262
      263
      264
      265
      266
      267
      268
      269
      270
      12 unmodified lines
      testOpenSubtitlesNestedAttributesFilesParsing()
      testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
      testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
      testStremioSubtitleDownloadURLParsing()
      testOpenSubtitlesV3DownloadResponseResolution()
      testOpenSubtitlesNestedDownloadResponseResolution()
      await testSubtitleResolverDownloadJSONReturningLink()
      221 unmodified lines
      assertEqual(candidates[0].label, "English")
      }
      +
      private static func testStremioSubtitleDownloadURLParsing() {
      let payload: [String: Any] = [
      "subtitles": [
      [
      "label": "English",
      "lang": "eng",
      "url": "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941"
      ],
      [
      "label": "Not a subtitle",
      "url": "https://www.strem.io/images/addons/opensubtitles-logo.png"
      ]
      ]
      ]
      +
      let candidates = SubtitleCandidateParser.candidates(in: payload)
      +
      assertEqual(candidates.count, 1)
      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 testOpenSubtitlesV3DownloadResponseResolution() {
      let payload = """
      {
      +
      +
      + +
      +

      Expected Impact for End-Users

      +

      When Stremio exposes external subtitles through subs*.strem.io download URLs, Dreamio should now carry those subtitles into the native VLC player instead of showing only embedded subtitle tracks in the UI.

      +
      + +
      +

      Validation

      +
        +
      • Passed: swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests
      • +
      • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build
      • +
      +
      + +
      +

      Issues, Limitations, and Mitigations

      +
        +
      • This was validated against the URL shape visible in the logs, not by replaying the exact remote Stremio session inside the app.
      • +
      • If Stremio introduces a different subtitle CDN path that does not include /download, another narrow allow-list entry may be needed.
      • +
      • The existing debug logging should now show parsed candidates for this URL form, which makes the next runtime check straightforward.
      • +
      +
      + +
      +

      Follow-up Work

      +

      No new follow-up issue was filed. The next useful check is runtime validation on the same episode: look for parsed=1, nonzero native subtitle candidates, and a [DreamioVLC] attach accepted subtitle=... line for the Stremio subtitle download URL.

      +
      +
      + + \ No newline at end of file From 046444f9adb2a49b47416510259aca31a0887cfa Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:50:18 -0400 Subject: [PATCH 21/21] preserve subtitle labels for opensubtitles tracks --- .../UserInterfaceState.xcuserstate | Bin 13916 -> 13916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index dfff5d4d0588b44f442decaf84b8c18bc04230ca..9c4fb737860018a6080069cbce260c485395e676 100644 GIT binary patch delta 249 zcmcbUb0=p*F%zdBb1-uVb0~A{<{G9;?5t6SU-wCG4(5vG(TZGRDRQVGG+#iFA%r2H zA%!84p^`xXNR|LeLk1%TD+Ut=Lj`??fX%nWo`SUgC_l3Kh@1c?8-F9CK!f{a2llwh z78;t`ml*gM_?f*KJQgzhG%|8DNL*o1vTGrYi4QWY7}S` tX*6rJX>@AzX!L0u)HtPaUE_|%eT_#N&oy3YywP~4@m1r`W;IhYMgR`~N@@TA delta 261 zcmcbUb0=p*F%zdZvp;hPb0~Az<{G9;?5w_$&)vm02Xn>pX!*Q6{jj|uG+#iFA%r2H zA%!84p^`xXNR|LeLk0r|D+W^rV+DN%$IZ9Io`ST#oLss2h@1c?8%HCfK!f{a2X?W^ z@*2{Uo!Ip!TWP3Ip2#JteTjjOfuGrj!DAt_ZzCgTgTxhvDGXB?rU3;5m;;%E7-lsx z@-#AXH!^Zf?$NMR^_6_3V{(~+7pyIuIf}t-5px7{WFsSQBO~7;=4gh~jg0)0Uumds zR@1!Bty!i~uhFE@s?n}7Lu0naJdK4Ki#4uk+}C)c@loT8#&?ZB8vivJHJLTJHmjMM GF#-S$WJ;g_