feat(player): a real player for recorded lives, shareable across the sites - #85
Merged
Conversation
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; |
ThreatCrush Security Scan72 finding(s) HIGH/CRITICAL: 13 | MEDIUM: 41 | LOW: 18
…and 22 more. Full results in the Security tab. Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
<>?t=in every spelling people write (372,6m12s,6:12)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.cssuses plainpux-player__*names for the same reason — it pastes into theirstyles.cssunchanged. The React side is a ref and a mount effect.Verification
Headless Chrome, against the real prod recording, 18/18:
lseeks 10s1:05 / 2:06…/l/TER8XG?t=62?t=1m2sbeats saved positionThat 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 forseeked/seekingtoo, which also picks up seeks it didn't make.Also: 44 unit tests,
next buildclean,tsc --noEmitandeslintclean, 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