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>
Audit follow-up on the snap-back paths. The immediate involuntary snap used the
captured target directly; if it was null/invalid (host position not known yet,
e.g. before the first host heartbeat) hcmSnapBackToHost no-ops and the guest is
left silently paused with no dialog and no retry. The deferred and "Stay in sync"
paths already re-query with retry — make the immediate path fall back to the same
when the captured target isn't usable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the snap-back robustly instead of deferring it to device testing —
since the set of video sites is unbounded, "measure which players misbehave, then
fix" doesn't scale; the snap-back must be safe on any player.
On an involuntary pause/seek, if the player isn't ready to play (readyState<3 or
seeking) we no longer snap immediately (which fights the buffer: seek → re-buffer
→ pause → … = stutter). Instead hcmDeferredSnapBack polls until the player can
play (8s cap) and then snaps ONCE to the host's re-queried current position. A
player can't play while buffering anyway, so waiting is also more correct, not
just safer. Ready players still snap immediately (no regression).
Guards: aborts if the user goes solo or is no longer a gated guest; single
pending poll (no stacking); cleared in hcmReset.
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 card was rendered in every room with a "Guest" badge by default — confusing
in normal rooms, and outright misleading against a relay that doesn't yet support
host control (older server omits hostPeerId, so amHost is always false and nobody
is ever recognized as host).
Now the card shows only when it's meaningful: the host always sees it (to toggle
host-only), a guest sees it only while host-only is active, and it's hidden in a
normal "everyone" room and whenever the server doesn't advertise hostPeerId
(feature unavailable). Thread hostPeerId through updateHostControlUI to detect
server support.
Note: the feature still requires the relay to run the host-control server code;
this just makes the client degrade cleanly instead of showing a stray "Guest".
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>
The Resync badge already retried REQUEST_HOST_SYNC when the host's state wasn't
known yet, but the dialog's "Stay in sync" path did a single no-retry request —
so a stay tap right after the host paused (before a heartbeat propagated) could
silently leave the guest paused/out of sync. Extracted the retry loop into a
shared hcmRequestHostSyncWithRetry() and used it in both paths.
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
hcmHandleBlocked no longer re-checks local control mode before acting. Background
only sends HOST_BLOCKED to a gated guest, so the message itself is authoritative —
adopt host-only/guest role from it. Fixes the join race where a HOST_BLOCKED
arriving before the CONTROL_MODE broadcast was silently ignored.
Also corrects the EC-4 note: "let catch-up re-sync" is invalid (sync is
event-driven, no continuous catch-up); the right fix is a buffer-aware deferred
snap-back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- popup: lock remote-control buttons (Play/Pause/SYNC) for guests in host-only
mode so clicks don't silently get gated and leave the button stuck; backstop
guards in the handlers and the 2.5s safety reset respects the lock.
- content: rebuild the desync dialog + resync badge with the DOM API inside a
Shadow DOM. innerHTML inline style="" attributes are stripped by strict
style-src CSP (Netflix/YouTube/Disney+); CSSOM .style is CSP-safe and the
shadow root isolates from page CSS.
- content: detect live-DVR in hcmIsLive() via a sliding seekable window
(seekable.start(0) > 1), not just duration === Infinity (EC-15).
Records remaining audit items (snap-back thrash, join race, dialog i18n) in the
edge-case log for the device-testing pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final wiring of host control mode — the user-facing UI:
- Active-room section gains a Host Control card: a Host/Guest role badge, a
host-only toggle ("Only I can control playback") shown to the host, and a
guest notice when the room is host-only.
- Toggle sends SET_CONTROL_MODE; UI refreshes from the server's CONTROL_MODE
broadcast (with optimistic revert on failure). Card updates on GET_STATUS,
CONTROL_MODE, and reconnect.
- i18n: 6 new keys (LABEL_HOST_CONTROL[_TOOLTIP], LABEL_HOST_ONLY_TOGGLE,
NOTICE_HOST_CONTROLS, BADGE_HOST, BADGE_GUEST) translated across all 15
popup locales.
Completes the feature end to end (server + background + content + popup).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Media players fire bursts of native play/pause events (source swaps, ABR,
ads, teardown). Each was relayed as a distinct command, spamming peers and
tripping the relay's event rate limit. Add leading+trailing coalescing in
the content script: emit the first event instantly (zero added latency) and,
on a 150ms window, collapse a burst to its final state. Echo-suppression and
seek-flush stay synchronous on event arrival; the trailing send is never
deduped against the leading one (a remote command may change shared state
mid-window — re-sending is the safe, idempotent choice).
Server: split the EVENT_ACK handler's logging so only a genuine different-room
ACK is logged as [SECURITY]; an ACK to a peer that already left is logged
quietly as [ACKDROP] (verbose-only), so real signals are no longer drowned out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two new languages (uk, zh / zh_CN) across the extension and website:
register them in SUPPORTED_LANGUAGES, the Chrome _locales map, the website
build/lang-init/app routing, hreflang/og/schema tags and language selectors.
Fix systematic machine-translation word-sense errors in the new zh/uk
locales (e.g. zh "Status"=地位→状态, "Leave Room"=留出空间→离开房间,
"Play"=玩→播放, "Seek"=寻找→跳转, audio Attack/Release/Knee; uk "Play"=Грати
→Відтворити, "Clear"=ЯСНО→Очистити, "Open"=ВІДЧИНЕНО→Відкрити, peers
"Однолітки"→"Учасники"), translate remaining English leftovers (Room ID,
Custom) and normalise terminology. All 15 locales pass test-locales.cjs.
Note: popup.html and website/template.html also carry the language-selector
additions here; their Ko-Fi→Support label text is part of the branding change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- "No peers yet" state now shows a localized "Copy invite link" button (reuses BTN_COPY_INVITE, present in all 13 locales) so users can share without hunting for the field
- Add v2.4.1 changelog entry covering the onboarding tour improvements, empty-state action, and the mobile landing-page fixes from this session
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add the previously-orphaned 5th onboarding step (ONBOARDING_5) so the tour ends on a closing card instead of stopping at the username field
- Make step 1 a centered welcome card instead of spotlighting the logo; guard querySelector for target-less steps
- Fix stale static placeholders in popup.html (Step 1 of 3 -> 5, progress 33% -> 20%)
Verified ONBOARDING_5/DONE exist across all 13 locales; rendered with the longest (German) copy.
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.
- background.js: remove isConnecting=false at line 608 (was set before
WebSocket handshake completed, defeating the guard for the entire
CONNECTING phase). isConnecting is already properly reset in onclose,
onopen/40 handler, catch block, and socket guard path.
- background.js: restore isConnecting=false in forceDisconnect() so
subsequent connect() calls can proceed after intentional disconnect.
- popup.js: remove hardcoded applyConnectionStatus('disconnected') that
caused a guaranteed red flicker on every popup open. Status is now
set only by the async GET_STATUS response.
If user has a room configured but the background is not connected
(e.g. service worker was killed, startup race, or stale state), the
popup now sends CONNECT on open. Previously it only called GET_STATUS
and showed 'disconnected' with 0 logs — the user had no way to know
whether the extension was working.
- Prevent concurrent connect() by removing isConnecting reset from
forceDisconnect(); the guard in connect() now reliably blocks
re-entry since no external caller can defeat it.
- Reset isConnecting at end of connect() so subsequent calls can
proceed without waiting for the async '40' handler.
- Clear connectIntent and cancel reconnect timer on server ERROR
when no currentRoom exists, preventing infinite reconnect loops
after failed join attempts (e.g. wrong password via invite link).
- Move connectIntent assignment after !roomId guard in
WEB_JOIN_REQUEST to avoid clobbering existing intent on invalid
input.
- Broadcast JOIN_STATUS failure to popup and bridge tabs when
WEB_JOIN_REQUEST receives an invalid room ID, so the website
join page receives feedback instead of hanging forever.
- Clear joinBtnTimeout on CONNECTION_STATUS connected/disconnected
to avoid stale timeout error messages in the popup.