mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-22 17:06:36 +00:00
fix(extension): harden player discovery for Crunchyroll
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user