Commit Graph

15 Commits

Author SHA1 Message Date
Timo df86264a77 fix(extension): instant mirror switching and prevent uncaught timeout errors 2026-08-18 21:27:02 +02:00
Timo 226453fd45 fix(extension): release a dead frame election and break the discovery deadlock
Reported as "Receiving end does not exist" with targetReady true and no
activation errors: the election named a frame the player had already torn down.
Three defects were stacked, each hidden by the one in front of it.

The election was never released. getReadyTabVideoState() recovered through the
guarded refresh, which reports "unchanged" when no video is reachable, so the
stale frameId/documentId survived; adoption compounded it by setting hasVideo,
after which the target only moves on a frame change. An unreachable content
script — as opposed to a page that simply has no video yet — now releases the
frame election back to the top frame. The tab selection is never touched.

Switching frames destroyed the top frame's scripts. Promoting the target out of
frame 0 called deactivateTargetTab() on the previous target, which sent
TARGET_DEACTIVATE to frame 0 and tore down its content script and the chat
overlay with it. That is why chat delivery failed after promotion, and why
releasing the election pointed at an empty frame. An in-tab frame switch now
leaves the top frame alone.

Discovery could deadlock. Monitors announce new players, but a rebuilt frame is
a new document with no monitor, so the video created in it was never reported —
and nothing then triggered the upkeep that would have installed one. Monitors
are reinstalled on every lifecycle notification with a trailing-edge debounce,
and a bounded discovery poll breaks the cycle when no notification arrives at
all: 2s, capped, only while a tab is selected with no video found, stopping the
moment one is.

The new browser test adopts a nested player, destroys its document the way the
real player does, and asserts both the release and that the rebuilt player is
picked up again without touching the popup. It was flaky until the deadlock was
closed, and now passes repeatedly at a stable 8.3s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:43:58 +02:00
Timo b54eefdb5a fix(extension): control the player frame without having to elect it first
Reported from the live site: a media title was recognised and audio processing
worked, but play and pause did nothing. Both halves of the command path were
gated on frame election, and the election had named the top frame.

Outbound, commands went to the elected frame alone, which holds no video, so
they were delivered and ignored. Inbound, isCurrentContentSender() required
sender.frameId to equal the elected frame, so the user's own play and pause
arriving from the real player frame were discarded as a stale sender — which is
why the room never saw them.

Neither direction actually needs the election. Every content-script command
handler already begins with findVideo() and returns when there is none, so a
tab-wide broadcast is delivered to all frames and acted on only by the one that
owns the player. And an inbound media event proves where the player is:
sender.frameId is authoritative, costs no permission and has no timing window,
so the reporting frame is adopted as the target and later commands are addressed
directly again.

Both relaxations apply only while the elected frame reports no video. A good
election still takes the strict path, so the hidden-player rejections are
unaffected.

This is the general answer to losing webNavigation.getAllFrames(). That call
observed the frame tree without touching it, so it never had a failure window;
executeScript has to enter every frame and reliably loses that race against a
player which renavigates and rebuilds its video, as Kodik does. The fix is to
stop depending on the answer rather than to keep chasing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:47:12 +02:00
Timo 68f2d9f27c fix(extension): find the player when the all-frames sweep comes back empty
Diagnosed against a fixture rebuilt from the live yummyanime.tv page, with the
ad churn the real site produces. Under that churn the resolver reported
frame=0, hasVideo=false while the video demonstrably existed two frame levels
down: one ad slot tearing down mid-call makes Chromium reject the whole
allFrames sweep, and the resolver then silently fell back to the top frame and
never looked again.

v3.1.2 did not have this failure because webNavigation.getAllFrames() gave it
an explicit frame list. That list is now rebuilt without the permission: every
content script that messages the background carries sender.frameId, so the
background keeps a per-tab registry of frames it has seen and the resolver asks
any frame the sweep missed directly. One rejected probe now costs one frame
instead of the whole page.

Two supporting fixes fell out of the same investigation. Frames reported hidden
by an ancestor that could inspect them directly — the 0x0 same-origin wrapper an
anime host parks unwatched mirrors in — are now excluded without waiting for the
postMessage visibility handshake, which was the tie the resolver kept failing to
break. And leaf frames with no video and no nested frames are left out of that
handshake entirely, so a churning ad slot can no longer make every phase wait on
a frame that is already gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:56:17 +02:00
Timo f450584562 fix(extension): stop the video-state poll from restarting the target
getReadyTabVideoState() treated "no video found" as a broken injection and
forced a full reactivation. On a page that legitimately has no video yet — an
anime or Drive page before playback starts — that fired on every call, and the
dev panel polls it on a timer. The result was an endless teardown and
reinjection cycle: the target never settled, the popup showed "activating"
forever, and the panel reported "Target tab changed before content script
recovery completed" because each read raced the reactivation it had triggered.

Only an unreachable content script justifies recovery now, and that recovery no
longer reinjects unless the selected frame actually moved.

Audited against v3.1.2, which worked on these pages. The only unjustified
deviation left was the retry budget, which had been cut from eight passes to
three and shortened the window for a late-loading player; it is back at eight,
now bounded by a wall-clock deadline instead of being unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 01:05:03 +02:00
Timo 096775d39f fix(extension): make anime-style nested players selectable again
Reproduced against the live yummyanime.tv layout, which is:

  top (no video)
  ├── visible same-origin wrapper 830x498 -> cross-origin player
  ├── hidden same-origin wrapper    0x0   -> cross-origin mirror
  └── hidden cross-origin trailer   0x0

