Plate № 21 · Media · third-party embed
A pattern from the gf.cx specimen book
Karaoke video player · Player.js sync
"Add the video code to patterns.gf — it's a good video player." A Bunny Stream embed wrapped with word-sweep captions, a CC toggle, and a seekable transcript — synced to the real playhead over the Player.js protocol, degrading gracefully when the protocol can't connect.
The problem
Cloudflare Pages caps files at 25 MB, so long-form video lives on a streaming vendor (Bunny Stream, via kubrick.gf.cx) and arrives as a cross-origin iframe. You control nothing inside it. If you want editorial captions — word-by-word karaoke from a Whisper transcript, styled to the house — you need the player's current time, and the only channel is postMessage. The trap: Bunny's embed silently drops any message that isn't wrapped in a Player.js envelope. No error, no reply — bare commands like {command:'getCurrentTime'} just vanish.
The element, in-page
The demo below runs in the sandboxed pattern-preview iframe — which is itself an environment where Player.js can't connect. So it demonstrates rung 2 of the ladder: a wall-clock drives the same word-sweep renderer over a simulated stage. Same CSS tokens, same cue engine as production; only the time source differs. Flip the tabs for the source.
SIMULATED PLAYBACK · 42-MIN TALK
The Player.js handshake (production time source)
The production page swaps the wall-clock for the real playhead. Bunny's embed speaks the Player.js spec — every command needs the envelope, and events come back the same way:
function pjs(method,value){
iframe.contentWindow.postMessage(JSON.stringify({
context:'player.js', version:'0.0.11',
method:method, value:value, listener:'gfcx'
}),'*');
}
function subscribe(){['timeupdate','play','pause','ended']
.forEach(function(ev){pjs('addEventListener',ev)});}
window.addEventListener('message',function(e){
if(!String(e.origin).includes('mediadelivery.net'))return;
var d=JSON.parse(e.data);
if(d.context!=='player.js')return;
if(d.event==='ready'){subscribe();return}
if(d.event==='timeupdate'&&d.value){
lastT=d.value.seconds; lastWall=Date.now(); playing=true;
updateCue(lastT);
}
});
// ready can fire before your listener exists (lazy iframe) — retry
iframe.addEventListener('load',subscribe);
var tries=0,retry=setInterval(function(){
subscribe(); if(++tries>=10||autoSync)clearInterval(retry)},1000);
// smooth sweep between coarse timeupdates: local 100ms extrapolation
setInterval(function(){
if(!playing)return;
updateCue(lastT+Math.min((Date.now()-lastWall)/1000,1.5))},100);
// click-to-seek from the transcript panel:
// row.onclick = function(){ pjs('setCurrentTime', cue.s) }
The three baked-in defaults
- Caption pill above the control bar —
bottom:68px, black pill (rgba(12,12,12,.85)), grey hairline, white Newsreader italic. Legible on any frame; never fights the vendor's scrubber. - Word-sweep from sentence cues — no word-level timestamps needed: word i of n lights at
s + (e−s)·i/n. Grey → white → glow, three CSS classes. - A degradation ladder, not a bridge — live Player.js sync → manual wall-clock button → static transcript. The manual button demotes itself into a
● live syncstatus light when the protocol connects. Story: antifragile.gf.cx field note № 8.
Where it applies — and where it doesn't
- Use for long-form vendor-hosted video that deserves editorial treatment — talks, teardowns, reference recordings — where you have a transcript (Whisper) and want captions, a CC toggle, and a seekable transcript in the house style.
- Skip for short clips (native
<video>+ WebVTT is simpler), or when the vendor's own caption rendering is acceptable — this pattern earns its keep when captions are content, not compliance.
Reusable elements
The named, copyable pieces — lift any one without the others:
- Player.js bridge — envelope sender, ready/retry subscribe, timeupdate listener. Works for any Player.js-speaking embed, not just Bunny.
- Word-sweep renderer —
renderCue/updateCuepair: spans per word, linear interpolation across the cue,.on/.nowclasses. - Smoothing ticker — 100 ms local extrapolation between coarse timeupdates, capped at 1.5 s drift, gated by play/pause events.
- Caption pill token set — the black-pill/hairline/white-italic trio, plus
:emptycollapse so the pill vanishes between cues. - Seekable transcript panel — cues rendered as timestamped rows,
setCurrentTimeon click, active row follows playback withscrollIntoView. - Self-demoting fallback button — manual sync control that disables into a status light when the better rung connects.
Reference
- Source
- Dan, 2026-07-05 · one build session on growth.gf.cx, including the silent-protocol dead end that produced the ladder.
- In use / example
- growth.gf.cx/swipe-file/brand-strategist-playbook/ — 42-min talk, 388 Whisper cues, all three rungs live. Copy its video section + karaoke script; swap the embed URL and CUES array (via
kubrick_ingest.py+ Whisper). - Reusable elements
- Player.js bridge · word-sweep renderer · smoothing ticker · caption pill tokens · seekable transcript · self-demoting fallback (listed above).
- Origin
- Bunny Stream embed silently dropping non-Player.js messages, 2026-07-05 — written up as "Silence is a failure mode"; protocol details in the kb note
bunny-playerjs-karaoke-pattern.