Skip to content

feat(player): a real player for recorded lives, shareable across the sites - #85

Merged
ralyodio merged 2 commits into
masterfrom
worktree-vod-player
Aug 29, 2026
Merged

feat(player): a real player for recorded lives, shareable across the sites#85
ralyodio merged 2 commits into
masterfrom
worktree-vod-player

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #84 — base is worktree-fix-replay-csp, so this diff is the player alone. #84 makes recordings load at all; this makes them worth watching.

Why

<video controls> is a different control bar in every browser, has no speed control in some, no ten-second skip in any, cannot be driven by a D-pad from a sofa, and forgets where you were the moment you close the tab. An hour-long recorded live is the case it serves worst.

Worth saying plainly, since it's what prompted this: genrewatch and tipoffwatch's existing player is an mpegts.js transport-stream demuxer feeding MSE, because a provider line serves http://, sends no CORS headers and puts the credential in the URL. That is a byte-delivery problem. PairUX recordings are ordinary MP4 over https that every browser decodes natively — so none of that rail applies here, and there is no demuxer in this player. What is worth sharing is the part above the bytes, which is what this is.

What it does

  • Own control bar — scrub with buffered range and hover time, skip 10s each way, volume, speed, picture-in-picture, fullscreen
  • Keyboard — space/k, arrows, j/l, m, f, p, 0–9, Home/End, < >
  • Resume where the reader left off, with a "Start over" out; remembers volume, mute and speed across recordings
  • Timestamps — copies a link at the current moment, honours ?t= in every spelling people write (372, 6m12s, 6:12)
  • Chapters — marks on the bar, click to jump, current one named beside the clock
  • Televisions — same user-agent list genrewatch/tipoffwatch already use, so a device is a TV in all three or none. Bigger controls, slower auto-hide, bigger seek step, and the controls a D-pad can't use are dropped
  • Explains failures instead of showing a black rectangle — including the CSP-blocked load from fix(csp): let recordings play on the site, not just in the embed #84, which was console-only

Shape

apps/web/src/lib/player/ is plain DOM with no framework in it, so genrewatch.com and tipoffwatch.com (Hono JSX, vanilla client bundle) can use this same player for their VOD tier rather than a second one that drifts. player.css uses plain pux-player__* names for the same reason — it pastes into their styles.css unchanged. The React side is a ref and a mount effect.

Verification

Headless Chrome, against the real prod recording, 18/18:

real recording loads 126.6s, 1280×720
native controls handed over
control bar rendered overlay, play, back, forward, mute, volume, rate, share, pip, fullscreen
play starts playback t=2.44
arrow seeks 5s, l seeks 10s 55 → 65
bar and clock follow 51.3%, 1:05 / 2:06
chapter marks + current name 3 marks, "Middle bit"
clicking a mark seeks t=89.998 (browser snaps to a keyframe)
speed control 1.25×
copy link carries the time …/l/TER8XG?t=62
resumes across a reload t=60.0, "Resumed at 1:00 · Start over"
remembers speed 1.25×
?t=1m2s beats saved position t=62.0
destroy leaves nothing behind
no page errors

That run earned its keep: it caught a real bug, now fixed and covered by a unit test. The bar and clock were only redrawn on timeupdate, which a paused video never fires — so arrowing along a paused recording moved the playhead and left the display behind. Seeks now redraw directly and the player listens for seeked/seeking too, which also picks up seeks it didn't make.

Also: 44 unit tests, next build clean, tsc --noEmit and eslint clean, full web suite 862 passing.

Not done

Chapters ship with no data source — the player renders whatever it's handed, and PairUX currently hands it none. The eventual source is the host (a list typed after the stream). Everything else here works without it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WFLwZtXE6iBJyJSgrsWqRM

