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 lines30430530630730830939 unmodified lines349350351352353354303 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              ? element303 unmodified lines30430530630730830931031131231331431539 unmodified lines355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436303 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.