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>
Disney+ has no absolute seek on its blob <video>, so absolute seeks are
translated into repeated clicks of the player's relative skip button. The
click count was capped at 12 (=120s at 10s/step), so any seek further than
two minutes away silently landed short — breaking absolute-position sync
(e.g. syncing to 5:00 from far away never arrived).
Derive the step size from the matched button's own label (10s default,
some UIs use 5/15/30) and issue enough clicks to cover the full delta,
bounded at a sane maximum.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the control overlay (and its live time readout) is briefly absent,
the old fallback recomputed the timeline from the raw blob-relative
video.currentTime via a cached scale/start. Disney recreates the <video>
element on some play/pause transitions, which resets currentTime, so that
math produced a wildly wrong time ("loses the time" after a pause).
The native clock advances 1:1 with real playback, so instead anchor the
last live UI position to video.currentTime and extrapolate by the elapsed
native delta. If the delta is negative or implausibly large (element
recreated / seek), freeze at the last known position rather than emitting
a garbage time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disney+ throttles the scrubber slider's aria-valuenow, so it can lag real
playback by several seconds (and its value snaps back when the control
overlay reappears on pause). The player's "time remaining" indicator,
however, updates live.
getDisneyPlusUiTimeline() now captures any element whose class/testid
mentions "remain", parses its clock text, and — when a reliable duration
is known — derives current = duration - remaining and prefers it over the
laggy slider value. Falls back to the previous behavior when no remaining
indicator is present.
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>
## Added
- **WebSocket Protocol Specification** (docs/PROTOCOL.md) - Complete reference
for all 20+ events with payload schemas, rate limits, and edge cases
- **LEAVE_ROOM Rate Limiting** - 10 requests/socket/minute to prevent abuse
- **Vitest Testing Framework** - Modern test setup with coverage reporting
- **Host Control Mode Documentation** - Consolidated EN documentation
(docs/host-control-mode.md) with references to internal implementation
## Changed
- **Graceful Shutdown** - Socket.IO clients properly disconnected during
server shutdown (server/index.js)
- **CI Workflow** - Added test step with continue-on-error for gradual rollout
- **README** - Added protocol documentation link
- **CHANGELOG** - Updated with all new features and improvements
## Moved
- Internal documentation moved to docs/internal/ for better organization
- Host Control Mode docs consolidated from 4 files to 1 comprehensive guide
## Technical Details
- Protocol spec: 495 lines, covers all events from shared/constants.js
- Rate limiter: Follows existing pattern (checkAuthRate, checkEventRate)
- Vitest: 6 tests for rate limiter, all passing
- Documentation: Host Control Mode doc includes edge cases, testing checklist,
architecture decisions
- Remove duplicate local TITLE_PRIVACY_MODES array definition
- Use Object.values(TITLE_PRIVACY_MODES).includes() for validation instead
- Keeps single source of truth in title-privacy.js
Co-Authored-By: Claude <noreply@anthropic.com>
- Manual Connect: split the server picker into its own inset panel,
visually separated from the Room ID / Password fields (they were
welded together in one flat list).
- Custom server URL: stop auto-prepending ws:// — default to wss://
for real hosts, ws:// only for localhost/127.0.0.1 (matches what
the background already enforces; the displayed URL no longer lies).
- Host Control card: replace the raw native checkbox with the proper
.toggle-switch slider used in Options; move the toggle row from a
<label> to a <div> to avoid an illegally nested label.
- Co-host discoverability: owner-only hint in the Host Control card
pointing to the participant list (new NOTICE_COHOST_HINT across all
15 locales). Shown only when actionable: owner + co-host-capable
relay + host-only active.
- Peer row: group right-side badges/actions into one container so they
no longer scatter via space-between or collide with long names;
restyle the give/revoke button (padding, weight, hover fill, tooltip).
- Gate promote/demote (and role badges) on host-only mode being ON,
in addition to the existing owner-privilege check — roles are moot
in 'everyone' mode, so no badge/button noise there.
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).
Two pre-existing issues surfaced during audit follow-up:
1. popup.js bound elements.regenId but never attached a click handler —
the 'Regenerate Peer ID' button has been dead UI since it was added
(verified across full git history). Wired it up to send REGENERATE_ID;
the background handler already did the right thing (rotate peerId,
persist, force reconnect). The button is functional (regen ID on
duplicate-identity errors), not privacy-theater — username is already
user-changeable in settings, so it stays out of scope.
2. MAX_CONTROLLERS=10 was arbitrary. Payload math (25 controllers ×
16-char IDs ≈ 500 bytes) is well under the 4KB maxHttpBufferSize,
controllers.has() is O(1) on a Set, and 'just use everyone mode' is
not a real answer when a host wants 12 controllers + 3 guests in a
15-person room. Removed the cap entirely; the only real upper bound
is MAX_PEERS_PER_ROOM (25).
Added TOAST_ID_REGENERATED to en.json — the i18n fallback merge
(Object.assign({}, enDict, targetDict)) covers the other 14 locales
automatically with English until they're translated.