Commit Graph

174 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 bf0dfb82c6 fix(extension): report a video the monitor finds when it installs
A monitor took the current DOM as its baseline on install, so a video that was
already present counted as "not a change" and was never announced. The discovery
poll added in the previous commit reinstalled monitors every 2s, which meant a
video appearing between two reinstalls was silently swallowed — the reported
debug log had no [Content] lines at all, which is the signature of exactly this.

Monitors now announce a video that is already there when they install, which
also makes the rebuilt-frame case work by construction rather than by timing.
The reinstall interval is raised to 5s now that each install is informative.

Also adds docs/frame-targeting-handoff.md: why v3.1.2 worked immediately with
webNavigation, why reconstructing that single call from sweeps, a learned
registry, per-frame monitors and a poll keeps producing timing windows, and the
proposed structural replacement (chrome.scripting.registerContentScripts with
allFrames, no new permission) together with the project invariant it conflicts
with — which is the owner's decision, not a code change to make unasked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:54:53 +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 e0c68650c5 perf(extension): cut target selection from ~20s to under 3s
Selecting an anime tab took long enough that it read as broken. Measured against
the two anime fixtures, the cost was three multiplying blocks, none of which was
doing useful work.

The visibility handshake ran on every page with more than one frame, including
pages where no frame had a video at all. Its only purpose is to rank and exclude
video candidates, so with nothing to rank it was several seconds of pure waiting
per attempt. It is now skipped unless a candidate exists.

Its pass count was fixed at four, the worst-case same-origin nesting depth. It
now scales to the depth actually observed, which is two on these players, and
each surplus pass was a full round trip across every frame.

The retry budget was spent waiting for a video that no frame had. Retrying
cannot conjure one, and the injected monitor promotes the real player within a
fraction of a second of it appearing, so the loop stops instead — and only
retries when the sweep itself came back thin, which is the case a second pass
can actually fix.

Both probe timeouts were also far too generous. inspectMediaFrame and the
monitor injection are synchronous DOM work: a live frame answers in tens of
milliseconds and anything slower is a frame being torn down, which is exactly
what an ad slot is. 2000ms down to 750ms; a frame dropped there is re-probed on
the next attempt and reports itself through its monitor anyway.

Measured, calm page then heavy ad churn:
  selection  2.5s / 2.6s   (was 2.5s / 7.2s, and ~12s before this series)
  promotion  0.34s / 2.7s  (was 0.35s / 6.4s, and ~15s before this series)

The remaining time is the injection chain itself, not discovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:03:40 +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 0bb203320e fix(extension): stop the frame registry from being wiped mid-setup
Inspected the live KODIK layout: the player is a cross-origin frame at depth 2
inside a visible same-origin wrapper, with no sandbox attribute and no video
element at all until playback starts.

The registry wipe added in the previous commit was wrong. tabs.onUpdated reports
status 'loading' for same-document History API navigations as well, which is
exactly what these sites do when you switch mirror or episode part. The wipe
therefore landed while the player frame was being built, leaving the recovery
probe with nothing to fall back on; if the all-frames sweep was rejected at that
moment the target stayed on frame 0 with no way back.

The registry now self-corrects instead: a probe that reached more than the top
frame is authoritative and current, so it supersedes the stored ids. A probe
that only reached frame 0 proves nothing and merges. Stale ids cost one bounded,
isolated probe until the next good resolve replaces them.

This matches the reported symptom, where audio boost and compressor worked on
KODIK — proving the content script was running in the player frame — while the
dev panel still reported frame 0 with no video, because the target had fallen
back after promotion had already succeeded once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:32:24 +02:00
Timo 414be96432 fix(extension): close the frame-registry gaps found in a manual audit
Read the whole target workflow end to end rather than re-running the suite.
Five defects, none of which the existing tests could have caught.

A srcless iframe resolves to its parent document's URL, so a hidden ad slot
could mark the page containing it as hidden and exclude the real player. The
hidden-frame filter now requires the frame element to have actually carried a
src, and ignores any frame claiming the href of the document that reported it.

The frame registry was fed only by incoming sender.frameId, which is empty
during the first activation — exactly when the all-frames sweep needs a
fallback. The resolver now reports every frame it reached and those ids are
recorded before anything is injected.

Monitor injection and deactivation both went through the sweep alone. A
rejected sweep therefore left deep frames without a monitor (so they could
never report themselves, keeping the registry empty) and, on the way out, left
stale monitors reporting after a target switch. Both now address known frames
individually as well.

Registry eviction skipped when the oldest entry was the top frame, so the set
could grow without bound, and a cap of 64 meant up to 64 individual probes —
the same cost the removed 0..64 sweep had. The cap is 24, eviction always
removes a non-top frame, and a committed navigation drops the tab's ids so dead
frames from the previous page are not probed forever.

