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

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

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

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