fix(extension): harden player discovery for Crunchyroll

This commit is contained in:
KoalaDev
2026-08-21 12:15:44 +02:00
parent 1e41d98c2f
commit 258f66f240
8 changed files with 127 additions and 2 deletions
+22
View File
@@ -5,6 +5,28 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.1.4] — 2026-08-21
This patch restores player detection on pages that use boxless CSS wrappers,
including Crunchyroll, without adding site-specific matching.
### Fixed
- **Extension: Crunchyroll playback synchronization** — Detects and controls
Crunchyroll's visible Bitmovin HTML5 player even though the page wraps it in
a boxless `display: contents` ancestor. The general fix keeps equivalent
players selectable on other sites while continuing to reject hidden players,
preloads, ads and background videos.
- **Extension: Player discovery during iframe churn** — Keeps stable nested
player frames discoverable when rapidly replaced ad frames make Chromium
reject an aggregate frame sweep, without restoring the `webNavigation`
permission or injecting globally into unselected tabs.
### Testing
- **Regression coverage** — Adds unit and browser E2E coverage for visible
players inside `display: contents` wrappers.
---
## [v3.1.3] — 2026-08-18
This release adds generic control for HTML5 players inside nested and cross-origin
+6 -1
View File
@@ -20,7 +20,7 @@ This document tracks which streaming platforms and media servers are supported b
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | — | — | — | Series title only (e.g. "The Simpsons"), no episode info. |
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — | — | — | — |
| **HBO Max / Max** | Not tested | Not tested | Not tested | — | — | — | — |
| **Crunchyroll** | Not tested | Not tested | Not tested | — | — | — | — |
| **Crunchyroll** (`crunchyroll.com`) | ✅ Full | ⚠️ Partial | ❌ | 2026-08-21 | Shik3i | v3.1.4 | Manual testing on the live service confirmed playback synchronization with its top-level Bitmovin HTML5 player. The Media Session exposes the episode as `E1 - Prologue` and the series as artist metadata, but the current episode parser does not recognize the abbreviated `E1` form for episode auto-sync. |
| **Vimeo** | Not tested | Not tested | Not tested | — | — | — | — |
| **Dailymotion** | Not tested | Not tested | Not tested | — | — | — | — |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — | — | — | — |
@@ -81,3 +81,8 @@ The currently verified cross-origin service topologies are:
- **YummyAnime:** `yummyanime.tv` top page → same-origin wrapper → `thealloha.club` player.
These embedded origins are implementation details of the services and may change independently. If the browser withholds access to a newly used player origin, KoalaSync asks for that origin through its normal site-access flow.
Crunchyroll currently exposes its Bitmovin `<video>` directly in the top-level
document. Its application layout uses a `display: contents` wrapper, which has
no box of its own even while the descendant player is visible; v3.1.4 handles
that standards-compliant layout without a Crunchyroll-specific host rule.
+40
View File
@@ -2622,10 +2622,46 @@ function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_
});
}
/**
* Runs as a tiny all-frame beacon before the heavier monitor injection.
* Chromium can reject an allFrames result wholesale when one unrelated ad
* frame disappears mid-sweep, even though stable frames already executed the
* function. Those stable frames announce their ids through the sender metadata,
* letting the next step address them individually without webNavigation.
*/
async function announcePotentialMediaFrame() {
let relevant = false;
try {
const identity = `${window.location.href} ${window.name || ''}`;
relevant = !!document.querySelector('video, iframe, frame')
|| /player|video|stream|watch|embed|media|xfp/i.test(identity);
} catch { /* inaccessible or already-detached document */ }
if (!relevant) return false;
try {
await chrome.runtime.sendMessage({ type: 'MEDIA_FRAME_DISCOVERED' });
} catch { /* extension context or document disappeared */ }
return true;
}
async function injectMediaFrameMonitors(tabId, contentTarget) {
// The sweep is best effort; the known frames are addressed individually so a
// rejected sweep cannot leave the deep player frame without a monitor — and
// therefore without any way to report itself later.
try {
const discoveries = await executeScriptWithTimeout({
target: { tabId, allFrames: true },
func: announcePotentialMediaFrame
}, 750);
for (const entry of discoveries || []) {
if (entry?.result === true) rememberFrameId(tabId, entry.frameId);
}
} catch {
// Stable frames still announce themselves if a disappearing ad frame
// makes Chromium reject the aggregate allFrames result.
}
// Give those sender messages one task boundary to update the registry before
// taking the snapshot used for individual monitor injections below.
await new Promise(resolve => setTimeout(resolve, 0));
const targets = [
...listMediaFrameScriptTargets(tabId),
...listKnownFrameIds(tabId)
@@ -3756,6 +3792,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const senderTabId = normalizeTabId(sender?.tab?.id);
if (senderTabId !== null) rememberFrameId(senderTabId, sender?.frameId);
if (message.type === 'MEDIA_FRAME_DISCOVERED') {
sendResponse({ status: 'ok' });
return;
}
const mediaLifecycleMessage = message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'
|| message.type === 'MEDIA_FRAME_VISIBILITY'
|| message.type === 'MEDIA_TARGET_REFRESH';
+8 -1
View File
@@ -659,7 +659,14 @@
|| Number(style.opacity) === 0)) {
return false;
}
if (typeof current.checkVisibility === 'function'
// `display: contents` deliberately gives the wrapper no box, so
// Chromium reports the wrapper itself as not visible even while
// its children are fully rendered. Crunchyroll's player layout
// uses exactly that shape. The explicit CSS checks above still
// reject genuinely hidden ancestors; skip only this boxless
// wrapper case when walking up from the video.
if (style?.display !== 'contents'
&& typeof current.checkVisibility === 'function'
&& !current.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
return false;
}
+3
View File
@@ -105,6 +105,9 @@ describe('target tab lifecycle', () => {
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('async function announcePotentialMediaFrame()');
expect(backgroundSource).toContain("{ type: 'MEDIA_FRAME_DISCOVERED' }");
expect(backgroundSource).toContain('func: announcePotentialMediaFrame');
// Monitors must reach the frames we know about, not only whatever the
// all-frames sweep happens to accept — both on the way in and out.
expect(backgroundSource.match(/\.\.\.listMediaFrameScriptTargets\(tabId\),/g)?.length).toBe(2);
+31
View File
@@ -184,6 +184,37 @@ assert.strictEqual(
'a hidden playing preload must not outrank the visible paused player'
);
// Crunchyroll wraps its Bitmovin player in `display: contents`. Such a wrapper
// has no box of its own and checkVisibility() returns false for the wrapper,
// even though the descendant video is fully visible.
const displayContentsPlayer = makeVideo('display-contents-player', 1920, 1080, {
controls: false,
paused: true,
duration: 1420
});
const displayContentsWrapper = {
_style: { display: 'contents', visibility: 'visible', opacity: '1' },
checkVisibility() { return false; },
parentElement: null
};
displayContentsPlayer.parentElement = displayContentsWrapper;
displayContentsPlayer.checkVisibility = () => true;
const displayContentsDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [displayContentsPlayer];
return [];
}
};
attachRenderEnvironment(
displayContentsDocument,
[displayContentsPlayer, displayContentsWrapper]
);
assert.strictEqual(
findVideo(displayContentsDocument),
displayContentsPlayer,
'a visible player inside a display: contents wrapper must remain selectable'
);
const belowFoldPlayer = makeVideo('below-fold-player', 800, 450, {
controls: true,
duration: 1200
+1
View File
@@ -13,6 +13,7 @@ const SCENARIOS = [
{ page: 'nested-frame.html', expected: 'framed-player', what: 'a player two frame levels down' },
{ page: 'shadow-player.html', expected: 'shadow-player', what: 'a player inside a shadow root over a light-DOM teaser' },
{ page: 'muted-player.html', expected: 'player', what: 'the only player even when muted' },
{ page: 'display-contents-player.html', expected: 'player', what: 'a visible player inside a boxless display-contents wrapper' },
{ page: 'hidden-preload.html', expected: 'player', what: 'the visible player over a hidden higher-resolution preload' },
{ page: 'ad-frame.html', expected: 'player', what: 'the real player over a muted ad in a first-party frame' },
{ page: 'background-loop.html', expected: 'player', what: 'the real player over a large looping background video' },
@@ -0,0 +1,16 @@
<!doctype html>
<meta charset="utf-8">
<title>Display contents player</title>
<h1>Visible player inside a boxless wrapper</h1>
<div id="app-contents" style="display: contents">
<div class="video-player-wrapper">
<video id="player" data-expected width="854" height="480" src="../media/player-480p-12s.mp4"></video>
</div>
</div>
<script>
const wrapper = document.getElementById('app-contents');
if (wrapper.checkVisibility() !== false || wrapper.getBoundingClientRect().width !== 0) {
throw new Error('display: contents fixture must expose a boxless wrapper');
}
</script>
<script src="ready.js"></script>