feat: rank player candidates by ordered signals, add browser E2E suite

The weighted score summed incomparable units, so size could outvote traits
that disqualify an element outright. Measured on a real page: a display:none
preload reports its full 1080p intrinsic size and scored 2073600, beating a
visible unmuted player at 509920.

Selection now compares an ordered list of signals, highest priority first:
has a source, is rendered, is not a silent background loop, rendered size
bucket, is playing, has controls, duration. Rendered size replaces intrinsic
resolution, and mute state is gone from the ranking entirely: it is a viewer
preference, not evidence about which element is the player.

It stays a ranking rather than a filter, so a page of only bad candidates
still yields one and findVideo never returns null where a video exists.

The new tests/e2e suite runs the shipped finder against real fixture pages
and drives the packed extension for injection, reinjection and remote
play/pause/seek into a first-party frame. All five scoring scenarios fail
against the previous implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-08-14 07:23:12 +02:00
parent 79a2204a97
commit 36e1291d2c
30 changed files with 781 additions and 29 deletions
+78 -22
View File
@@ -566,18 +566,21 @@
};
tryOnce();
tryOnce();
}
}
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
// player buffering — and a player can't actually play while it's stalled. Snapping
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
// looks like stutter. Instead: if the player isn't ready, wait until it can play
// player buffering — and a player can't actually play while it's stalled. Snapping
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
// (re-queried, since the captured target may be stale by the time buffering ends).
@@ -589,28 +592,81 @@
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
hcmDeferredSnapPending = true;
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
const poll = () => {
// Abort if the reason to snap is gone: user went solo, we're no longer a
const poll = () => {
// gated guest, or the video vanished.
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
const ready = video && video.readyState >= 3 && !video.seeking;
if (ready || Date.now() >= deadline) {
hcmDeferredSnapPending = false;
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
return;
}
scheduleLifecycleTimeout(poll, 300);
};
poll();
}
// Entry point: background told us our local action was blocked in host-only.
function hcmHandleBlocked(action, target) {
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
// host-only + !host before sending), so it's authoritative. Adopt the
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
if (intent === 'live') return; // EC-15: leave the guest alone on live
if (intent === 'involuntary') {
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
// cooldown — the deliberate dialog path below must still go through,
// otherwise a second deliberate pause inside the cooldown window leaves
// the user stuck paused with no UI (M-3).
// gated guest, or the video vanished.
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
const ready = video && video.readyState >= 3 && !video.seeking;
if (ready || Date.now() >= deadline) {
hcmDeferredSnapPending = false;
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
// Buffering/ads/throttle — silently re-sync, no dialog spam.
const video = findVideo();
if (video && video.readyState >= 3 && !video.seeking) {
// Ready now → snap immediately. Use the captured target if it's