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>
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.
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>
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>
- server: SET_PEER_ROLE now uses the same per-room 500ms debounce as
SET_CONTROL_MODE (M-4), preventing a buggy/malicious host from
thrashing the room's UI with rapid promote/demote bursts.
- server: PEER_STATUS relay treats explicit null as 'clear' for
tabTitle/mediaTitle/currentTime, so the tab-close heartbeat actually
zeroes out stale state on other peers instead of being silently
preserved by the clamp fallback.
- background: _persistLastSeq is now trailing-debounced (500ms),
cutting storage.session IPC writes from one-per-relayed-event to
one-per-quiet-window in active rooms.
- locales: add missing TOAST_ID_REGENERATED to all 14 non-English
locales (was blocking locale coverage test on main).
- test: H-1 force-sync demote test now waits out the debounce window
to reflect real-world UI timing (a host cannot promote, run a
force-sync, and demote inside 500ms).
Defense-in-depth: the field was always undefined in practice (no code
path ever sets currentRoom.password — verified across the full git
history), and no GET_STATUS caller in the codebase reads it. But if a
future change ever populated currentRoom.password, any caller of
GET_STATUS would receive it in cleartext.
Threat surface today is narrow (externally_connectable is not set, no
content script sends GET_STATUS, only the popup uses it), but the field
has no reason to exist — removing it eliminates the future-leak vector
for free.
Backward compatible: no caller reads this field, so removing it is a no-op
for every existing code path.
8 hex chars (32-bit) gives ~7% collision probability at the relay's
25k-peer capacity and ~1% at 10k peers. A same-room collision silently
triggers the dedup path and kicks the older session with a confusing
'Another session with this ID joined' error.
16 hex chars (64-bit) drops collision probability to ~1e-10 even at one
million peers. The server's JOIN_ROOM sanitizer already clamps peerId to
16 chars, so no server change is needed. Fully backward compatible:
- Existing clients keep their persisted 8-char peerId (only newly
generated IDs change).
- Mixed 8-char + 16-char ID rooms work — both fit the server's 16-char
clamp.
- No protocol change on the wire.
Birthday-paradox reference (computed, not estimated):
32-bit @ 10k peers: 1.16%
32-bit @ 25k peers: 7.02%
64-bit @ 25k peers: ~0.017e-9%
Audit fixes (each verified against the actual code path):
H-1 (server): track force-sync initiator on PREPARE; let the demoted
initiator's EXECUTE through the host-only gate so mid-sync demotion no
longer strands the whole room paused. Clear on EXECUTE/peer-leave.
M-1 (background): episode-lobby gate now uses !amController() for parity
with CONTENT_EVENT and server gates — co-hosts can drive the room and
initiate lobbies, not just the owner.
M-2/M-3 (popup/content/background): forceSyncReset respects hcmGuestLocked;
desynced guest skips EPISODE_LOBBY so they don't get frozen in pause after
lobby completion, and checkEpisodeLobbyCompletion excludes desynced peers
from the required count so they don't block the lobby.
M-4 (background): getHostSyncTarget clamps extrapolation to 2x heartbeat
interval so a stale host heartbeat can't snap the guest tens of seconds
past the host's real position.
L-1..L-4 (server/content/background/popup): clarify dedup comment re:
network-blip window, enforce desync invariant on SW-restore, add
forceSyncBtn guest-locked backstop, refresh badge text in place.
Backward compatibility (verified by BC-1..BC-4 regression tests):
- Old client ↔ new server: server adds fields only, never requires; old
heartbeats stripped of desynced; host-only enforced server-side even
when the client has no awareness.
- New client ↔ old server: empty capabilities → host-control UI hidden,
all gates default to everyone, behavior byte-identical to pre-HCM.
- Mixed rooms: every pre-HCM event type relays cleanly in both directions.
- background: track controllers[] from ROOM_DATA/CONTROL_MODE (restore + reset on
teardown); add amController() (owner or co-host). Both gates now key on controller
membership (sender: !amController(); receiver: senderId not in controllers); desync
invariant uses amController. Thread controllers/amController/coHostSupported through
GET_STATUS/GET_CONTROL_MODE/CONTROL_MODE. New SET_PEER_ROLE message (owner→server).
- content: gate on amController instead of amHost (a controller is not a gated guest);
reconcile/HOST_BLOCKED updated accordingly. Drop now-unused hcmAmHost.
Single-host behavior unchanged (owner is always the sole controller until they promote).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Findings recovered from the multi-agent audit (verification phase was cut off by a
usage limit; verified inline against the code):
HIGH:
- Host was demoted on peerId dedup (fast reconnect / second tab): the dedup path
removes the old socket and the kicked socket's 'disconnect' both ran the
host-leave fallback before the same peerId re-joined → room silently unlocked on
every host network blip. Now skip the fallback while a join for that peerId is in
flight (peerJoinLocks). Regression test added. (A long real disconnect still
falls back — that's the deferred host-grace EC-10.)
- Episode auto-advance froze a gated guest: in host-only the guest's EPISODE_LOBBY
is dropped server-side, but the guest still self-paused (PAUSE_FOR_LOBBY) waiting
for readies that never came → 60s freeze. A host-only guest now skips creating a
lobby (the host drives episode sync).
LOW / cleanup:
- GET_HCM_STRINGS could return a raw key name ("HCM_DIALOG_TITLE") if the locale
dictionary failed to load (getMessage returns the key on miss) → omit it so
content keeps its English fallback.
- hcmReset now also clears the snap-back cooldown + buffering grace, so a stale
cooldown can't swallow the first snap-back after a room/host change.
- Popup play/pause labels reset on lock too (not just unlock), so a button can't
freeze on "Playing…" when host-only activates mid-click.
- Fix a stale comment (heartbeat now carries 'desynced').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the implicit "hostPeerId present ⇒ feature supported" heuristic with an
explicit capabilities list the relay advertises in ROOM_DATA. Cleaner, self-
documenting, and the extensible hook for the planned co-host feature (owner
promotes guests to extra controllers) — a future 'co-host' capability + events
slot in without a protocol bump.
- shared: CAPABILITIES { HOST_CONTROL }.
- server: SERVER_CAPABILITIES advertised in ROOM_DATA.
- background: track serverCapabilities (empty against older relay), serverSupports(),
thread hostControlSupported through GET_STATUS / GET_CONTROL_MODE / CONTROL_MODE.
- popup: gate the host-control card on the explicit capability instead of hostPeerId.
Backwards-compatible both ways: old relay omits the field → feature stays hidden
(no errors); old client ignores the field. WS test asserts ROOM_DATA advertises
the capability. Adds docs/host-control-mode-TESTING.md (beta-server setup, wss
caveat, two-new-clients note, verification checklist).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host-control desync dialog and the "watching on your own" badge were
hardcoded English — content.js has no i18n loader. Background does, so it now
resolves the strings (GET_HCM_STRINGS: loadLocale + getMessage) and content
fetches them on init with English fallbacks, re-rendering the badge if it was
already shown. The dialog body is now generic (dropped the paused/jumped verb)
to keep translation clean across languages.
Adds 6 keys (HCM_DIALOG_TITLE/BODY/STAY/SOLO, HCM_BADGE_SOLO/RESYNC) translated
across all 15 popup locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues surfaced by a holistic audit of the combined desync-persist +
reconcile-on-init changes:
1. Stale desync outside gated-guest state. hcmDesynced is persisted, but neither
ROOM_DATA nor CONTROL_MODE cleared it when the local peer became host or the
room switched to 'everyone'. Combined with reconcile-on-init, a desynced guest
promoted to host (host-leave fallback) while content was reloading could
re-adopt desynced=true — self-labelling "Solo" to all peers and ignoring host
commands in 'everyone' mode. Enforce the invariant centrally
(hcmEnforceDesyncInvariant: desynced ⟹ host-only && !host) on every role/mode
change, and guard the content reconcile to only adopt when actually a gated guest.
2. Solo-badge flicker. The host-side PEER_STATUS handler set peer.desynced
unconditionally, but the background-driven keepAlive heartbeat omits the field —
so every ~30s it clobbered desynced to false, flickering the badge. Only update
when present (matching the sibling fields), and include desynced in the
background heartbeat so both heartbeat sources are consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes a split-brain where a guest who chose "watch on my own" and then reloaded
the video page would appear stuck as "Solo" to the host while actually being
back in sync.
Root cause: background persists hcmDesynced (survives SW restart) and attaches
it to content-driven heartbeats, but a freshly injected content script started
with hcmDesynced=false and GET_CONTROL_MODE never returned the desync state — so
content re-applied host commands (synced) while background kept relaying
desynced=true to the host.
Fix: GET_CONTROL_MODE now also returns desynced; content adopts it on init
(re-shows the badge) and also adopts hostPeerId so later host-change resets work.
Desync now survives a page reload consistently on both sides, as intended.
Verified the full path: background.js heartbeat (1555 omits / 2018 attaches),
reset paths, CONTENT_BOOT and keepAlive-disconnect (neither reset it), content
sendHeartbeat on inject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Original code at L1957 already validates sender.tab tabId against
currentTabId before calling processEvent() — added guard was
duplicate. HCM_DESYNC_STATE had no such guard; added with same
sender.tab && currentTabId pattern (popup-safe via undefined
short-circuit).
hcmDesynced now persisted in session storage:
- Restored in ensureState()
- Set on HCM_DESYNC_STATE change
- Reset to false in all 3 teardown storage.sets
Survives SW restarts; cleared on leave/switch/idle-room
- CONTENT_EVENT now rejected from non-selected tabs (popup exempted via sender.tab check)
- HCM_DESYNC_STATE same guard
- hcmDesynced persisted in session storage: restored on SW restart, cleared on teardown
Bis auf peerId-Auth (akzeptiert) alle Audit-Findings behoben:
- Snap-back spielt nicht mehr blind ab, wenn Host-Status unbekannt
- Teardown-Pfade broadcasten jetzt CONTROL_MODE → kein verwaistes Badge/Dialog
- Dialog-Timer bei Ersetzung sauber gecleart
- Server re-synchronisiert Sender bei nicht-Host-Reject
- Popup-Toggle revertiert bei Server-Ablehnung
- Desync-Zweig sendet keine Phantom-ACKs mehr an Host
- Toggle-Debouncing serverseitig (500ms pro Raum)
- Button-Text auf Unlock zurückgesetzt
- Live-Erkennung defensiver, Resync-Retry bei fehlendem Target, Badge-Retry
Neu: Host sieht 'Solo'-Badge für desyncte Gäste via PEER_STATUS-Heartbeat
(16 Sprachen, neuer i18n-Key BADGE_DESYNCED/TOOLTIP_PEER_DESYNCED)
Tests: H-5-Reject-Unicast, M-4-Debounce, Desync-Heartbeat-Relay
Guest-side handling when a room is in host-only mode:
- Intent classifier (EC-9): tells a deliberate guest pause/seek from an
involuntary one (buffering/seeking/tab-refocus/no recent gesture) via
readyState, video.seeking, a 'waiting' buffering-grace window, the existing
visibility grace, and own-tracked keydown/pointerdown gestures.
- HOST_BLOCKED handler: live streams are left alone (EC-15); involuntary events
snap back silently; deliberate events show a Teleparty-style choice dialog
(Stay in sync / Watch on my own), defaulting to stay after 8s (EC-18).
- Snap-back reuses tryMediaAction + suppress, with a cooldown to avoid
pause/play fight loops (EC-4).
- Desync ("watch on my own") mode: persistent resync badge, ignores host
commands while solo (still ACKs so force-sync doesn't stall), and auto-clears
on episode change (EC-12). Resync pulls the host's extrapolated position.
- background: GET_CONTROL_MODE (init fetch) + REQUEST_HOST_SYNC (resync target).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Background service worker now understands room control mode (no UI yet):
- Track controlMode + hostPeerId + amHost(); adopt from ROOM_DATA, update on
CONTROL_MODE, restore from session storage, reset on leave/room-switch.
- Receiver-side gate: ignore room-moving events from non-host peers in host-only
mode (backstop for old/buggy/modified clients past the server gate).
- Sender-side gate: a guest's play/pause/seek/force-sync is not broadcast;
instead a HOST_BLOCKED message is sent to the content tab with an extrapolated
host sync target (hook for step 4's snap-back + desync dialog).
- broadcastControlMode() notifies popup + content tab; GET_STATUS exposes
controlMode/hostPeerId/amHost; new SET_CONTROL_MODE message lets the host toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When no other peer is present, heartbeats, force-sync, and episode auto-sync
are now fully suppressed (previously the keepAlive heartbeat, force-sync, and
episode lobby were still broadcast to an empty room):
- keepAlive heartbeat gated on a live peer count
- force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic)
- episode auto-sync skips the lobby entirely and just plays the next episode
The solo state is recomputed live from the current peer list on every event
(never cached), so the instant another peer joins syncing resumes. On that
solo->not-solo transition the background also requests an immediate heartbeat
from the content script so the newcomer sees the current position without
waiting for the next interval.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room ID, password, and username were resurrected from synced storage on a
fresh install (sync survives an uninstall in the user's Google account), which
made the extension silently auto-connect to a dead room and appear permanently
connected. getSettings() and all settings reads (popup, content, audio-options,
applyAudioSettingsToTab) are now local-only, and legacy keys are actively
purged from sync on install/update/startup. Only onboardingComplete and
dismissedHints remain in sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address client-side rate-limit edge cases that could trip the server's
per-socket event budget or per-IP connection limit:
- Pace the offline event-queue flush after (re)connect in batches
(10 per 3s) instead of one synchronous burst, so a reconnect after a
long outage no longer dumps the whole backlog and gets kicked.
- Wrap socket.send() in try/catch and re-queue on failure (race with a
server-side disconnect) instead of losing the event.
- Ping liveness: tolerate one missed PONG; only force a reconnect after
2 consecutive misses (~20s) instead of a single hard 5s timeout.
- Reconnect backoff: tune to ~8 attempts/60s (under the connection limit)
and add +/-20% jitter to de-synchronize reconnect herds.
All magic numbers extracted into named constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3-way merge of Kaia-Alenia's tab-manager refactor (PR #11) onto current
main (v2.4.3). Only the tab-related service-worker listeners move out of
background.js into extension/modules/tab-manager.js via initTabManager({...})
dependency injection; all other recent main changes are preserved untouched.
Behavior-preserving; listeners still register synchronously at top level.
The unrelated website/assets/NewLogoIcon_64.webp change from the PR was
excluded to keep main clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch the Ko-Fi support links to https://support.koalastuff.net across the
README badge, the extension footer (popup.html/template.html, label
"Support KoalaSync") and remove the now-unused KOFI_URL constant. Stop
generating/sending the anonymous uninstall token: initUninstallURL() now
registers the feedback URL without the `t` parameter or storage write.
CHANGELOG updated to match.
Also guard the client CMD_ACK path: only ACK a command sender that is still
a known peer in the room, so we don't emit ACKs to peers that already left
(which the server now drops as absent-peer ACKs anyway).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move extractEpisodeId() and sameEpisode() to shared episode-utils.js.
background.js imports via ES module. content.js (IIFE) receives injected
copy via build-extension.js marker replacement, eliminating duplication.