[object Object]

← back to Dead Agentabrams

Music room: YouTube era-matched video-library backdrop + archive.org failover + resume (TK-11178)

b7cdfc80049f07ffa7b4c9669acd86a2297b24da · 2026-09-03 09:56:28 -0700 · Steve (via Claude)

- 18-video embed-verified library (1969-1995), nearest-year era matching
- Every show: muted era-matched official GD concert video as #stage backdrop
- archive.org down -> unmuted YouTube concert becomes the audio (replaces the
  old dead 'open archive.org player' fallback that also failed during an outage)
- localStorage resume; venue button toggles the video backdrop; React-to-Music
  disabled on the YouTube stream (Web Audio can't read a cross-origin iframe)
- Timed the two hot-path archive.org fetches so an outage fails fast to fallback
- Mutual audio exclusion enforced (no double audio); verified end-to-end via
  Playwright against the live archive.org outage

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

Files touched

Diff

commit b7cdfc80049f07ffa7b4c9669acd86a2297b24da
Author: Steve (via Claude) <steve@designerwallcoverings.com>
Date:   Thu Sep 3 09:56:28 2026 -0700

    Music room: YouTube era-matched video-library backdrop + archive.org failover + resume (TK-11178)
    
    - 18-video embed-verified library (1969-1995), nearest-year era matching
    - Every show: muted era-matched official GD concert video as #stage backdrop
    - archive.org down -> unmuted YouTube concert becomes the audio (replaces the
      old dead 'open archive.org player' fallback that also failed during an outage)
    - localStorage resume; venue button toggles the video backdrop; React-to-Music
      disabled on the YouTube stream (Web Audio can't read a cross-origin iframe)
    - Timed the two hot-path archive.org fetches so an outage fails fast to fallback
    - Mutual audio exclusion enforced (no double audio); verified end-to-end via
      Playwright against the live archive.org outage
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_012XLgczMhQr91Ad5jL38JM4
---
 room/index.html | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 181 insertions(+), 6 deletions(-)

diff --git a/room/index.html b/room/index.html
index 3971347..35c8ccc 100644
--- a/room/index.html
+++ b/room/index.html
@@ -24,6 +24,14 @@
   #stage{position:fixed;inset:0;display:block}
   canvas{position:absolute;inset:0;width:100%;height:100%}
   #orb{z-index:4;pointer-events:none}
+  /* ---- Concert-video backdrop (TK-11178): official GD footage behind the generative scene ---- */
+  #venueVideo{position:absolute;inset:0;overflow:hidden;z-index:0;pointer-events:none;background:#000;opacity:0;transition:opacity 1.4s ease}
+  #venueVideo iframe,#venueVideo>div{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
+    width:100vw;height:56.25vw;min-height:100vh;min-width:177.78vh;border:0;pointer-events:none}
+  #stage.video-bg #venueVideo{opacity:1}
+  #stage.video-bg #sky{opacity:.20}
+  #stage.video-bg #scene{opacity:.66}
+  .btn:disabled,button:disabled{opacity:.42;cursor:not-allowed}
   /* construction-paper grain — the cutout/South-Park texture over the whole scene */
   .grain{position:fixed;inset:0;pointer-events:none;z-index:5;opacity:.10;mix-blend-mode:overlay;
     background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 220 220' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
@@ -123,6 +131,8 @@
   .btn[aria-pressed="true"]:hover{background:linear-gradient(180deg,rgba(242,182,74,.28),rgba(242,182,74,.12))}
   .toggle{display:flex;flex-direction:column;gap:8px;align-items:stretch}
   .toggle .btn{justify-content:flex-start}   /* equal-width pills, left-aligned labels */
+  .toggle.pillrow{flex-direction:row;flex-wrap:wrap;align-items:center}
+  .toggle.pillrow .btn{justify-content:center}
 
   /* ---- Camera pills ---- */
   .cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
@@ -263,6 +273,7 @@
   <h1 class="sr">Scarlet Riverboat Masquerade — an animated generative gallery</h1>
   <!-- role removed from <main>: role="application" suppressed normal screen-reader navigation over the native controls + prose -->
 
+  <div id="venueVideo" aria-hidden="true"><div id="ytHost"></div></div>
   <canvas id="sky" aria-hidden="true"></canvas>
   <canvas id="scene" aria-hidden="true"></canvas>
   <canvas id="orb" aria-hidden="true"></canvas>
@@ -326,7 +337,7 @@
         <button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
         <button class="btn" id="restartBtn">↻ Restart</button>
       </div>
-      <div class="toggle">
+      <div class="toggle pillrow">
         <button class="btn" id="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
         <button class="btn" id="trailBtn" aria-pressed="true">✧ Motion Trails</button>
         <button class="btn" id="reactBtn" aria-pressed="false">♫ React to Music</button>
@@ -1494,7 +1505,8 @@ chromeBtn.addEventListener('click', toggleChrome);
 const venueBtn=document.getElementById('venueBtn');
 venueBtn.addEventListener('click',()=>{
   venueOn=!venueOn; venueBtn.setAttribute('aria-pressed',String(venueOn));
-  announce(venueOn?'Venue backdrop on.':'Venue backdrop off.');
+  if(window.__vbVenue) window.__vbVenue(venueOn);      // also toggle the concert-video backdrop (TK-11178)
+  announce(venueOn?'Concert-video backdrop on.':'Concert-video backdrop off.');
 });
 
 // keyboard shortcuts
@@ -1568,6 +1580,146 @@ const CATALOG = [
 ];
 const DEFAULT_SHOW = 7; // Cornell '77
 
+/* ============ Concert-video backdrop + archive.org failover + resume (TK-11178) ============
+   archive.org (the Live Music Archive) is the ONLY host for the real SBD tapes — when it is
+   unreachable the room would go silent. This layer adds, WITHOUT re-hosting anything (YouTube
+   is an embed, archive.org is a stream, resume lives in the visitor's own browser):
+     • an era-matched OFFICIAL Grateful Dead concert video as the #stage backdrop during every
+       show (muted, behind the generative scene), and
+     • a YouTube fallback that BECOMES the audio when archive.org is down.
+   Video IDs verified embeddable via yt-dlp (playable_in_embed=true, age_limit=0, not-live). */
+const MR_VIDEOS=[
+  {id:'2lK8DS449j0',year:1969,title:'1/24/69 · San Francisco'},
+  {id:'Pg-_ok-FqOE',year:1970,title:'5/1/70 · Alfred College'},
+  {id:'qu5Ij4vFgLo',year:1971,title:'8/6/71 · Hollywood Palladium'},
+  {id:'K7k9rXwaxcs',year:1972,title:'7/18/72 · Roosevelt Stadium'},
+  {id:'fzZ8ruYGgLM',year:1973,title:'2/15/73 · Madison, WI'},
+  {id:'auYN5XQSXxI',year:1974,title:'2/24/74 · Winterland'},
+  {id:'nfVLeDXLvK4',year:1976,title:'NYE 1976 · Cow Palace'},
+  {id:'zs5Sd6nd3u0',year:1977,title:'5/11/77'},
+  {id:'A-CqDFfJ0Rc',year:1978,title:'Closing of Winterland · 12/31/78'},
+  {id:'aEmJlxYAxps',year:1980,title:'Dead Ahead · Oct 1980'},
+  {id:'qa6BmpOZNXc',year:1981,title:'10/16/81 · Amsterdam'},
+  {id:'MJ3iAA2QL60',year:1985,title:'4/7/85 · Philadelphia'},
+  {id:'lzuYS_I8dpo',year:1987,title:'Shoreline · 10/2/87'},
+  {id:'A8rVM8GlD4c',year:1989,title:'Truckin’ Up to Buffalo · 7/4/89'},
+  {id:'iR4_D6P6Hrc',year:1990,title:'Rich Stadium · 7/16/90'},
+  {id:'G9ESSXKBq48',year:1991,title:'10/31/91 · Oakland Coliseum'},
+  {id:'YDgqP_4Q_fM',year:1993,title:'Buckeye Lake · 6/11/93'},
+  {id:'sSPyP9qwkpA',year:1995,title:'Soldier Field · 1995 (final era)'}
+];
+function _yearOf(s){
+  const m=String((s&&s.date)||'').match(/\b(19\d{2})\b/); if(m) return +m[1];
+  const m2=String((s&&s.id)||'').match(/gd(\d{2,4})/i);
+  if(m2){ let y=+m2[1]; if(y<100) y=(y<=40?2000:1900)+y; return y; }
+  return null;
+}
+function pickVideoFor(s){
+  const y=_yearOf(s);
+  if(y==null) return MR_VIDEOS[Math.floor(MR_VIDEOS.length/2)];
+  let best=MR_VIDEOS[0], bd=1e9;
+  for(const v of MR_VIDEOS){ const d=Math.abs(v.year-y); if(d<bd){ bd=d; best=v; } }
+  return best;
+}
+
+// --- YouTube IFrame-API backed background player (loaded lazily; this host sets no CSP) ---
+const VB={ mode:'off', ready:false, player:null, curId:null, wantId:null, wantPlay:false, wantMuted:true, venueOn:true };
+const _stageEl=document.getElementById('stage');
+function _vbShow(){ if(VB.venueOn && VB.wantId) _stageEl.classList.add('video-bg'); }
+function _vbHide(){ _stageEl.classList.remove('video-bg'); }
+function _loadYTApi(){
+  if(window.YT && window.YT.Player){ VB.ready=true; _buildPlayer(); return; }
+  if(!document.getElementById('yt-iframe-api')){
+    const t=document.createElement('script'); t.id='yt-iframe-api';
+    t.src='https://www.youtube.com/iframe_api'; document.head.appendChild(t);
+  }
+}
+const _prevYTReady=window.onYouTubeIframeAPIReady;
+window.onYouTubeIframeAPIReady=function(){ if(typeof _prevYTReady==='function'){ try{_prevYTReady();}catch(e){} } VB.ready=true; _buildPlayer(); };
+function _buildPlayer(){
+  if(VB.player || !(window.YT && window.YT.Player)) return;
+  if(!document.getElementById('ytHost')) return;
+  VB.player=new YT.Player('ytHost',{
+    host:'https://www.youtube-nocookie.com',
+    playerVars:{playsinline:1,controls:0,disablekb:1,rel:0,modestbranding:1,iv_load_policy:3,fs:0,cc_load_policy:0},
+    events:{ onReady:()=>{ if(VB.wantId) _applyVideo(); }, onStateChange:_onPlayerState, onError:_onPlayerError }
+  });
+}
+function _onPlayerState(e){
+  // loop the backdrop so the visuals (and, in fallback, the audio) persist for the whole show
+  if(window.YT && e.data===YT.PlayerState.ENDED){ try{ VB.player.seekTo(0); VB.player.playVideo(); }catch(_){} }
+}
+function _onPlayerError(){
+  // a specific video went private/blocked → hop to the next-nearest era video once
+  const cur=VB.curId, alt=MR_VIDEOS.find(v=>v.id!==cur);
+  if(alt){ VB.wantId=alt.id; VB.curId=null; _applyVideo(); }
+}
+function _applyVideo(){
+  const p=VB.player; if(!p || !p.loadVideoById) return;
+  if(VB.wantMuted){ try{p.mute();}catch(e){} } else { try{p.unMute(); p.setVolume(100);}catch(e){} }
+  if(VB.wantId!==VB.curId){
+    VB.curId=VB.wantId;
+    if(VB.wantPlay) p.loadVideoById(VB.wantId); else p.cueVideoById(VB.wantId);
+  } else if(VB.wantPlay){ try{p.playVideo();}catch(e){} }
+}
+function vbSetVideo(show,opts){
+  opts=opts||{};
+  const v=pickVideoFor(show); if(!v) return;
+  VB.wantId=v.id; VB.wantPlay=opts.play!==false ? !!opts.play : false; VB.wantMuted=opts.muted!==false;
+  _loadYTApi();
+  if(VB.player && VB.player.loadVideoById) _applyVideo();
+  _vbShow();
+}
+// browsers block UNMUTED autoplay without a gesture — arm a one-time tap/key handler to add sound
+let _ytUnlockArmed=false;
+function _primeYTUnlock(){
+  if(_ytUnlockArmed) return; _ytUnlockArmed=true;
+  const go=()=>{ if(VB.mode==='youtube' && VB.player){ try{ VB.player.unMute(); VB.player.setVolume(100); VB.player.playVideo(); }catch(e){} }
+    document.removeEventListener('pointerdown',go,true); document.removeEventListener('keydown',go,true); _ytUnlockArmed=false; };
+  document.addEventListener('pointerdown',go,true); document.addEventListener('keydown',go,true);
+}
+// archive mode: the real SBD tape plays from <audio>; the concert video is a MUTED visual backdrop.
+function enterArchiveMode(show){
+  VB.mode='archive';
+  if(typeof reactBtn!=='undefined' && reactBtn){ reactBtn.disabled=false; reactBtn.removeAttribute('title'); }
+  vbSetVideo(show,{play:true,muted:true});
+}
+// youtube fallback: archive.org is down → the video BECOMES the audio (unmuted). Mutual exclusion: kill <audio>.
+function enterYouTubeFallback(show){
+  VB.mode='youtube';
+  try{ audio.pause(); }catch(e){}
+  try{ audio.removeAttribute('src'); audio.load(); }catch(e){}   // no archive audio can leak under the video
+  const hasAct=!!(navigator.userActivation ? navigator.userActivation.isActive : false);
+  vbSetVideo(show,{play:true,muted:!hasAct});                    // gesture → sound now; cold load → muted+visible
+  if(!hasAct) _primeYTUnlock();
+  const v=MR_VIDEOS.find(x=>x.id===VB.wantId)||{};
+  $('mrNow').textContent='▶ '+(v.title?('Grateful Dead — '+v.title):'Grateful Dead (YouTube)');
+  $('mrSub').textContent='archive.org is unreachable — playing the nearest official Grateful Dead concert on YouTube.';
+  const msg=$('mrMsg');
+  if(msg){ msg.hidden=false;
+    msg.innerHTML='The Live Music Archive is unreachable right now, so the room is streaming the nearest official '+
+      'Grateful Dead concert from YouTube — your exact tape returns when archive.org is back.'+
+      (hasAct?'':' Tap or press any key for sound.')+
+      ' <a href="https://www.youtube.com/watch?v='+VB.wantId+'" target="_blank" rel="noopener noreferrer">Watch on YouTube ↗</a>'; }
+  setPlay(true);
+  if(typeof reactBtn!=='undefined' && reactBtn){ reactBtn.disabled=true; reactBtn.title='Live audio analysis isn’t available on the YouTube stream.'; }
+  announce('archive.org is unreachable — playing the nearest official Grateful Dead concert from YouTube.');
+}
+function vbTogglePlay(){
+  const p=VB.player; if(!p || !window.YT) return;
+  const st=p.getPlayerState?p.getPlayerState():-1;
+  if(st===YT.PlayerState.PLAYING){ try{p.pauseVideo();}catch(e){} setPlay(false); }
+  else { try{ if(VB.mode==='youtube') p.unMute(); p.playVideo(); }catch(e){} setPlay(true); }
+}
+// --- resume where you left off (visitor's own browser only; wrapped so private mode can't throw) ---
+const _RKEY='deadroom.resume.v1'; let _rLast=0;
+function saveResume(){ try{
+  const now=Date.now(); if(now-_rLast<1000) return; _rLast=now;
+  localStorage.setItem(_RKEY, JSON.stringify({showIdx, trackIdx, t:Math.floor(audio.currentTime||0), mode:VB.mode}));
+}catch(e){} }
+function readResume(){ try{ return JSON.parse(localStorage.getItem(_RKEY)||'null'); }catch(e){ return null; } }
+/* ============ end TK-11178 backdrop/failover/resume subsystem ============ */
+
 const $ = id => document.getElementById(id);
 const audio = $('mrAudio'), room = $('musicroom'), sel = $('mrShow'), live = $('live');
 let showIdx=-1, tracks=[], trackIdx=-1, seeking=false, loadToken=0;
@@ -1596,7 +1748,7 @@ async function loadShowRef(s, opts){
   $('mrTracks').innerHTML=''; $('mrMsg').hidden=true; $('mrMsg').innerHTML='';
   setFoot(s);
   try{
-    const r=await fetch(`https://archive.org/metadata/${s.id}`,{mode:'cors'});
+    const r=await fetchT(`https://archive.org/metadata/${s.id}`, 7000);   // timeout so an archive.org outage fails fast → YouTube fallback (TK-11178)
     if(!r.ok) throw new Error('metadata '+r.status);
     const j=await r.json();
     if(myToken!==loadToken) return;         // a newer show was selected while we awaited — drop this result
@@ -1615,10 +1767,11 @@ async function loadShowRef(s, opts){
     highlight();
     $('mrDur').textContent='0:00'; $('mrCur').textContent='0:00'; $('mrSeek').value='0';
     announce(`Loaded ${s.title}. ${tracks.length} tracks from the Live Music Archive.`);
+    enterArchiveMode(s);                      // muted era-matched concert video behind the live SBD audio
     if(opts.play) playTrack(0);
   }catch(e){
     if(myToken!==loadToken) return;         // superseded — let the newer selection own the UI
-    fallbackEmbed(s);
+    enterYouTubeFallback(s);                 // archive.org unreachable → the official YouTube concert becomes the audio
   }
 }
 async function loadShow(i, opts){ if(sel) sel.value=String(i); return loadShowRef(Object.assign({venueIdx:i}, CATALOG[i]), opts); }
@@ -1802,13 +1955,21 @@ audio.addEventListener('error',()=>{
   const err=audio.error;
   if(err && err.code===err.MEDIA_ERR_ABORTED) return;      // fired by our own src swaps — not a real failure
   if(showIdx<0) return;
-  if(!tracks.length){ fallbackEmbed(CATALOG[showIdx]); return; }
+  if(!tracks.length){ enterYouTubeFallback(CATALOG[showIdx]); return; }
   // a specific track wouldn't stream (404/geo/transient) — move on rather than stall silently
   if(trackIdx>=0 && trackIdx<tracks.length-1){ announce('That track was unavailable; skipping ahead.'); playTrack(trackIdx+1); }
   else { setPlay(false); const m=$('mrMsg'); m.hidden=false; m.textContent='That track could not be streamed from the archive right now — try another night.'; }
 });
 
+// --- keep the muted concert backdrop in step with the SBD audio (archive mode only) + resume + venue bridge ---
+audio.addEventListener('play', ()=>{ if(VB.mode==='archive' && VB.player && VB.player.playVideo){ try{ VB.player.mute(); VB.player.playVideo(); }catch(e){} } });
+audio.addEventListener('pause',()=>{ if(VB.mode==='archive' && VB.player && VB.player.pauseVideo){ try{ VB.player.pauseVideo(); }catch(e){} } });
+audio.addEventListener('timeupdate', saveResume);
+audio.addEventListener('pause', saveResume);
+window.__vbVenue=function(on){ VB.venueOn=!!on; if(VB.venueOn){ _vbShow(); } else { _vbHide(); } };
+
 $('mrPlay').addEventListener('click',()=>{
+  if(VB.mode==='youtube'){ vbTogglePlay(); return; }     // in fallback the YouTube video IS the audio
   if(!tracks.length){ loadShow(showIdx<0?DEFAULT_SHOW:showIdx,{play:true}); return; }
   if(trackIdx<0){ playTrack(0); return; }
   if(audio.paused) audio.play().catch(()=>{}); else audio.pause();
@@ -1935,7 +2096,7 @@ function _matches(title,want){ const n=_norm(title), w=_norm(want); if(!n||!w) r
 // fan out to the curated shows in parallel, pool every mp3 track (title + direct url)
 async function _poolFromShows(){
   const results=await Promise.allSettled(CATALOG.map(s=>
-    fetch(`https://archive.org/metadata/${s.id}`,{mode:'cors'}).then(r=>r.ok?r.json():null).then(j=>({s,j}))));
+    fetchT(`https://archive.org/metadata/${s.id}`, 6000).then(r=>r.ok?r.json():null).then(j=>({s,j}))));  // timed so boot can't hang on an archive.org outage (TK-11178)
   const pool=[];
   for(const res of results){
     if(res.status!=='fulfilled' || !res.value || !res.value.j) continue;
@@ -1957,6 +2118,19 @@ function _tryAutoplay(){
   });
 }
 async function bootMusic(){
+  // Returning visitor → resume their last show (cued; playback stays user-initiated per the listen-only ethos).
+  // First-ever visit (or blocked storage) → fall through to the curated autostart set below.
+  const _R=readResume();
+  if(_R && Number.isInteger(_R.showIdx) && _R.showIdx>=0 && _R.showIdx<CATALOG.length){
+    $('mrNow').textContent='Resuming your last show…';
+    await loadShow(_R.showIdx);                                   // archive down → this cues the YouTube fallback for that era
+    if(tracks.length && _R.trackIdx>0 && _R.trackIdx<tracks.length){
+      trackIdx=_R.trackIdx; audio.src=tracks[_R.trackIdx].url; highlight(); $('mrNow').textContent='♪ '+tracks[_R.trackIdx].title;
+    }
+    if(_R.t>0){ const _seek=()=>{ try{ if(audio.duration) audio.currentTime=Math.min(_R.t, audio.duration-1); }catch(e){} audio.removeEventListener('loadedmetadata',_seek); }; audio.addEventListener('loadedmetadata',_seek); }
+    _tryAutoplay();
+    return;
+  }
   $('mrNow').textContent='Cueing the set…';
   try{
     const pool=await _poolFromShows();
@@ -1967,6 +2141,7 @@ async function bootMusic(){
       $('mrNow').textContent=`♪ ${tracks[0].title}`;
       $('mrSub').textContent='Autostart — '+list.map(t=>t.title).join(' · ');
       setFoot(list[0].show);
+      enterArchiveMode(list[0].show);         // muted era-matched concert video behind the autostart set
       announce('Autostarting: '+list.map(t=>t.title).join(', ')+'.');
       _tryAutoplay();
       return;

← f3c92ee docs(TK-11100): Option A patch artifact for the scarlet-rive  ·  back to Dead Agentabrams  ·  Put all dock pills (Pause/Restart + the 5 mode toggles) on o 339f860 →