Three things kept that page from ever settling on a target.

Equally-ranked players were a hard failure. Several mirrors or dubs loaded at
once is an ordinary layout for these sites, and refusing to activate made them
unusable. The resolver now holds the top frame and waits for one of them to
start playing, which is the signal that breaks the tie.

Inconclusive probes moved the target. A page whose players are still loading
resolves differently from one call to the next, and every difference triggered
a full teardown and reinjection, so activation never finished — the popup sat
on "activating" with nothing in the log. A probe that finds no video now leaves
the target where it is.

The visibility handshake expired mid-probe. Its listener lived 1000ms while the
probe sequence is six separate executeScript round trips; on a heavy page it
was gone before the answer arrived, leaving every frame's visibility unknown —
the exact state that makes two players look equal. It now outlives the sequence.

A settled failure also no longer reports itself as "activating".

Covered by two fixtures built from the real page: one where the player exists
up front, and one where the host only creates it on play, asserting the target
is promoted into the deep cross-origin frame without touching the popup again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:47:29 +02:00
Timo fd42de365a fix(extension): keep the chosen tab selected when activation fails
Selecting a tab and reopening the popup showed no target again. The selection
only ever existed as currentTabId, which means "the tab we successfully
injected into". Any activation failure — a player frame needing host access, a
page still loading, a document that navigated mid-injection — cleared it, so
the user's choice disappeared along with the failure.

The choice is now stored in its own right, persisted before activation starts
and kept across a failed one. GET_STATUS reports it as targetTabId with a
terminal state next to it: ready, activating, access_required or error, plus
the underlying message. The popup already highlights targetTabId, so the tab
stays visibly selected and can explain itself instead of silently vanishing.

Nothing retries on its own. Reactivation happens only when the user selects
again or grants the missing host access, which is what turned the previous
attempt at this into an endless reinjection loop on every popup open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:28:50 +02:00
Timo 75a9ba5d3d fix(extension): control nested players without permission prompts or churn
Google Drive and YummyAnime host their player in a cross-origin iframe. The
3.1.2 targeting work reached those frames but misdiagnosed and destabilized
them in four separate ways. No manifest permission is added or restored;
webNavigation stays removed.

Access diagnosis was inferred, not measured. Every frame probe error was
swallowed, and any origin that failed to answer was reported as missing host
access. A slow or still-loading player frame therefore produced
"Host access required for youtube.googleapis.com" for an origin the extension
already held. The resolver now asks permissions.contains() before raising an
access error, and treats a granted-but-unresponsive origin as a retry, not a
user decision.

Probes were unbounded. Every executeScript in the resolver now runs under a
timeout, so one unreachable frame can no longer stall an activation, and the
retry budget drops from eight passes to three.

The chat overlay followed the player into its frame, which rendered it on top
of the video and scoped closing and minimizing to that frame. It is now always
installed in the tab's top document, with all chat traffic routed to frame 0,
while only the playback controller goes into the selected media frame.

Nested targets reactivated continuously. Every heartbeat and content event
revalidated the target with a full teardown and reinjection, and the media
monitor treated ordinary play, pause and buffering as frame layout changes.
Both paths now reactivate only when the selected frame or document actually
moves.

Also restores the audio-route retention that keeps a deselected tab audible:
createMediaElementSource() can only be called once per element, so a
reinjected content script must adopt the existing route rather than rebuild it.

Verified with 90 unit tests, 40 browser E2E tests including two new
Drive-shaped fixtures that assert the controller lands in the player frame
while the chat stays in the top document, and npm run verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:15:10 +02:00
Timo e1daeecab1 fix(extension): harden target frame recovery and switching 2026-08-17 23:06:46 +02:00
Timo 3c99efe4e4 fix(extension): persist dynamic target selection 2026-08-17 22:10:11 +02:00
Timo 4d7897028b fix(extension): remove webNavigation permission 2026-08-17 18:41:40 +02:00
KoalaDev 082b69f509 fix(extension): support cross-origin media frames 2026-08-17 16:49:53 +02:00
KoalaDev ef1fdc89c3 test: prove the frame lifecycle fixes against the real extension
The evidence for the frame-observation fixes was a reimplementation of the
logic measured in a browser console, not the shipped code. Two extension
specs now cover the observable behaviour end to end: a player frame that
swaps its document, and the same one level deeper.

The nested case fails against the pre-fix content.js and passes now. The
top-level case already passed before the fix, so that fix removed dead
observer registrations without changing what a user could see; recorded
here so the distinction is not lost.

Also adds bench-finder.mjs, which measures the extracted shipped finder
instead of a transcription of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 07:53:45 +02:00
KoalaDev 66971150c7 test: cover the popup blacklist UI, tighten audit findings
The delta model was only covered at module level; the popup wiring around
it was not exercised at all. Seven specs now drive the real settings UI in
the packed extension, including the migration path: a pre-v3.1.0 snapshot
is converted on open, the legacy key is removed, and a default missing
from that snapshot is delivered again.

Also from the audit pass:
- await the blacklist read in init instead of firing a floating promise
- unify the debug report on the finder's own candidate list, which the
  separate traversal missed shadow-DOM videos from
- assert that a single candidate is always returned regardless of its
  ranking signals, so no scoring signal can regress a single-player site

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 07:39:41 +02:00
KoalaDev 36e1291d2c 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>
2026-08-14 07:23:12 +02:00