The stuck-activation watchdog only ran inside GET_STATUS, so it never fired
while the popup was closed — the one situation it exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:25:19 +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 ac0093b043 fix(extension): stop a single unresponsive frame from stalling activation
v3.1.2 worked on the nested anime players. Removing the webNavigation
permission in 4d78970 replaced its per-frame listing with one allFrames call
and left the injection path unbounded, and that is what broke them.

Both properties are restored without the permission. The allFrames sweep now
only discovers the frame list — it already reports frameId and documentId for
every frame it reached — and each probe after it is addressed to a single
frame, so a player or ad frame that never answers can no longer cancel the
others. Every chrome.scripting.executeScript in the injection path is bounded
by a timeout; nine of them could previously stay pending forever, which pinned
activeTargetActivation and left the popup reporting "activating" with nothing
in the log.

A watchdog abandons any activation still running after 30s and turns it into a
reportable error, so that state cannot be permanent again regardless of cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:55:12 +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 aa5173e0d4 fix(extension): report why a target failed instead of a generic comm error
The dev panel asked the content script for video state even when activation
never completed, so every failure surfaced as "communication with the tab
video failed" — the one message that says nothing about the cause. It now
shows the activation state and its actual error, and content injection logs
the frame it was aimed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:36:23 +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 04694d4439 revert(extension): drop post-3.1.2 frame-targeting band-aids
Restores extension/ to the state directly after webNavigation was removed
(4d78970). The six follow-up commits layered heuristics on an unverified
premise (frame-ID sweeps, multi-phase probes, retry loops) without fixing
the underlying resolver. They are removed so the real fix can be built on
a known state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:53:10 +02:00
Timo 804c36c694 fix(extension): bound frame injection and retry failures 2026-08-17 23:37:09 +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 8eaf026487 fix(extension): harden target and chat recovery 2026-08-17 21:44:41 +02:00
Timo bce582b568 fix(extension): preserve target and chat visibility state 2026-08-17 21:33:19 +02:00
Timo 1e6778ba93 fix(extension): recover stale media frame targets 2026-08-17 21:14:56 +02:00
Timo 4d7897028b fix(extension): remove webNavigation permission 2026-08-17 18:41:40 +02:00
KoalaDev a8fd79618f fix(extension): cancel superseded frame monitors 2026-08-17 17:11:47 +02:00
KoalaDev 082b69f509 fix(extension): support cross-origin media frames 2026-08-17 16:49:53 +02:00
KoalaDev 79a2204a97 feat: store the hide-clutter list as a delta, not a snapshot
Saving the editor used to persist the full effective list, which froze the
shipped defaults at that moment: every default added in a later version
never reached anyone who had touched the list once.

Storage now keeps only { removedDefaults, addedDomains }, and the effective
list is resolved against whatever ships in the running version. Entries are
tagged default or user, the editor groups them under comment headers, and
'#' lines are ignored on save. Lists saved by earlier versions are migrated
to the delta form on first read and the legacy key is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:57:41 +02:00
Timo 48d6c1dc0b feat:local-blacklist-and-audio-boost 2026-08-12 02:07:34 +02:00
Timo 8050748e61 fix(extension): harden chat and target tab lifecycle 2026-07-31 09:48:22 +02:00
Timo 0b6ae803c8 feat: complete docked chat interactions 2026-07-29 22:31:40 +02:00
Timo 63db02211b fix(chat): harden overlay interactions and notifications 2026-07-26 04:14:25 +02:00
Timo 9251ae6aff add dedicated chat settings 2026-07-26 02:12:28 +02:00
Timo 77790a279c fix(chat): preserve mixed-version compatibility 2026-07-26 01:02:20 +02:00
Timo e9d735cd39 Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	extension/background.js
#	extension/popup.js
2026-07-19 20:32:24 +02:00
Timo fe13275b5d Harden host access recovery races 2026-07-15 13:31:56 +02:00
Timo 142153a131 Harden host access recovery for v2.6.2 2026-07-15 12:46:57 +02:00
Timo 7e7e60f267 Fix blocked website access and add troubleshooting page 2026-07-15 12:24:59 +02:00
KoalaDev 9e0d294758 fix(chat): harden client state and messaging 2026-07-15 08:40:27 +02:00
KoalaDev e9dc299fc9 feat(chat): add in-page overlay 2026-07-15 07:23:02 +02:00
KoalaDev ee0d68d8d3 feat(chat): add client encryption and key lifecycle 2026-07-15 07:10:32 +02:00
Timo 9e8a446d97 fix: resolve Episode Lobby stuck loading state and prevent triggers on non-episodic media 2026-07-12 16:57:06 +02:00
KoalaDev 1f1844e99b Fix bamboo stalk taper and align stray colors to palette
The stalks used border-radius:50%, which renders a tall ellipse whose
ends taper to points, and unbounded parallax drift pushed them off the
bottom of the viewport on long pages. Rounded caps + 20% overscan +
a viewport-relative drift clamp keep them full-height while scrolling.