ralyodio and others added 2 commits August 29, 2026 07:04
media-src only allowed a remote origin on /embed/*, so every past live was
CSP-blocked on pairux.com's own replay pages. The <video> rendered with the
right src and the MP4 served fine — Chrome just refused the load with
"MEDIA_ELEMENT_ERROR: Media load rejected by URL safety check", visible
only in the console, so the player looked broken for no reason.

Allow the Supabase Storage origin on every page instead, derived from the
same NEXT_PUBLIC_SUPABASE_URL that builds the playback URLs. This is also
narrower than what /embed/* had: a specific origin rather than all https:.

Verified against prod /l/TER8XG in headless Chrome — blocked before the
change; after it the recording loads (126.6s, 1280x720) and plays with no
CSP violations. Adds the first CSP tests for the middleware, covering the
media-src/embed divergence that let this through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFLwZtXE6iBJyJSgrsWqRM
…sites

`<video controls>` is a different control bar in every browser, has no speed
control in some, no ten-second skip in any, cannot be driven by a D-pad from a
sofa, and forgets where you were the moment you close the tab. An hour-long
recorded live is exactly the case it serves worst.

This replaces it on /l/<code> and /embed/<code> with a player that:

- draws its own control bar: scrub with buffered range and hover time, skip 10s
  each way, volume, speed, picture-in-picture, fullscreen
- takes the keyboard: space/k, arrows, j/l, m, f, p, 0-9, Home/End, < >
- resumes where the reader left off, and says so with a "Start over" out; also
  remembers volume, mute and speed across recordings
- copies a link at the current moment, and honours ?t= in every spelling people
  write (372, 6m12s, 6:12)
- draws chapter marks on the bar and names the current one
- knows a television from a desktop, using the same user agent list genrewatch
  and tipoffwatch already use, and grows the controls, slows the auto-hide,
  seeks in bigger steps and drops what a D-pad cannot use
- explains a failure instead of showing a black rectangle -- including the
  CSP-blocked load from the previous commit, which was console-only

The core is plain DOM in lib/player with no framework in it, so genrewatch.com
and tipoffwatch.com (Hono JSX, vanilla client bundle) can use this same player
for their VOD tier rather than a second one that drifts. The React wrapper is a
ref and a mount effect. Note their existing player is an mpegts.js transport
stream demuxer for a provider line -- a different problem; these are ordinary
MP4s that browsers decode natively, and there is no demuxer here.

Verified in headless Chrome against the real prod recording, 18/18: it plays,
seeks, cycles speed, marks and jumps chapters, copies a timestamped link,
resumes across a reload, honours ?t= over a saved position, and tears down
clean with no page errors. That run caught a real bug now fixed and covered:
the bar and clock only redrew on `timeupdate`, which a PAUSED video never
fires, so arrowing along a paused recording moved the playhead and left the
display behind.

44 unit tests, `next build` clean, tsc and eslint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFLwZtXE6iBJyJSgrsWqRM
title: label,
'data-control': control,
});
button.innerHTML = icon;
const ended = video.ended;
const icon = ended ? ICONS.replay : isPlaying ? ICONS.pause : ICONS.play;
const label = ended ? 'Play again' : isPlaying ? 'Pause' : 'Play';
playButton.innerHTML = icon;
playButton.innerHTML = icon;
playButton.setAttribute('aria-label', label);
playButton.title = label;
overlay.innerHTML = icon;

function renderVolume(): void {
const off = video.muted || video.volume === 0;
muteButton.innerHTML = off ? ICONS.muted : ICONS.volume;
on(document, 'fullscreenchange', () => {
const isFull = document.fullscreenElement === root;
root.classList.toggle('pux-player--fullscreen', isFull);
fullscreenButton.innerHTML = isFull ? ICONS.exitFullscreen : ICONS.enterFullscreen;
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

72 finding(s)

HIGH/CRITICAL: 13 | MEDIUM: 41 | LOW: 18

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH js-electron-node-integration apps/desktop/src/main/window.ts:49
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:192
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:126
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:214
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:142
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM js-open-redirect apps/web/src/hooks/useDesktopHandoff.ts:24
MEDIUM redos-nested-quantifier apps/web/src/lib/deliverable.ts:11
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:125
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:387
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:390
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:399
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:590
MEDIUM manifest-install-lifecycle-script package.json:55
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:342
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146

…and 22 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio changed the base branch from worktree-fix-replay-csp to master August 29, 2026 07:32
@ralyodio
ralyodio merged commit 2ae742c into master Aug 29, 2026
6 checks passed
@ralyodio
ralyodio deleted the worktree-vod-player branch August 29, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants