Plate № 21 · Media · third-party embed

A pattern from the gf.cx specimen book

Karaoke video player · Player.js sync

Source · Dan, 2026-07-05

"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.

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

Where it applies — and where it doesn't

Reusable elements

The named, copyable pieces — lift any one without the others:

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.