Sweep the remaining off-palette colors into the nature palette:
content-script overlays (slate grays), amber warnings/badges/star
accents (now terracotta) in popup, background badge and website
illustrations, and the support-heart red (now --danger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:28:50 +02:00
Timo 869c64171b Fix Disney+ force sync, seek, and HCM regressions for v2.5.3
The v2.5.2 Disney+ page-API integration leaked blob-relative <video>
time into force sync, seeks, and heartbeats when the page-API bridge
had no fresh data, so force sync on Disney+ appeared broken.

- getSyncCurrentTime/getSyncDuration now refuse native values on Disney+
  (return null/0) so stale bridge data degrades to a clean no-op instead
  of broadcasting garbage to peers. The get_current_time handler and
  episode/lobby/hcmIsLive paths are routed through the same accessor.
- Validate FORCE_SYNC_PREPARE/SEEK payloads as finite before relaying;
  the internal coercion no longer treats '' as 0.
- Stop double-routing FORCE_SYNC_PREPARE from the popup path (the
  generic popup route now covers only play/pause/seek).
- popup force-sync: exclude null/empty peer times from the jump-to-others
  median (Number(null)===0 was dragging the target to 0), guard against
  NaN end-to-end, clear the dangling reset timer on failure, and retry
  without re-injecting when the content script responds but the Disney+
  bridge has not yet delivered a finite time.
- hcmIsLive skips the native-duration live signal on Disney+ only,
  preserving YouTube/Twitch Infinity-duration live detection.

Disney-specific logic remains strictly gated to disneyplus.com; no
Netflix/YouTube/Twitch/generic path is affected.
2026-07-02 16:57:48 +02:00
Timo 076157fef1 Sync Disney+ via the page media player API (precise time + seek)
Disney+'s <video> is blob-relative (unusable as an absolute clock) and its
scrubber aria-value freezes during playback, so DOM scraping lagged and the
+/-10s button seek could neither reach far targets nor land precisely. The
real player hangs off the <disney-web-player> custom element as
`.mediaPlayer`, exposing seek(ms) and timeline.info (playhead/duration ms).

Since the isolated content world can't read that page object, route it
through the existing MAIN-world page-API bridge (as Netflix already does):

- page-api-seek-overrides.js: register a 'disney' provider.
- background.js installPageApiSeekBridge: seek Disney via mediaPlayer.seek(),
  and post the exact playhead/duration (seconds) to the content world every
  250ms. Both are gated on provider === 'disney'; Netflix path unchanged.
- content.js: cache the pushed playhead, prefer it in getDisneyPlusTimeline
  (DOM scraping stays as fallback), and check the page-API seek first in
  seekVideo. Outcome is identical for Netflix and generic sites.

Verified live on Disney+: reported time matches the player exactly and
seek lands within ~1s of the target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:56:02 +02:00
Timo 51da169d69 Remove temporary Media Session interceptor and Video Event Log diagnostics
Clean up the exploratory Disney+ diagnostics now that the Shadow DOM
timeline/seek integration is in place:

- Remove Proposal 1 (Media Session interceptor): the background.js
  interceptor injection and the content.js message listener capturing
  __koalaMediaSessionCapture, plus the now-unused mediaSessionPosition
  reporting in content.js and popup.js.
- Remove Proposal 3 (Video Event Log): videoEventsLog, logVideoEvent(),
  the per-video event logging listeners, and the popup.html/popup.js UI.
- Keep Proposal 2 (DOM timestamp scraper) for ongoing diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:18:50 +02:00
Timo 066d2c9407 Implement temporary Media Session interceptor diagnostics for Disney+ and restore Netflix sessionId lookup guard 2026-07-02 13:19:47 +02:00
Timo ce522e2ab7 Safeguard Netflix player session ID lookup in background.js 2026-07-02 13:01:14 +02:00
Timo beda924b65 Fix DEV_SIMULATE_REMOTE_SEEK targetTime routing in background.js and add build identifier 2026-07-02 12:57:58 +02:00
KoalaDev 0af22998c8 Handle target tab navigation reinjection 2026-07-02 10:02:31 +02:00
KoalaDev 52265e84eb Generalize page API seek overrides 2026-07-02 09:57:57 +02:00
KoalaDev 57cd071d58 Add hidden remote seek test controls 2026-07-02 09:50:39 +02:00
KoalaDev 8a21fbe07f Fix Netflix seek handling 2026-07-02 09:43:11 +02:00