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.
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.postMessageand listened for windowmessageevents. - Wrapped constructed
Workerinstances so messages to and from Stremio workers are inspected. - Wrapped
MessagePort.postMessageand message listeners for channel-based state transport. - Wrapped
BroadcastChannelconstruction 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 OpenSubtitlesfile_idhandling 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, orbroadcast-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
303 unmodified lines30430530630730830939 unmodified lines349350351352353354303 unmodified linespostSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);};const originalFetch = window.fetch;if (originalFetch) {window.fetch = async (...args) => {39 unmodified linesreturn originalXHRSend.apply(this, args);};const stopNativeHandledMedia = (element) => {const media = element instanceof HTMLVideoElement? element303 unmodified lines30430530630730830931031131231331431539 unmodified lines355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436303 unmodified linespostSubtitleInspection(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 linesreturn 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, orbroadcast-channel.messagewithparsedabove 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.