← back to Dead Agentabrams

room/index.html

2698 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Scarlet Riverboat Masquerade — an authored, animated generative gallery of a paddlewheel riverboat drifting through a crimson canyon at dusk. Steer tempo, lanterns, crowd, animals, cameras and chapters.">
<meta name="color-scheme" content="dark">
<title>Scarlet Riverboat Masquerade — An Animated Gallery</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%237c1522'/><circle cx='8' cy='6' r='3' fill='%23f2b64a'/></svg>">
<style>
  :root{
    --ink:#f4e7d6;
    --ink-dim:#c9a98c;
    --gold:#f2b64a;
    --crimson:#7c1522;
    --panel:rgba(24,8,12,0.72);
    --panel-edge:rgba(242,182,74,0.28);
    --focus:#ffd27a;
    --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
    --sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;background:#0a0406;color:var(--ink);font-family:var(--sans);overflow:hidden}
  #stage{position:fixed;inset:0;display:block}
  canvas{position:absolute;inset:0;width:100%;height:100%}
  #orb{z-index:4;pointer-events:none}
  #stage:not(.orb-on) #orb{display:none}   /* orb OFF by default (Steve: onload no orb); 🔮 Orb toggles it on */
  /* ---- Concert-video backdrop (TK-11178): official GD footage behind the generative scene.
     Confined to a positioned box (Steve: "the area in back of where singers would sing") —
     defaults roughly over the stage riser, fully dialable via the Videos panel's X/Y/W/H
     sliders (--vr-bd-x/-y/-w/-h). Contain-fit inside that box — never cropped into a close-up. ---- */
  #venueVideo{position:absolute;
    left:var(--vr-bd-x, 48%); top:var(--vr-bd-y, 43%);
    width:var(--vr-bd-w, 18vw); height:var(--vr-bd-h, 32vh);
    transform:translate(-50%,-50%);
    overflow:hidden;z-index:0;pointer-events:none;background:#000;opacity:0;
    transition:opacity 1.4s ease,left .15s,top .15s,width .15s,height .15s;border-radius:3px}
  #venueVideo iframe,#venueVideo>div{position:absolute;inset:0;width:100%;height:100%;border:0;pointer-events:none}
  #stage.video-bg #venueVideo{opacity:.55}
  #stage.video-bg #sky{opacity:.20}
  #stage.video-bg #scene{opacity:.85}
  /* YouTube fallback = the video IS the show → let it fill the frame, not just the riser box */
  #stage.video-bg.yt-hero #venueVideo{left:50%;top:50%;width:100vw;height:56.25vw;min-height:100vh;min-width:177.78vh}
  #stage.video-bg.yt-hero #sky{opacity:.04}
  #stage.video-bg.yt-hero #scene{opacity:.16}
  #stage.video-bg.yt-hero #orb{opacity:.5}
  /* ---- Movie screen (Steve: "it's a movie screen!") — the concert plays on a framed
     screen mounted on the stage; the dancers dance in FRONT of it (scene paints above
     #venueVideo). Shown ONLY while a video is playing (.movie-on). ---- */
  #stage.movie-on #venueVideo{
    opacity:1; z-index:1;
    left:calc(50% + var(--movie-dx, 0px)); top:calc(36% + var(--movie-dy, 0px));
    transform:translate(-50%,-50%);
    width:calc(min(54vw,760px) * var(--movie-scale, 1)); height:auto; aspect-ratio:16/9;
    background:#000; border-radius:4px; border:2px solid #0a0304;
    box-shadow:0 0 0 8px #17070a, 0 0 0 10px rgba(242,182,74,.45),
               0 30px 90px rgba(0,0,0,.75), 0 0 70px rgba(242,182,74,.18);
  }
  /* Crop the YouTube iframe so its own chrome (title bar, byline, logo, "More videos")
     is pushed outside the gold frame — only the footage shows (graphic-designer #3). */
  #stage.movie-on #venueVideo iframe{transform:scale(1.34);transform-origin:center center}
  /* Stage riser/base so the screen reads as MOUNTED on the stage, not floating (designer note). */
  #stage.movie-on #venueVideo::before{content:"";position:absolute;left:50%;bottom:-8px;
    transform:translateX(-50%);width:34%;height:26px;background:linear-gradient(#17070a,#0a0304);
    clip-path:polygon(18% 0,82% 0,100% 100%,0 100%);z-index:-1;box-shadow:0 14px 30px rgba(0,0,0,.6)}
  #stage.movie-on #sky{opacity:.5}
  #stage.movie-on #scene{opacity:1}
  @media (max-width:820px){ #stage.movie-on #venueVideo{width:calc(86vw * var(--movie-scale,1));top:32%} }
  /* drag-to-move + corner-resize handles on the movie screen (Steve: adjust + scale up/down) */
  #venueVideo .mv-move,#venueVideo .mv-size{position:absolute;pointer-events:auto;display:none}
  #stage.movie-on #venueVideo .mv-move{display:block;inset:0;cursor:move;z-index:1}
  #stage.movie-on #venueVideo .mv-size{display:block;right:-9px;bottom:-9px;width:26px;height:26px;z-index:2;
    cursor:nwse-resize;border-radius:5px;background:rgba(242,182,74,.9);
    box-shadow:0 2px 10px rgba(0,0,0,.55);border:1px solid #17070a}
  #stage.movie-on #venueVideo .mv-size::after{content:"⤡";position:absolute;inset:0;display:flex;
    align-items:center;justify-content:center;color:#17070a;font-size:14px;font-weight:700}
  .btn:disabled,button:disabled{opacity:.42;cursor:not-allowed}
  /* ---- Controls in a collapsed RIGHT drawer, opened by the top-right hamburger (TK-11178 UI) ---- */
  #dock{
    left:auto; right:0; top:0; bottom:auto; transform:translateX(100%);
    height:100vh; width:min(90vw,340px); max-width:none; border-radius:0;
    flex-direction:column; align-items:stretch; justify-content:flex-start; flex-wrap:nowrap;
    gap:16px; padding:64px 18px 26px; overflow-y:auto;
    border-left:1px solid var(--panel-edge); box-shadow:-14px 0 44px rgba(0,0,0,.55);
    transition:transform .34s ease; z-index:11;
  }
  #dock .group{flex-direction:column; align-items:stretch; gap:10px}
  #dock .group + .group{border-left:0; border-top:1px solid rgba(242,182,74,.16); padding:14px 0 0; margin-top:2px}
  #dock .group.pillrow .toggle{flex-direction:column; align-items:stretch; gap:8px}
  #dock .toggle .btn{justify-content:flex-start}
  #stage.controls-open #dock{transform:translateX(0)}
  /* chapters + cams + museum label now live INSIDE the drawer → make them flow as groups */
  #dock .chapters, #dock .cams{position:static;left:auto;right:auto;top:auto;bottom:auto;transform:none;
    display:flex;flex-direction:column;gap:8px;z-index:auto;backdrop-filter:none;max-width:none}
  #dock .chapters button, #dock .cams button, #dock .label-toggle{width:100%;justify-content:flex-start}
  #dock .label-toggle{position:static;left:auto;right:auto;top:auto;bottom:auto;margin-top:2px}
  /* 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")}

  /* ---- Skip link ---- */
  .skip{
    position:fixed;top:-60px;left:12px;z-index:20;background:var(--gold);color:#2a0a0e;
    padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s;
  }
  .skip:focus{top:12px}

  /* ---- Back-link (only shown when hosted as a room of the DEAD listening exhibition) ---- */
  .backlink{
    position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:10;display:none;
    align-items:center;gap:8px;text-decoration:none;pointer-events:auto;
    font-family:var(--serif);font-style:italic;font-size:12.5px;color:var(--ink-dim);
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:999px;
    padding:6px 15px;backdrop-filter:blur(6px);transition:color .3s,background .3s;
  }
  .backlink.on{display:inline-flex}
  .backlink:hover{color:var(--gold);background:rgba(40,12,16,.86)}

  /* ---- Title plate ---- */
  .titleplate{
    position:fixed;top:22px;left:26px;z-index:6;max-width:min(46vw,520px);
    pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.7);
  }
  .titleplate .kicker{
    font-family:var(--sans);letter-spacing:.42em;text-transform:uppercase;
    font-size:11px;color:var(--gold);opacity:.9;margin:0 0 6px 2px;
  }
  .titleplate h1{
    font-family:var(--serif);font-weight:600;font-style:italic;
    font-size:clamp(26px,4.4vw,52px);line-height:.98;margin:0;color:var(--ink);
  }
  .titleplate .chap{
    font-family:var(--serif);font-size:14px;color:var(--ink-dim);margin-top:8px;font-style:italic;
  }

  /* ---- Chapter rail ---- */
  .chapters{
    position:fixed;top:64px;right:26px;z-index:6;display:flex;gap:8px;flex-direction:column;align-items:flex-end;
  }
  .chapters button{
    font-family:var(--serif);font-size:13px;font-style:italic;
    background:var(--panel);color:var(--ink-dim);border:1px solid transparent;
    border-radius:999px;padding:7px 15px;cursor:pointer;backdrop-filter:blur(6px);
    transition:color .3s,border-color .3s,background .3s;
    display:flex;align-items:center;gap:8px;
  }
  .chapters button .dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.6}
  .chapters button[aria-current="true"]{color:var(--gold);border-color:var(--panel-edge);background:rgba(40,12,16,.86)}
  .chapters button:hover{color:var(--ink)}

  /* ---- Control dock ---- */
  .dock{
    position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:7;
    display:flex;gap:18px;align-items:stretch;flex-wrap:wrap;justify-content:center;
    max-width:min(96vw,1080px);
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
    padding:14px 20px;backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);
  }
  .group{display:flex;gap:12px;align-items:center}
  .group + .group{border-left:1px solid rgba(242,182,74,.16);padding-left:18px}
  .field{display:flex;flex-direction:column;gap:5px;min-width:96px}
  .field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim)}
  .field .val{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:12px}
  input[type=range]{
    -webkit-appearance:none;appearance:none;height:4px;border-radius:4px;
    background:linear-gradient(90deg,var(--crimson),var(--gold));outline:none;width:100%;
  }
  input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none;width:16px;height:16px;border-radius:50%;
    background:var(--ink);border:2px solid var(--gold);cursor:pointer;box-shadow:0 0 8px rgba(242,182,74,.6);
  }
  input[type=range]::-moz-range-thumb{
    width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--gold);cursor:pointer;
  }
  .btn{
    font-family:var(--sans);font-size:12px;letter-spacing:.04em;font-weight:500;
    background:linear-gradient(180deg,rgba(58,18,24,.72),rgba(34,10,14,.72));
    color:var(--ink);border:1px solid rgba(242,182,74,.22);
    border-radius:11px;padding:0 14px;min-height:38px;cursor:pointer;
    transition:background .18s,color .18s,border-color .18s,box-shadow .18s,transform .06s;
    display:inline-flex;align-items:center;gap:8px;white-space:nowrap;
    box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 1px 2px rgba(0,0,0,.35);
  }
  .btn:hover{border-color:rgba(242,182,74,.5);color:var(--gold);
    background:linear-gradient(180deg,rgba(78,24,30,.85),rgba(48,14,18,.85))}
  .btn:active{transform:translateY(1px)}
  /* active "on" state — a lit lantern glow, not a solid candy fill */
  .btn[aria-pressed="true"]{
    color:var(--gold);border-color:rgba(242,182,74,.6);font-weight:600;
    background:linear-gradient(180deg,rgba(242,182,74,.20),rgba(242,182,74,.07));
    box-shadow:inset 0 0 0 1px rgba(242,182,74,.22),inset 0 1px 0 rgba(255,235,190,.16),0 0 14px rgba(242,182,74,.16);
  }
  .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 */
  /* all-toggles-on-one-line layout: Pause/Restart + the 5 mode pills as a single flat row.
     Row below ~1180px would crowd the fixed camera-pill/label panels, so it falls back
     to the original stacked layout there (still matches the file's other breakpoint). */
  @media (min-width:1180px){
    .group.pillrow .toggle{flex-direction:row;align-items:center;gap:8px}
    .group.pillrow .toggle .btn{justify-content:center}
  }

  /* ---- Camera pills ---- */
  .cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
  .cams .btn{justify-content:flex-start;min-width:118px}

  /* ---- Museum label ---- */
  .label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
  .museum{
    position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,368px);max-height:74vh;overflow:auto;
    background:linear-gradient(180deg,rgba(28,10,14,.96),rgba(16,6,9,.96));
    border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;
    box-shadow:0 18px 50px rgba(0,0,0,.6);transform-origin:bottom right;
    transition:opacity .3s,transform .3s;font-size:13px;line-height:1.55;color:var(--ink-dim);
  }
  .museum[hidden]{display:none}
  .museum h2{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:18px;margin:0 0 4px}
  .museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px;opacity:.8}
  .museum p{margin:0 0 10px}
  .museum strong{color:var(--ink);font-weight:600}
  .museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(242,182,74,.16);
    font-size:11px;line-height:1.7;letter-spacing:.02em}
  .museum .keys b{display:inline-block;min-width:74px;color:var(--gold);font-family:var(--serif);font-style:italic;font-weight:600}

  /* ---- Music Room console ---- */
  .musicroom{
    position:fixed;left:26px;top:182px;z-index:11;width:min(84vw,300px);
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:14px;
    backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden;font-size:12px;
  }
  .musicroom > header{
    display:flex;align-items:center;gap:9px;padding:11px 14px;cursor:pointer;
    font-family:var(--serif);font-style:italic;color:var(--gold);font-size:14px;user-select:none;
  }
  .musicroom .live-dot{width:8px;height:8px;border-radius:50%;background:#5a2128;flex:none}
  .musicroom.playing .live-dot{background:var(--gold);animation:pulse 2s infinite}
  @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(242,182,74,.5)}70%{box-shadow:0 0 0 8px rgba(242,182,74,0)}100%{box-shadow:0 0 0 0 rgba(242,182,74,0)}}
  .musicroom .chev{margin-left:auto;transition:transform .3s;font-size:11px}
  .musicroom[data-open="false"] .chev{transform:rotate(-90deg)}
  .musicroom[data-open="false"] .mrbody{display:none}
  .mrbody{padding:0 14px 14px}
  .mr-search{width:100%;background:rgba(40,12,16,.8);color:var(--ink);border:1px solid var(--panel-edge);border-radius:9px;padding:8px 10px;font-size:12px;margin-bottom:8px}
  .mr-search::placeholder{color:var(--ink-dim)}
  .mr-year{width:100%;background:rgba(40,12,16,.8);color:var(--ink);border:1px solid var(--panel-edge);border-radius:9px;padding:7px 9px;font-size:12px;margin-bottom:8px;cursor:pointer}
  /* source selector — which free Dead archive to browse */
  .mr-src{display:flex;gap:6px;margin-bottom:9px}
  .mr-src button{flex:1;background:rgba(40,12,16,.7);color:var(--ink-dim);border:1px solid var(--panel-edge);border-radius:9px;padding:8px 8px;cursor:pointer;line-height:1.25;text-align:center;font-family:var(--sans)}
  .mr-src button b{display:block;font-size:12px;color:var(--ink);font-weight:600}
  .mr-src button .yr{display:block;font-size:9px;color:var(--ink-dim);letter-spacing:.02em;margin-top:1px}
  .mr-src button:hover{background:rgba(70,20,26,.85)}
  .mr-src button.on{background:linear-gradient(180deg,#ffd97a,var(--gold));border-color:var(--gold)}
  .mr-src button.on b,.mr-src button.on .yr{color:#2a0d10}
  /* tappable touring-history year map — wrapping tile grid (WCAG-sized targets) */
  .mr-map-head{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--ink-dim);margin:2px 0 5px;gap:8px}
  #mrMapLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .mr-allbtn{flex:none;background:rgba(70,20,26,.6);color:var(--ink);border:1px solid var(--panel-edge);border-radius:8px;font-size:11px;padding:6px 12px;min-height:32px;cursor:pointer}
  .mr-allbtn.on{background:var(--gold);color:#2a0d10;border-color:var(--gold)}
  .mr-map{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:9px}
  .mr-yr{flex:1 1 46px;min-width:46px;min-height:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;padding:5px 3px;cursor:pointer;color:var(--ink);border:1px solid var(--panel-edge);border-radius:8px;background:rgba(90,30,20,calc(.12 + var(--heat,0) * .55));transition:transform .1s,box-shadow .15s,background .15s}
  .mr-yr:hover{transform:translateY(-1px);box-shadow:0 3px 8px rgba(0,0,0,.35)}
  .mr-yr.on{background:linear-gradient(180deg,#ffd97a,var(--gold));border-color:var(--gold);color:#2a0d10;box-shadow:0 0 10px rgba(240,190,90,.6)}
  .mr-yr .yl{font-size:12px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums}
  .mr-yr .yc{font-size:8.5px;color:var(--ink-dim);letter-spacing:.02em;line-height:1}
  .mr-yr.on .yc{color:#6a2c12}
  .mr-yr.loading{animation:mrPulse 1s ease-in-out infinite}
  @keyframes mrPulse{0%,100%{opacity:.4}50%{opacity:.8}}
  .mr-map-err{font-size:11px;color:#e2a08a;padding:6px 4px;font-style:italic}
  .mr-results{max-height:150px;overflow:auto;margin-bottom:8px;display:flex;flex-direction:column;gap:2px}
  .mr-results:empty{display:none}
  .mr-result{display:flex;gap:9px;align-items:baseline;width:100%;text-align:left;background:none;border:0;color:var(--ink-dim);font-family:var(--sans);font-size:11.5px;padding:5px 6px;cursor:pointer;border-radius:6px}
  .mr-result:hover{color:var(--ink);background:rgba(70,20,26,.6)}
  .mr-result .rd{color:var(--gold);font-variant-numeric:tabular-nums;min-width:74px;flex:none}
  .mr-result .rv{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .mr-hint{color:var(--ink-dim);font-size:11px;padding:5px 4px;font-style:italic}
  .mr-picks{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-dim);margin:2px 0 5px}
  .mr-show{width:100%;background:rgba(40,12,16,.7);color:var(--ink);border:1px solid var(--panel-edge);
    border-radius:9px;padding:8px 10px;font-family:var(--serif);font-size:12.5px;cursor:pointer}
  .mr-now{margin:12px 0 3px;color:var(--ink);font-family:var(--serif);font-style:italic;font-size:13px;min-height:18px;
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .mr-sub{color:var(--ink-dim);font-size:10.5px;letter-spacing:.03em;margin-bottom:9px}
  .mr-transport{display:flex;align-items:center;gap:8px;margin:8px 0}
  .mr-transport .btn{padding:9px 12px;min-height:44px;min-width:44px;justify-content:center}
  .mr-transport .play{min-width:54px;justify-content:center;font-size:14px}
  .mr-tracks-toggle{margin-left:auto;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-dim);
    background:none;border:0;cursor:pointer;padding:10px 6px;min-height:44px}
  .mr-tracks-toggle:hover{color:var(--gold)}
  .mr-seek{display:flex;align-items:center;margin:6px 0 2px;padding:9px 0}
  .mr-seek input[type=range]{width:100%;height:7px}
  .mr-time{font-variant-numeric:tabular-nums;color:var(--ink-dim);font-size:10px;text-align:right}
  .mr-tracks{max-height:148px;overflow:auto;margin-top:9px;border-top:1px solid rgba(242,182,74,.16);padding-top:8px;display:none}
  .musicroom.show-tracks .mr-tracks{display:block}
  .mr-tracks button{display:flex;gap:9px;width:100%;text-align:left;background:none;border:0;color:var(--ink-dim);
    font-family:var(--sans);font-size:11.5px;padding:5px 4px;cursor:pointer;border-radius:6px;line-height:1.3}
  .mr-tracks button:hover{color:var(--ink);background:rgba(70,20,26,.5)}
  .mr-tracks button[aria-current="true"]{color:var(--gold)}
  .mr-tracks button .n{color:var(--ink-dim);min-width:20px;font-variant-numeric:tabular-nums;opacity:.7;flex:none}
  .mr-foot{margin-top:11px;font-size:10px;line-height:1.55;color:var(--ink-dim)}
  .mr-foot a{color:var(--gold);text-decoration:none;border-bottom:1px solid rgba(242,182,74,.4)}
  .mr-msg{margin-top:9px;font-size:11px;line-height:1.5;color:var(--ink-dim)}

  /* ---- Videos panel — official GD concert footage on YouTube, standalone.
     Independent of archive.org/Music Room so it still works when that's down. ---- */
  .videoroom{
    position:fixed;right:26px;top:158px;z-index:9;width:min(84vw,300px);
    background:var(--panel);border:1px solid var(--panel-edge);border-radius:14px;
    backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden;font-size:12px;
  }
  .videoroom > header{display:flex;align-items:center;gap:9px;padding:11px 14px;cursor:pointer;
    font-family:var(--serif);font-style:italic;color:var(--gold);font-size:14px;user-select:none}
  .videoroom .chev{margin-left:auto;transition:transform .3s;font-size:11px}
  .videoroom[data-open="false"] .chev{transform:rotate(-90deg)}
  .videoroom[data-open="false"] .vrbody{display:none}
  .vrbody{padding:0 14px 14px}
  .vr-hint{font-size:11px;font-style:italic;color:var(--ink-dim);margin-bottom:9px}
  .vr-transport{display:flex;align-items:center;gap:6px;margin-bottom:9px}
  .vr-transport .btn{flex:1;padding:8px 6px;min-height:40px;justify-content:center;font-size:12px}
  .vr-size{margin-bottom:9px}
  .vr-size label{display:block;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:4px}
  .vr-size .val{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:12px;text-transform:none;letter-spacing:0}
  .vr-size input[type=range]{width:100%;margin-bottom:8px}
  .vr-list{max-height:220px;overflow:auto;display:flex;flex-direction:column;gap:2px}
  .vr-row{display:flex;gap:9px;align-items:baseline;width:100%;text-align:left;background:none;border:0;
    color:var(--ink-dim);font-family:var(--sans);font-size:11.5px;padding:6px 6px;cursor:pointer;border-radius:6px}
  .vr-row:hover{color:var(--ink);background:rgba(70,20,26,.6)}
  .vr-row.on{color:var(--gold);background:rgba(242,182,74,.12)}
  .vr-row .ry{color:var(--gold);font-variant-numeric:tabular-nums;min-width:34px;flex:none}
  .vr-row .rt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  @media (max-width:820px){
    /* distinct slots so Videos no longer sits on top of Music Room (was unclickable) */
    .videoroom{top:auto;bottom:12px;left:12px;right:12px;width:auto}
  }

  /* master Controls toggle — panels start collapsed, user opens them */
  .chrome-toggle{position:fixed;top:22px;right:26px;z-index:12}
  #stage.hide-chrome .dock,
  #stage.hide-chrome .cams,
  #stage.hide-chrome .chapters,
  #stage.hide-chrome .musicroom,
  #stage.hide-chrome .label-toggle,
  #stage.hide-chrome .museum,
  #stage.hide-chrome .videoroom{display:none!important}

  :focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:6px}
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

  @media (max-width:820px){
    .titleplate{max-width:64vw}
    .dock{gap:10px;padding:10px 12px}
    .group + .group{padding-left:10px}
    .field{min-width:74px}
    .cams{flex-direction:row;left:12px;bottom:100px}
    .cams .btn{min-width:auto;padding:7px 10px}
    .label-toggle{bottom:100px;right:12px}
    .museum{bottom:148px;right:12px}
    .chapters{top:auto;bottom:100px;right:12px;flex-direction:row}
    .musicroom{top:auto;bottom:70px;left:12px;right:12px;width:auto}
    .musicroom .mr-tracks{max-height:96px}
  }
  /* ---- Phones (Steve: mobile-friendly · shrink text · scale it) — fluid, smaller type;
     panels kept inside the viewport; controls reachable. ---- */
  @media (max-width:640px){
    .titleplate{max-width:76vw;top:14px;left:14px}
    .titleplate .kicker{font-size:10px;letter-spacing:.22em}
    .titleplate h1{font-size:clamp(20px,6.5vw,30px);line-height:1.02}
    .titleplate .chap{font-size:11px}
    .chrome-toggle{top:12px;right:12px;font-size:12px;padding:7px 12px}
    .videoroom,.musicroom{left:10px;right:10px;width:auto;font-size:11px}
    .videoroom>header,.musicroom>header{font-size:12.5px;padding:9px 12px}
    .vr-hint,.mr-hint,.mr-sub{font-size:10px}
    .vr-row,.mr-result,.mr-year,.mr-search{font-size:10.5px}
    .vr-list{max-height:32vh}
    .dock{gap:8px;padding:8px 10px}
    .field label{font-size:9px}
    .field .val{font-size:10.5px}
    .cams .btn,.chapters button,.btn,.mr-show,.mr-allbtn{font-size:10.5px;padding:6px 9px}
    .museum{font-size:11.5px;padding:14px 16px;left:10px;right:10px}
    .museum h2{font-size:15px}
    .museum p{font-size:10.5px;line-height:1.55}
    #stage.movie-on #venueVideo{width:calc(92vw * var(--movie-scale,1));top:28%}
  }
  /* ---- Very small phones — one more step down ---- */
  @media (max-width:400px){
    .titleplate h1{font-size:clamp(18px,6.6vw,26px)}
    .videoroom,.musicroom{font-size:10.5px}
  }
  @media (prefers-reduced-motion: reduce){
    .museum,.chapters button,.btn,.skip{transition:none}
    /* also silence the always-on keyframe loops (live-dot pulse, loading tiles) */
    *,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important}
    .musicroom.playing .live-dot{animation:none}
    .mr-yr.loading{animation:none}
  }
</style>
</head>
<body>
<a href="#dock" class="skip">Skip to controls</a>
<a href="https://stage.agentabrams.com" class="backlink" id="backlink" aria-label="Back to The Main Stage">← The Main Stage</a>
<main id="stage" aria-label="Scarlet Riverboat Masquerade — an animated visual gallery you can steer with the controls and keyboard">
  <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 class="mv-move" title="Drag to move the screen"></div><div class="mv-size" title="Drag to scale the screen"></div></div>
  <canvas id="sky" aria-hidden="true"></canvas>
  <canvas id="scene" aria-hidden="true"></canvas>
  <canvas id="orb" aria-hidden="true"></canvas>
  <div class="grain" aria-hidden="true"></div>

  <div class="titleplate">
    <p class="kicker">A Riverboat Listening Room</p>
    <h1 aria-hidden="true">Scarlet Riverboat<br>Masquerade</h1>
    <p class="chap" id="chapLabel">Chapter I · Departure</p>
  </div>

  <section class="musicroom" id="musicroom" data-open="false" aria-label="Music room — Grateful Dead Live Music Archive">
    <header id="mrToggle" role="button" tabindex="0" aria-expanded="false" aria-controls="mrbody">
      <span class="live-dot" aria-hidden="true"></span>
      <span>Music Room</span>
      <span class="chev" aria-hidden="true">▾</span>
    </header>
    <div class="mrbody" id="mrbody">
      <input class="mr-search" id="mrSearch" type="search" placeholder="🔍 Search the archive — date, city, song…" aria-label="Search the Grateful Dead archive">
      <div class="mr-src" id="mrSrc" role="group" aria-label="Choose a music source"></div>
      <div class="mr-map-head"><span id="mrMapLabel">Tap a year</span><button type="button" class="mr-allbtn on" id="mrAll">All years</button></div>
      <div class="mr-map" id="mrMap" role="group" aria-label="Browse shows by year"></div>
      <input type="hidden" id="mrYear" value="">
      <div class="mr-results" id="mrResults" role="list"></div>
      <div class="mr-picks">Curated nights:</div>
      <select class="mr-show" id="mrShow" aria-label="Choose a recording from the Live Music Archive"></select>
      <div class="mr-now" id="mrNow">Choose a night, then press play.</div>
      <div class="mr-sub" id="mrSub"></div>
      <div class="mr-transport">
        <button class="btn" id="mrPrev" aria-label="Previous track">⏮</button>
        <button class="btn play" id="mrPlay" aria-label="Play" aria-pressed="false">▶</button>
        <button class="btn" id="mrNext" aria-label="Next track">⏭</button>
        <button class="mr-tracks-toggle" id="mrTracksToggle" aria-expanded="false" aria-controls="mrTracks">Tracks</button>
      </div>
      <div class="mr-seek"><input type="range" id="mrSeek" min="0" max="1000" value="0" step="1" aria-label="Seek within track"></div>
      <div class="mr-time"><span id="mrCur">0:00</span> / <span id="mrDur">0:00</span></div>
      <div class="mr-tracks" id="mrTracks" role="list" aria-label="Set list"></div>
      <div class="mr-foot" id="mrFoot"></div>
      <div class="mr-msg" id="mrMsg" hidden></div>
    </div>
    <audio id="mrAudio" preload="none"></audio>
  </section>

  <section class="videoroom" id="videoroom" data-open="false" aria-label="Videos — official Grateful Dead concert footage on YouTube">
    <header id="vrToggle" role="button" tabindex="0" aria-expanded="false" aria-controls="vrbody">
      <span>🎬 Videos</span>
      <span class="chev" aria-hidden="true">▾</span>
    </header>
    <div class="vrbody" id="vrbody">
      <div class="vr-hint">Pick a concert — it plays on the stage.</div>
      <div class="vr-transport" role="group" aria-label="Video transport">
        <button type="button" class="btn" id="vrPrev" aria-label="Previous concert">⏮</button>
        <button type="button" class="btn" id="vrBack30" aria-label="Back 30 seconds">⏪ 30</button>
        <button type="button" class="btn" id="vrFwd30" aria-label="Forward 30 seconds">30 ⏩</button>
        <button type="button" class="btn" id="vrNext" aria-label="Next concert">⏭</button>
      </div>
      <div class="vr-list" id="vrList" role="list" aria-label="Concert videos"></div>
    </div>
  </section>

  <button class="btn chrome-toggle" id="chromeBtn" aria-pressed="false">☰ Controls</button>

  <div class="dock" id="dock" role="group" aria-label="Visual controls">
    <nav class="chapters group" aria-label="Chapters">
      <button data-chapter="0" aria-current="true"><span class="dot" aria-hidden="true"></span>I · Departure</button>
      <button data-chapter="1"><span class="dot" aria-hidden="true"></span>II · Moon Bend</button>
      <button data-chapter="2"><span class="dot" aria-hidden="true"></span>III · Dawn Landing</button>
    </nav>
    <div class="cams group" role="group" aria-label="Camera view">
      <button class="btn" data-cam="wide" aria-pressed="true">◈ Wide River</button>
      <button class="btn" data-cam="deck" aria-pressed="false">◈ Deck</button>
      <button class="btn" data-cam="over" aria-pressed="false">◈ Overhead</button>
    </div>
    <div class="group pillrow">
      <div class="toggle">
        <button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
        <button class="btn" id="restartBtn">↻ Restart</button>
      </div>
      <div class="toggle">
        <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>
        <button class="btn" id="pteroBtn">🦖 Fly Over</button>
        <button class="btn" id="venueBtn" aria-pressed="true">🏛 Venue</button>
        <button class="btn" id="orbBtn" aria-pressed="false" title="Show the orb — then drag it around the room">🔮 Orb</button>
      </div>
    </div>
    <div class="group">
      <div class="field">
        <label for="tempo">Visual Tempo <span class="val" id="tempoV">Moderato</span></label>
        <input type="range" id="tempo" min="0" max="200" value="100" aria-valuetext="Moderato">
      </div>
      <div class="field">
        <label for="lantern">Lantern Glow <span class="val" id="lanternV">Warm</span></label>
        <input type="range" id="lantern" min="0" max="100" value="70" aria-valuetext="Warm">
      </div>
      <div class="field">
        <label for="crowd">Crowd Density <span class="val" id="crowdV">Full</span></label>
        <input type="range" id="crowd" min="0" max="100" value="80" aria-valuetext="Full">
      </div>
      <div class="field">
        <label for="animals">Animal Visibility <span class="val" id="animalsV">All</span></label>
        <input type="range" id="animals" min="0" max="100" value="100" aria-valuetext="All">
      </div>
      <div class="field">
        <label for="bears">Dancing Bears <span class="val" id="bearsV">3</span></label>
        <input type="range" id="bears" min="1" max="10" value="3" step="1" aria-valuetext="3">
      </div>
    </div>
    <button class="btn label-toggle" id="labelBtn" aria-expanded="false" aria-controls="museum">🏷 Museum Label</button>
  </div>

  <aside class="museum" id="museum" hidden aria-label="Museum label">
    <h2>Scarlet Riverboat Masquerade</h2>
    <div class="meta">Generative Canvas · Live Music Archive · Authored Visual Rhythm</div>
    <p>A fantastical paddlewheel riverboat drifts through a hand-painted crimson canyon while masked revelers fill three decks. This is a <strong>listening room</strong>: press play in the Music Room and a Grateful Dead concert streams from the Internet Archive's <strong>Live Music Archive</strong> while the boat turns around you.</p>
    <p><strong>The motion is composed, not measured.</strong> The choreography is an <em>authored visual cadence</em> written into the drawing loop itself — it is deliberately <em>not</em> audio analysis and does not react to the waveform. Set the visual tempo to whatever feels right against the night's playing; the two rhythms — heard and drawn — keep company without one driving the other. <em>(Flip “React to Music” and that changes on purpose: the lanterns, fireflies, and dancers then bloom to a live analysis of the streaming audio.)</em></p>
    <p><strong>The recordings are not copied or re-hosted here.</strong> They are community tapes made available through the Grateful Dead collection at the Internet Archive; playback is user-initiated and every set links back to its source item on archive.org.</p>
    <p>The <strong>original visual language</strong> reduces each dancer to a weighted silhouette: couples pivot in shared arcs, a linked circle turns about the smokestack, soloists trail scarves, and musicians appear only as abstract shadow-forms — a communal, exploratory spirit rendered without any borrowed logo, emblem, or album art.</p>
    <p>On the shore, a cast of creatures each carry a distinct weighted loop — a <strong>trotting fox</strong>, a <strong>slow-stepping heron</strong>, a <strong>squash-and-stretch rabbit</strong>, a <strong>bear conducting fireflies</strong> with two <strong>dancing cubs</strong>, and <strong>songbirds</strong> whose note-bubbles pop to the beat; now and then a <strong>pterodactyl</strong> glides across. The whole cast is drawn as flat, outlined paper-cutout figures over a construction-paper grain. Palette <em>and</em> choreography change across the three chapters as the boat travels from dusk (Departure) through moonrise (Moon Bend) into sunrise (Dawn Landing).</p>
    <div class="keys">
      <div><b>Space</b> pause / play</div>
      <div><b>R</b> restart the journey</div>
      <div><b>M</b> reduced motion</div>
      <div><b>T</b> motion trails</div>
      <div><b>A</b> react to the music</div>
      <div><b>P</b> pterodactyl fly-over</div>
      <div><b>Click</b> launch a firework</div>
      <div><b>1&nbsp;2&nbsp;3</b> wide · deck · overhead</div>
      <div><b>← →</b> previous / next chapter</div>
      <div><b>L</b> this museum label</div>
    </div>
  </aside>

  <p class="sr" aria-live="polite" id="live">Wide river view. Chapter one, Departure.</p>
</main>

<script>
(() => {
"use strict";
const sky = document.getElementById('sky');
const scene = document.getElementById('scene');
const sctx = sky.getContext('2d');
const ctx = scene.getContext('2d');
const live = document.getElementById('live');

// ---------- State ----------
const S = {
  t: 0,               // authored master clock (visual beats)
  playing: true,
  reduced: false,
  trails: true,
  tempo: 1.0,         // 0..2
  lantern: 0.7,
  crowd: 0.8,
  animals: 1.0,
  bears: 3,           // dancing-bear count (1..10)
  cam: 'wide',        // wide | deck | over
  chapter: 0,         // 0..2 (target)
  chapterMix: 0,      // eased 0..2 for palette blend
  autoJourney: true,  // gentle auto-advance until the visitor picks a chapter
  journeyClock: 0,    // seconds accrued toward the next auto-advance
  W: 0, H: 0, DPR: 1
};
const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter

// Respect OS reduced-motion at load
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
  S.reduced = true;
}
// ...and keep honoring it if the visitor flips the OS setting mid-session
if (window.matchMedia){
  const _rmq = matchMedia('(prefers-reduced-motion: reduce)');
  const _sync = e => {
    S.reduced = e.matches;
    const rb = document.getElementById('reduceBtn');
    if (rb) rb.setAttribute('aria-pressed', String(S.reduced));
    if (S.reduced) ctx.clearRect(0,0,S.W,S.H);
  };
  if (_rmq.addEventListener) _rmq.addEventListener('change', _sync);
  else if (_rmq.addListener) _rmq.addListener(_sync);
}

// ---------- Palettes + per-chapter choreography ----------
const CHAPTERS = [
  { // Departure — dusk crimson/gold, couples lead, easy circle
    name:'Departure',
    sky:['#2a0a12','#6d1220','#c0432b','#e6893f'],
    canyon:['#3a0d16','#7a1a25','#521018'],
    river:'#5a1420', riverHi:'#c95a3a',
    lantern:'#ffcf6e', dancer:'rgba(20,4,8,0.92)', accent:'#f2b64a',
    choreo:{ ringDir:1, ringSpeed:0.45, coupleTurn:1.0, scarf:0.9, ringSway:1.0 }
  },
  { // Moon Bend — deep violet/blue night, the linked circle dominates and quickens
    name:'Moon Bend',
    sky:['#0a0620','#241245','#4a1c52','#7a2b46'],
    canyon:['#180a2a','#341a44','#20102e'],
    river:'#1a1030', riverHi:'#8a6bd0',
    lantern:'#ffe08a', dancer:'rgba(8,4,18,0.92)', accent:'#c9a2ff',
    choreo:{ ringDir:1, ringSpeed:0.9, coupleTurn:0.7, scarf:1.15, ringSway:1.4 }
  },
  { // Dawn Landing — rose/amber sunrise, soloists + scarves take the floor, circle reverses
    name:'Dawn Landing',
    sky:['#3a1428','#8a3550','#d97a5a','#f6c88a'],
    canyon:['#4a1a2a','#8a3a44','#5c2230'],
    river:'#6a2438', riverHi:'#f6b06a',
    lantern:'#fff0c0', dancer:'rgba(28,10,14,0.9)', accent:'#ffcf8a',
    choreo:{ ringDir:-1, ringSpeed:0.4, coupleTurn:1.25, scarf:1.5, ringSway:0.8 }
  }
];

function lerp(a,b,t){return a+(b-a)*t;}
function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v));}
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)];}
function mixHex(h1,h2,t){const a=hexToRgb(h1),b=hexToRgb(h2);return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;}
// Darken any 'rgb(r,g,b)' (or hex) color toward black by t (0..1). pal() returns rgb() strings,
// so we must NOT feed those back into hexToRgb — this handles both forms safely.
function shade(color,t){
  const m=String(color).match(/\d+/g);
  let r,g,b;
  if(String(color).charAt(0)==='#'){ const c=hexToRgb(color); r=c[0];g=c[1];b=c[2]; }
  else if(m && m.length>=3){ r=+m[0]; g=+m[1]; b=+m[2]; }
  else return color;
  const k=1-clamp(t,0,1);
  const ch=v=>Math.round(clamp(v*k,0,255));
  return `rgb(${ch(r)},${ch(g)},${ch(b)})`;
}
function pal(key,i){ // blended palette between integer chapters
  const m = S.chapterMix; const lo=Math.floor(m), hi=Math.min(2,lo+1), f=m-lo;
  const A=CHAPTERS[lo][key], B=CHAPTERS[hi][key];
  if(Array.isArray(A)) return mixHex(A[i],B[i],f);
  return mixHex(A,B,f);
}
// Choreography snaps to the nearest chapter (palette blends, steps change crisply)
function CH(){ return CHAPTERS[clamp(Math.round(S.chapterMix),0,2)].choreo; }

// ---- Concert venue backdrops (swap with the streaming show) ----
const VENUES=[
  {name:'Fillmore West · San Francisco · 1969',    lights:['#ffcf6e','#ff7a4a','#ffd06e']},
  {name:'Veneta · Oregon · 1972',                  lights:['#ffe0a0','#ffbf6a','#fff0c0']},
  {name:'Jai-Alai Fronton · Miami · 1974',         lights:['#a8c8ff','#ffd0f0','#8fe0ff']},
  {name:'Barton Hall · Cornell · 1977',            lights:['#ffd0f0','#c9a2ff','#ffe08a']},
  {name:'Nassau Coliseum · Uniondale · 1990',      lights:['#80e0ff','#ff8ad0','#ffd66e']}
];
let venueOn=true;

// ---- Song-motif layer: original DRAWN animated items keyed to the current track title ----
const MOTIF_MAP=[
  [/fire|flame|mountain|burn/,                             'flame'],
  [/sugar|magnolia|sugaree|begonia|rose|flower|lily|garden|daffodil|scarlet/, 'flower'],
  [/rain|snow|cold|storm|winter|hail/,                     'snow'],
  [/star|moon|dark star|midnight|shine|sunshine|sun/,      'star'],
  [/water|river|ripple|ocean|wave|sea|row|creek/,          'water'],
  [/wheel|turnin|truck|road|ramble|train|casey|railroad|jones/, 'wheel'],
  [/bird|eagle|dove|sparrow|crow|wing/,                    'bird']
];
let motifParticles=[], motifClock=0, motifType=null, motifKey=null;
function currentMotifType(){
  const tr=((window.SRM&&window.SRM.track)||'').toLowerCase();
  if(tr===motifKey) return motifType;
  motifKey=tr; motifType='note';                 // default: musical notes for every song
  for(const m of MOTIF_MAP){ if(m[0].test(tr)){ motifType=m[1]; break; } }
  return motifType;
}
function drawMotif(type,s,life,tt,col){
  const al=Math.max(0,Math.min(0.9,life));
  if(type==='flame'){
    const f=Math.sin(tt*12+s)*0.18;
    ctx.globalAlpha=al; ctx.fillStyle='#ff8a3a';
    ctx.beginPath(); ctx.moveTo(0,s*0.6); ctx.quadraticCurveTo(-s*0.5,0,0,-s*0.75-f*s); ctx.quadraticCurveTo(s*0.5,0,0,s*0.6); ctx.fill();
    ctx.fillStyle='#ffd24a'; ctx.beginPath(); ctx.moveTo(0,s*0.4); ctx.quadraticCurveTo(-s*0.25,0,0,-s*0.42-f*s); ctx.quadraticCurveTo(s*0.25,0,0,s*0.4); ctx.fill();
  } else if(type==='flower'){
    ctx.globalAlpha=al; ctx.rotate(tt*0.6+s); ctx.fillStyle=col||'#ff6aa0';
    for(let i=0;i<5;i++){ ctx.rotate(Math.PI*2/5); ctx.beginPath(); ctx.ellipse(0,-s*0.5,s*0.28,s*0.5,0,0,7); ctx.fill(); }
    ctx.fillStyle='#ffd24a'; ctx.beginPath(); ctx.arc(0,0,s*0.22,0,7); ctx.fill();
  } else if(type==='snow'){
    ctx.globalAlpha=al; ctx.strokeStyle='#dff0ff'; ctx.lineWidth=1.6; ctx.lineCap='round'; ctx.rotate(tt*0.8);
    for(let i=0;i<6;i++){ ctx.rotate(Math.PI/3); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-s*0.6); ctx.stroke(); }
  } else if(type==='star'){
    const tw=0.6+0.4*Math.sin(tt*6+s); ctx.globalAlpha=al*tw; ctx.fillStyle='#ffe89a';
    ctx.beginPath(); for(let i=0;i<10;i++){ const a=i*Math.PI/5-Math.PI/2, r=(i%2?s*0.22:s*0.6); ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r); } ctx.closePath(); ctx.fill();
  } else if(type==='water'){
    ctx.globalAlpha=al; ctx.fillStyle='#7ac0ff';
    ctx.beginPath(); ctx.moveTo(0,-s*0.55); ctx.quadraticCurveTo(s*0.5,s*0.2,0,s*0.5); ctx.quadraticCurveTo(-s*0.5,s*0.2,0,-s*0.55); ctx.fill();
  } else if(type==='wheel'){
    ctx.globalAlpha=al; ctx.rotate(tt*3); ctx.strokeStyle='#f2b64a'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.arc(0,0,s*0.5,0,7); ctx.stroke();
    for(let i=0;i<6;i++){ ctx.rotate(Math.PI/3); ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-s*0.5); ctx.stroke(); }
  } else if(type==='bird'){
    const flap=Math.sin(tt*8+s)*0.4; ctx.globalAlpha=al; ctx.strokeStyle='#241010'; ctx.lineWidth=2.2; ctx.lineCap='round';
    ctx.beginPath(); ctx.moveTo(-s*0.5,0); ctx.quadraticCurveTo(-s*0.2,-s*0.45-flap*s,0,0); ctx.quadraticCurveTo(s*0.2,-s*0.45-flap*s,s*0.5,0); ctx.stroke();
  } else {  // musical note
    ctx.globalAlpha=al; ctx.fillStyle=col||'#f2b64a';
    ctx.beginPath(); ctx.ellipse(-s*0.18,s*0.35,s*0.28,s*0.2,-0.3,0,7); ctx.fill();
    ctx.fillRect(s*0.06,-s*0.55,s*0.09,s*0.95);
    ctx.beginPath(); ctx.moveTo(s*0.15,-s*0.55); ctx.quadraticCurveTo(s*0.5,-s*0.5,s*0.42,-s*0.18); ctx.lineTo(s*0.15,-s*0.28); ctx.closePath(); ctx.fill();
  }
  ctx.globalAlpha=1;
}
function drawSongMotifs(dt){
  const type=currentMotifType();
  if(type && S.playing && !S.reduced){
    motifClock+=dt;
    if(motifClock>0.55 && motifParticles.length<48){ motifClock=0;
      motifParticles.push({x:S.W*(0.06+Math.random()*0.88), y:S.H*0.9, vy:-(18+Math.random()*26), drift:(Math.random()-0.5)*14, life:1, size:16+Math.random()*16, type, col:pal('accent',0)});
    }
  }
  if(!motifParticles.length) return;
  for(let i=motifParticles.length-1;i>=0;i--){ const p=motifParticles[i];
    p.y+=p.vy*dt; p.x+=p.drift*dt; p.life-=dt*0.16;
    if(p.life<=0 || p.y<S.H*0.14){ motifParticles.splice(i,1); continue; }
    ctx.save(); ctx.translate(p.x,p.y); drawMotif(p.type,p.size,p.life,S.t,p.col); ctx.restore();
  }
}

function drawVenue(){
  if(!venueOn) return;
  const W=S.W,H=S.H;
  const si=(window.SRM&&window.SRM.showIdx);
  let vName, vLights;
  if(si!=null && si>=0 && si<VENUES.length){ vName=VENUES[si].name; vLights=VENUES[si].lights; }
  else { vName=(window.SRM&&window.SRM.venueLabel)||''; vLights=['#ffcf6e','#ff7a4a','#a8c8ff','#c9a2ff']; }  // searched show
  const V={name:vName, lights:vLights};
  // stage-light beams fanning down from the rig, gently sweeping, brightening on the beat
  ctx.save(); ctx.globalCompositeOperation='lighter';
  for(let i=0;i<4;i++){
    const bx=W*(0.16+0.22*i), sweep=Math.sin(S.t*0.5+i*1.3)*W*0.06, tx=bx+sweep;
    ctx.globalAlpha=0.045+(S.amp||0)*0.06+(S.pulse||0)*0.05;
    ctx.fillStyle=V.lights[i%V.lights.length];
    ctx.beginPath(); ctx.moveTo(bx-16,0); ctx.lineTo(bx+16,0); ctx.lineTo(tx+78,H*0.62); ctx.lineTo(tx-78,H*0.62); ctx.closePath(); ctx.fill();
  }
  ctx.restore(); ctx.globalAlpha=1;
  // venue marquee
  ctx.save();
  const fs=Math.max(13,Math.min(21,W/60));
  ctx.font=`italic 600 ${fs}px "Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif`;
  ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.shadowColor='rgba(0,0,0,0.65)'; ctx.shadowBlur=14;
  ctx.globalAlpha=0.92; ctx.fillStyle=pal('accent',0);
  ctx.fillText(V.name, W*0.5, H*0.14);
  ctx.restore();
}

// cutout ("South Park") styling: flat fill + a bold dark outline, round joins
const OUTLINE='rgba(8,3,5,0.92)';
function cut(fill,w){ ctx.fillStyle=fill; ctx.strokeStyle=OUTLINE; ctx.lineWidth=w||2.4; ctx.lineJoin='round'; ctx.lineCap='round'; }
function fillOutline(){ ctx.fill(); ctx.stroke(); }   // paint the current path as a cutout shape

// ---------- Resize ----------
function resize(){
  S.DPR = Math.min(2, window.devicePixelRatio||1);
  S.W = window.innerWidth; S.H = window.innerHeight;
  [sky,scene].forEach(c=>{
    c.width = Math.max(1,Math.round(S.W*S.DPR)); c.height = Math.max(1,Math.round(S.H*S.DPR));
    c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0);
  });
  buildSky();
}
window.addEventListener('resize', resize);

// ---------- Sky (redrawn on chapter change / resize) ----------
let skyDirty = true;
function buildSky(){ skyDirty = true; }
function drawSky(){
  const W=S.W,H=S.H;
  const g = sctx.createLinearGradient(0,0,0,H*0.72);
  g.addColorStop(0, pal('sky',0));
  g.addColorStop(0.45, pal('sky',1));
  g.addColorStop(0.8, pal('sky',2));
  g.addColorStop(1, pal('sky',3));
  sctx.clearRect(0,0,W,H);
  sctx.fillStyle=g; sctx.fillRect(0,0,W,H*0.74);

  // Moon / sun — position drifts with the journey
  const cx = lerp(W*0.78, W*0.28, S.chapterMix/2);
  const cy = lerp(H*0.18, H*0.30, Math.sin(S.chapterMix/2*Math.PI));
  const r = lerp(46, 62, S.chapterMix/2);
  const glow = sctx.createRadialGradient(cx,cy,4,cx,cy,r*3.2);
  glow.addColorStop(0, pal('lantern',0)); glow.addColorStop(0.15, pal('accent',0));
  glow.addColorStop(1,'rgba(0,0,0,0)');
  sctx.fillStyle=glow; sctx.beginPath(); sctx.arc(cx,cy,r*3.2,0,7); sctx.fill();
  sctx.fillStyle=pal('lantern',0); sctx.beginPath(); sctx.arc(cx,cy,r,0,7); sctx.fill();

  // Stars, densest at Moon Bend
  const starA = Math.max(0, 1-Math.abs(S.chapterMix-1)) * 0.9;
  if(starA>0.02){
    sctx.fillStyle=`rgba(255,240,210,${starA})`;
    for(let i=0;i<120;i++){
      const sx=(i*137.5)%W, sy=((i*89.3)%(H*0.5));
      const tw = 0.4+0.6*Math.abs(Math.sin(i*1.7));
      sctx.globalAlpha=starA*tw; sctx.fillRect(sx,sy,1.5,1.5);
    }
    sctx.globalAlpha=1;
  }
  skyDirty=false;
}

// ---------- Canyon layers (parallax, far -> near) ----------
function canyonSilhouette(baseY, amp, seed, color, offset){
  const W=S.W,H=S.H;
  ctx.fillStyle=color;
  ctx.beginPath();
  ctx.moveTo(0,H);
  ctx.lineTo(0,baseY);
  const step = W/14;
  for(let x=0;x<=W+step;x+=step){
    const n = Math.sin((x*0.006)+seed) + 0.5*Math.sin((x*0.017)+seed*2.3);
    const y = baseY - amp*(0.6+0.4*n) - offset;
    ctx.lineTo(x, y);
  }
  ctx.lineTo(W,H);
  ctx.closePath();
  ctx.fill();
}

// ---------- Boat transform for the active camera ----------
function boatTransform(){
  const W=S.W,H=S.H;
  const bob = S.reduced?0:Math.sin(S.t*0.6)*6*(1+(S.amp||0)*1.1);
  const sway = S.reduced?0:Math.sin(S.t*0.33)*0.012;
  let cx=W*0.5, cy=H*0.60+bob, scale=Math.min(W,H)/620, rot=sway;
  if(S.cam==='deck'){ cx=W*0.5; cy=H*0.66+bob; scale=Math.min(W,H)/300; rot=sway*0.6; }
  if(S.cam==='over'){ cx=W*0.5; cy=H*0.52; scale=Math.min(W,H)/540; rot=0; }
  return {cx,cy,scale,rot,bob};
}

// ---------- Dancers ----------
function drawFigure(x,y,s,phase,type,accent){
  const t=S.t, ch=CH();
  ctx.save();
  ctx.translate(x,y);
  ctx.scale(s,s);

  let turn=0, lean=0, bob=0, scarf=0, armSwing=0;
  const ph = phase;
  if(type==='couple'){
    turn = Math.sin(t*1.1+ph)*0.9*ch.coupleTurn;   // shared pivot, chapter-weighted
    lean = Math.sin(t*1.1+ph)*0.12*ch.coupleTurn;
    bob = Math.abs(Math.sin(t*1.1+ph))* -3;         // rise on the turn
    armSwing = 0.7;
  } else if(type==='circle'){
    turn = Math.sin(t*0.8+ph)*0.35;
    bob = Math.abs(Math.sin(t*1.6+ph))*-2.4;
    lean = Math.sin(t*0.8+ph)*0.05;
    armSwing=0.4;
  } else if(type==='solo'){
    turn = (t*1.8+ph)%(Math.PI*2);                  // continuous spin
    scarf = 1; bob = Math.abs(Math.sin(t*2.2+ph))*-2;
    armSwing=1.0;
  } else if(type==='musician'){
    bob = Math.sin(t*1.4+ph)*-1.6;
    armSwing = 0.5+0.3*Math.sin(t*2+ph);
  }
  ctx.rotate(lean);
  ctx.translate(0,bob*(1+(S.amp||0)*0.7));   // dancers bounce harder on louder passages when reactive

  // scarf trail for soloists — length/opacity swell across chapters
  if(type==='solo' && scarf){
    ctx.save();
    ctx.rotate(turn);
    ctx.strokeStyle=accent; ctx.globalAlpha=0.5*ch.scarf; ctx.lineWidth=2.2;
    ctx.beginPath();
    const seg = Math.round(10*ch.scarf);
    for(let i=0;i<seg;i++){
      const a=i*0.5, rr=8+i*2.4;
      const px=Math.cos(a+t*3)* rr, py=-14+Math.sin(a+t*3)*rr*0.4;
      i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
    }
    ctx.stroke(); ctx.globalAlpha=1; ctx.restore();
  }

  ctx.rotate(turn*0.28);
  const body='rgba(74,30,34,0.98)';   // flat cutout costume tone

  // head (with a mask mark) — flat fill + bold outline
  cut(body,1.4);
  ctx.beginPath(); ctx.arc(0,-22,4.6,0,7); fillOutline();
  ctx.fillStyle=accent; ctx.globalAlpha=0.9; ctx.fillRect(-3.4,-24,6.8,2.4); ctx.globalAlpha=1;   // mask band

  // torso (tapered) — cutout
  cut(body,1.4);
  ctx.beginPath();
  ctx.moveTo(-4,-18); ctx.quadraticCurveTo(0,-6,-3,4);
  ctx.lineTo(3,4); ctx.quadraticCurveTo(0,-6,4,-18);
  ctx.closePath(); fillOutline();

  // arms — swing conveys weight/effort (bold outline limbs)
  const sw = Math.sin(t*(type==='musician'?2:1.4)+ph)*armSwing;
  ctx.lineWidth=2.8; ctx.strokeStyle=OUTLINE; ctx.lineCap='round';
  ctx.beginPath();
  ctx.moveTo(-3,-15); ctx.lineTo(-9-sw*4,-10+sw*3);
  ctx.moveTo(3,-15);  ctx.lineTo(9-sw*4,-10-sw*3);
  ctx.stroke();

  // legs — bent-knee push (the source of the bob)
  const legPush = Math.abs(Math.sin(t*(type==='solo'?2.2:1.1)+ph));
  ctx.beginPath();
  ctx.moveTo(-2,3); ctx.lineTo(-3-legPush*1.5, 16);
  ctx.moveTo(2,3);  ctx.lineTo(3+ (1-legPush)*1.5, 16);
  ctx.stroke();

  // musician's instrument = abstract shadow bar
  if(type==='musician'){
    ctx.strokeStyle=accent; ctx.globalAlpha=0.5; ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(4,-14); ctx.lineTo(14,-18); ctx.stroke();
    ctx.globalAlpha=1;
  }
  ctx.restore();
}

// Little lantern with glow
function lantern(x,y,s,glow,color){
  if(glow>0.02){
    const g=ctx.createRadialGradient(x,y,1,x,y,26*s*(0.5+glow));
    g.addColorStop(0,color); g.addColorStop(0.4,`rgba(255,190,90,${0.35*glow})`);
    g.addColorStop(1,'rgba(0,0,0,0)');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,26*s*(0.5+glow),0,7); ctx.fill();
  }
  ctx.fillStyle=color; ctx.beginPath(); ctx.arc(x,y,2.6*s,0,7); ctx.fill();
}

// ---------- The Riverboat ----------
function drawBoat(){
  const {cx,cy,scale,rot}=boatTransform();
  const accent=pal('accent',0), lan=pal('lantern',0);
  const glow=S.lantern*(1+(S.amp||0)*0.9);   // lanterns bloom on the beat when reactive
  ctx.save();
  ctx.translate(cx,cy); ctx.rotate(rot); ctx.scale(scale,scale);

  if(S.cam==='over'){ drawBoatOverhead(accent,lan,glow); ctx.restore(); return; }

  // ----- hull -----
  cut('rgba(52,16,22,0.98)',3.5);
  ctx.beginPath();
  ctx.moveTo(-230,40);
  ctx.quadraticCurveTo(-260,10,-210,4);
  ctx.lineTo(210,4);
  ctx.quadraticCurveTo(262,10,232,40);
  ctx.quadraticCurveTo(0,78,-230,40);
  ctx.closePath(); fillOutline();
  // waterline highlight
  ctx.strokeStyle=pal('riverHi',0); ctx.globalAlpha=0.5; ctx.lineWidth=2;
  ctx.beginPath(); ctx.moveTo(-215,42); ctx.quadraticCurveTo(0,72,215,42); ctx.stroke();
  ctx.globalAlpha=1;

  // ----- three decks -----
  const deckDefs=[
    {y:4,  w:200, h:-34, n:9},   // lower
    {y:-30,w:160, h:-30, n:7},   // middle
    {y:-60,w:120, h:-26, n:5}    // upper
  ];
  deckDefs.forEach((d,di)=>{
    ctx.fillStyle='rgba(72,26,30,0.97)';
    ctx.fillRect(-d.w, d.y+d.h, d.w*2, 6);
    ctx.strokeStyle=OUTLINE; ctx.lineWidth=1.6; ctx.strokeRect(-d.w, d.y+d.h, d.w*2, 6);
    ctx.strokeStyle='rgba(70,24,28,0.9)'; ctx.lineWidth=2;
    for(let px=-d.w; px<=d.w; px+=d.w/6){
      ctx.beginPath(); ctx.moveTo(px,d.y+d.h); ctx.lineTo(px,d.y+d.h+6); ctx.stroke();
    }
    const nl = 6;
    for(let i=0;i<=nl;i++){
      const lx=-d.w + (2*d.w)*(i/nl);
      const flick = S.reduced?1:(0.85+0.15*Math.sin(S.t*3+i+di)+(S.pulse||0)*0.7);  // lamps throb on the beat
      lantern(lx, d.y+d.h-4, 1, glow*flick, lan);
    }
  });

  // ----- smokestacks -----
  [-40,40].forEach(sx=>{
    ctx.fillStyle='rgba(46,14,20,0.98)'; ctx.strokeStyle=OUTLINE; ctx.lineWidth=2.4;
    ctx.fillRect(sx-7,-140,14,86); ctx.strokeRect(sx-7,-140,14,86);
    ctx.fillStyle=accent; ctx.globalAlpha=0.7; ctx.fillRect(sx-9,-142,18,6); ctx.globalAlpha=1;
    ctx.strokeRect(sx-9,-142,18,6);
  });
  // smoke drift
  if(!S.reduced){
    ctx.fillStyle=`rgba(200,140,120,0.10)`;
    for(let i=0;i<6;i++){
      const t=S.t*0.5+i; const sx=-40+Math.sin(t)*8;
      ctx.beginPath(); ctx.arc(sx-Math.sin(t*0.4)*30, -150-i*16-(S.t*4%20), 8+i*2,0,7); ctx.fill();
    }
  }

  // ----- pilot house -----
  ctx.fillStyle='rgba(64,22,26,0.98)'; ctx.strokeStyle=OUTLINE; ctx.lineWidth=2.4;
  ctx.fillRect(-30,-104,60,30); ctx.strokeRect(-30,-104,60,30);
  ctx.fillStyle=accent; ctx.globalAlpha=0.4; ctx.fillRect(-24,-98,48,10); ctx.globalAlpha=1;

  // ----- paddlewheel (right side) -----
  drawPaddlewheel(232,26,accent);

  // ----- populate decks with dancers -----
  populateDecks(deckDefs,accent);

  ctx.restore();
}

function drawPaddlewheel(x,y,accent){
  const spin = S.reduced? 0 : S.t*2.1;
  ctx.save(); ctx.translate(x,y);
  ctx.strokeStyle='rgba(60,20,24,0.9)'; ctx.lineWidth=3;
  ctx.beginPath(); ctx.arc(0,0,34,0,7); ctx.stroke();
  ctx.save(); ctx.rotate(spin);
  ctx.strokeStyle=accent; ctx.lineWidth=2.4; ctx.globalAlpha=0.85;
  for(let i=0;i<8;i++){
    ctx.rotate(Math.PI/4);
    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-32); ctx.stroke();
    ctx.fillStyle='rgba(50,16,20,0.9)';
    ctx.fillRect(-9,-34,18,7);
  }
  ctx.restore();
  ctx.globalAlpha=1;
  ctx.fillStyle=accent; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill();
  if(!S.reduced){
    ctx.fillStyle='rgba(240,200,170,0.18)';
    for(let i=0;i<5;i++){ const a=(S.t*3+i)%1; ctx.beginPath(); ctx.arc(6+a*24,34+Math.sin(i)*3,3-a*2,0,7); ctx.fill();}
  }
  ctx.restore();
}

function populateDecks(deckDefs, accent){
  const density = S.crowd, ch = CH();
  // Deck 0 (lower): couples turning beneath lanterns
  const d0=deckDefs[0];
  const nCouples = Math.round(lerp(0,5,density));
  for(let i=0;i<nCouples;i++){
    const gx = -d0.w+30 + (2*d0.w-60)*(nCouples<=1?0.5:i/(nCouples-1));
    const gap = 9+Math.sin(S.t*1.1+i)*3;
    drawFigure(gx-gap, d0.y+d0.h-2, 0.62, i*1.3, 'couple', accent);
    drawFigure(gx+gap, d0.y+d0.h-2, 0.62, i*1.3+Math.PI, 'couple', accent);
  }
  // Deck 1 (middle): linked circle around the smokestack + a soloist
  const d1=deckDefs[1];
  const ringN = Math.round(lerp(3,8,density));
  const ringR = 46, ringSpin = S.reduced?0:S.t*0.5*ch.ringSpeed*ch.ringDir;
  // linking ribbon between the circle dancers
  ctx.strokeStyle=accent; ctx.globalAlpha=0.28; ctx.lineWidth=1.6;
  ctx.beginPath();
  for(let i=0;i<=ringN;i++){
    const a=(i/ringN)*Math.PI*2+ringSpin;
    const rx=Math.cos(a)*ringR, ry=d1.y+d1.h-2+Math.sin(a)*6*ch.ringSway;
    i===0?ctx.moveTo(rx,ry):ctx.lineTo(rx,ry);
  }
  ctx.stroke(); ctx.globalAlpha=1;
  for(let i=0;i<ringN;i++){
    const a = (i/ringN)*Math.PI*2 + ringSpin;
    const rx = Math.cos(a)*ringR, rz=Math.sin(a)*0.42;
    const fx = rx, fy = d1.y+d1.h-2 + Math.sin(a)*6*ch.ringSway;
    drawFigure(fx, fy, 0.5*(0.8+rz*0.2+0.2), i, 'circle', accent);
  }
  if(density>0.3) drawFigure(d1.w-30, d1.y+d1.h-2, 0.6, 2.0, 'solo', accent);

  // Deck 2 (upper): musicians as abstract silhouettes + a soloist
  const d2=deckDefs[2];
  const nMus = Math.round(lerp(1,4,density));
  for(let i=0;i<nMus;i++){
    const gx=-d2.w+26 + (2*d2.w-52)*(i/Math.max(1,nMus));
    drawFigure(gx, d2.y+d2.h-2, 0.55, i*2.1, 'musician', accent);
  }
  if(density>0.5) drawFigure(0, d2.y+d2.h-2, 0.58, 1.1, 'solo', accent);
  // dancing bears join the revelers up on deck
  if(density>0.3){
    bearCub(-d0.w+52, d0.y+d0.h-2, 0.42, S.t, 0.5, BEAR_COLORS[0]);
    bearCub(d0.w-52,  d0.y+d0.h-2, 0.42, S.t, 2.2, BEAR_COLORS[3]);
    bearCub(0,        d1.y+d1.h-2, 0.40, S.t, 4.0, BEAR_COLORS[2]);
  }
}

// Overhead camera: top-down of the circle around the smokestack
function drawBoatOverhead(accent,lan,glow){
  const ch=CH();
  ctx.fillStyle='rgba(38,12,16,0.92)';
  ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.fill();
  ctx.strokeStyle='rgba(70,24,28,0.8)'; ctx.lineWidth=3;
  ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.stroke();
  // bow marker
  ctx.fillStyle=accent; ctx.globalAlpha=0.5;
  ctx.beginPath(); ctx.moveTo(0,-118); ctx.lineTo(-10,-102); ctx.lineTo(10,-102); ctx.closePath(); ctx.fill();
  ctx.globalAlpha=1;
  // central smokestack (top)
  ctx.fillStyle='rgba(20,6,10,0.98)';
  ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.fill();
  ctx.strokeStyle=accent; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.stroke(); ctx.globalAlpha=1;
  // linked circle rotating around it — the marquee choreography, seen from above
  const ringN=Math.round(lerp(4,12,S.crowd)); const R=78; const spin=S.reduced?0:S.t*0.55*ch.ringSpeed*ch.ringDir;
  ctx.strokeStyle=accent; ctx.globalAlpha=0.35; ctx.lineWidth=2;
  ctx.beginPath();
  for(let i=0;i<=ringN;i++){const a=(i/ringN)*Math.PI*2+spin; const x=Math.cos(a)*R,y=Math.sin(a)*R*0.62; i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
  ctx.closePath(); ctx.stroke(); ctx.globalAlpha=1;
  for(let i=0;i<ringN;i++){
    const a=(i/ringN)*Math.PI*2+spin;
    const x=Math.cos(a)*R, y=Math.sin(a)*R*0.62;
    ctx.save(); ctx.translate(x,y);
    ctx.fillStyle=pal('dancer',0); ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
    ctx.fillStyle=accent; ctx.globalAlpha=0.8; ctx.fillRect(-4,-1.5,8,3); ctx.globalAlpha=1;
    ctx.restore();
    lantern(Math.cos(a)*(R+22),Math.sin(a)*(R+22)*0.62,0.7,glow*0.8,lan);
  }
  // couples as small paired dots on the outer deck
  const nc=Math.round(lerp(0,6,S.crowd));
  for(let i=0;i<nc;i++){
    const a=(i/Math.max(1,nc))*Math.PI*2 - spin*0.6; const r=150;
    const x=Math.cos(a)*r, y=Math.sin(a)*r*0.6;
    const g=6+Math.sin(S.t*1.1+i)*3;
    ctx.fillStyle=pal('dancer',0);
    ctx.beginPath(); ctx.arc(x-g,y,3.4,0,7); ctx.fill();
    ctx.beginPath(); ctx.arc(x+g,y,3.4,0,7); ctx.fill();
  }
}

// ---------- Shoreline animals ----------
// ---- roaming / tempo helpers: animals travel across the scene and step to the beat ----
// _tempoMul: 1.0 ≈ neutral. Detected music BPM drives it when reacting, else the Visual Tempo slider.
function _tempoMul(){
  if(window.SRM && window.SRM.react && window.SRM.bpm>40) return clamp(window.SRM.bpm/110, 0.7, 2.2);
  return 0.7 + S.tempo*0.65;                      // 0.7..2.0 from the slider
}
// _roam: a horizontal position that walks across the screen and wraps off-screen; returns x + facing dir.
// Derived from S.t (which already integrates tempo) so locomotion speeds up/slows with the music.
function _roam(seed, speed){
  const dir = (seed%2===0) ? 1 : -1;
  const span = S.W*1.30;
  const p = (((S.t*speed*0.06*_tempoMul()) + seed*0.31) % 1 + 1) % 1;
  const x = dir>0 ? (-S.W*0.15 + p*span) : (S.W*1.15 - p*span);
  return {x, dir};
}
// every animal gets a small upward HOP on each detected beat when reacting to the music
function _beatHop(){ return (window.SRM && window.SRM.react) ? (S.pulse||0)*10 : 0; }
// place a creature at its roamed position, facing its travel direction, hopping on the beat
function _place(fn, roam, y, s, accent, hop){
  ctx.save();
  ctx.translate(roam.x, y - hop);
  ctx.scale(roam.dir, 1);                         // flip to face the way it's walking/flying
  fn(0, 0, s, S.t, accent);
  ctx.restore();
}

function drawAnimals(){
  if(S.animals<=0.02) return;
  const W=S.W,H=S.H;
  const baseY=H*0.78;
  const accent=pal('accent',0);
  ctx.save();
  ctx.globalAlpha=Math.min(1,S.animals);
  // shore band
  ctx.fillStyle=pal('canyon',0);
  ctx.beginPath(); ctx.moveTo(0,baseY);
  for(let x=0;x<=W;x+=40){ctx.lineTo(x,baseY+Math.sin(x*0.02)*6);}
  ctx.lineTo(W,H); ctx.lineTo(0,H); ctx.fill();

  const t=S.t;
  const scaleA = S.cam==='deck'?1.15:1;
  const hop=_beatHop();

  // walkers now ROAM the shore (each faces its direction of travel, hops on the beat)
  _place(fox,              _roam(0,1.00), baseY-6, 1*scaleA,   accent, hop);
  _place(heron,            _roam(3,0.55), baseY-4, 1*scaleA,   accent, hop*0.6);
  _place(rabbit,           _roam(4,1.35), baseY-4, 1*scaleA,   accent, hop*1.4);
  _place(bearAndFireflies, _roam(1,0.42), baseY-2, 1.1*scaleA, accent, hop*0.8);
  if(!S.playing && !S.reduced){ drawIdleBackflipBear(scaleA, baseY); } else { drawBearCubs(t, scaleA, baseY); }   // idle: a single bear backflips across; playing: the troupe dances + sidesteps to tempo
  drawSongbirds(t, scaleA, baseY);  // songbirds now FLY around the sky

  ctx.restore();
}

function drawIdleBackflipBear(scaleA, baseY){
  const W=S.W;
  const cross=8.5, gap=1.4, span=cross+gap;
  const tt=S.idleT||0; const idx=Math.floor(tt/span); const local=tt%span;
  if(local>cross) return;
  const prog=local/cross;
  const FUR=BEAR_COLORS[idx%BEAR_COLORS.length];
  const size=1.55*scaleA, margin=64;
  const x=-margin + prog*(W+margin*2);
  const flips=3, fp=(prog*flips)%1;
  const lift=Math.sin(fp*Math.PI)*54*scaleA;
  const spin=-(prog*flips)*Math.PI*2;
  ctx.save(); ctx.translate(x,(baseY+8)-lift); ctx.rotate(spin);
  bearCub(0,0,size,(S.idleT||0)*0.8,idx*1.3,FUR); ctx.restore();
}

function fox(x,y,s,t,accent){
  const gait=Math.sin(t*3.2);
  const bob=Math.abs(Math.sin(t*3.2))*-4;
  ctx.save(); ctx.translate(x + Math.sin(t*0.4)*30, y+bob); ctx.scale(s,s);
  cut('rgba(150,58,42,0.98)');
  ctx.beginPath(); ctx.ellipse(0,-10,15,7,0,0,7); fillOutline();
  ctx.beginPath(); ctx.moveTo(12,-14); ctx.lineTo(24,-16); ctx.lineTo(13,-8); ctx.closePath(); fillOutline();
  ctx.beginPath(); ctx.moveTo(14,-18); ctx.lineTo(17,-24); ctx.lineTo(19,-17);ctx.closePath(); fillOutline();
  ctx.strokeStyle=accent; ctx.lineWidth=5; ctx.lineCap='round'; ctx.globalAlpha=0.8;
  ctx.beginPath(); ctx.moveTo(-14,-10); ctx.quadraticCurveTo(-26,-14-gait*5,-30,-4-gait*3); ctx.stroke();
  ctx.globalAlpha=1;
  ctx.strokeStyle='rgba(30,8,12,0.95)'; ctx.lineWidth=2.4;
  const lg=gait*4;
  ctx.beginPath();
  ctx.moveTo(-8,-4); ctx.lineTo(-8-lg,3);
  ctx.moveTo(8,-4);  ctx.lineTo(8+lg,3);
  ctx.moveTo(-4,-4); ctx.lineTo(-4+lg,3);
  ctx.moveTo(4,-4);  ctx.lineTo(4-lg,3);
  ctx.stroke();
  ctx.restore();
}

function heron(x,y,s,t,accent){
  const step=Math.sin(t*0.9);
  const wing=Math.sin(t*1.2)*0.5;
  ctx.save(); ctx.translate(x + Math.sin(t*0.25)*18, y); ctx.scale(s,s);
  ctx.strokeStyle='rgba(24,8,14,0.95)'; ctx.fillStyle='rgba(24,8,14,0.95)';
  ctx.lineWidth=2;
  ctx.beginPath();
  ctx.moveTo(-2,-18); ctx.lineTo(-4,2+ (step>0?0:step*8));
  ctx.moveTo(3,-18);  ctx.lineTo(5,2+ (step>0?step*8:0));
  ctx.stroke();
  cut('rgba(96,44,66,0.98)');
  ctx.beginPath(); ctx.ellipse(0,-24,10,7,-0.2,0,7); fillOutline();
  ctx.save(); ctx.translate(0,-26); ctx.rotate(wing*0.6);
  ctx.beginPath(); ctx.moveTo(0,0); ctx.quadraticCurveTo(-14,-4,-20,4); ctx.quadraticCurveTo(-10,2,0,3); ctx.closePath(); fillOutline();
  ctx.restore();
  ctx.lineWidth=2.4;
  ctx.beginPath(); ctx.moveTo(6,-28);
  ctx.quadraticCurveTo(16,-34,12,-46); ctx.stroke();
  ctx.beginPath(); ctx.arc(12,-48,3,0,7); ctx.fill();
  ctx.strokeStyle=accent; ctx.beginPath(); ctx.moveTo(14,-48); ctx.lineTo(24,-49); ctx.stroke();
  ctx.restore();
}

function rabbit(x,y,s,t,accent){
  const cyc=(t*1.6)%(Math.PI*2);
  const air=Math.max(0,Math.sin(cyc));
  const stretch=1+air*0.4, squash=1-Math.max(0,-Math.sin(cyc))*0.35;
  const hopY=-air*9;   // low, grounded hop — stays on the shore
  ctx.save();
  ctx.translate(x, y+hopY);   // roam is handled by _place(); keep only the vertical hop
  ctx.scale(s*(2-stretch)*0.9 + s*0.1, s*stretch*squash);
  cut('rgba(120,66,60,0.98)',2);
  ctx.beginPath(); ctx.ellipse(0,-8,9,7,0,0,7); fillOutline();
  ctx.beginPath(); ctx.arc(8,-12,4.5,0,7); fillOutline();
  const ear = air*0.8;
  ctx.save(); ctx.translate(8,-15); ctx.rotate(ear);
  ctx.beginPath(); ctx.ellipse(0,-6,2,6,0.2,0,7); ctx.fill();
  ctx.beginPath(); ctx.ellipse(4,-6,2,6,0.4,0,7); ctx.fill();
  ctx.restore();
  ctx.fillStyle=accent; ctx.globalAlpha=0.85;
  ctx.beginPath(); ctx.arc(-9,-8,3,0,7); ctx.fill(); ctx.globalAlpha=1;
  ctx.fillStyle='rgba(34,10,14,0.95)';
  ctx.beginPath(); ctx.ellipse(-3,-2+air*2,4,2,air*0.6,0,7); ctx.fill();
  ctx.restore();
}

function bearAndFireflies(x,y,s,t,accent){
  const wob=Math.sin(t*0.35)*3;
  ctx.save(); ctx.translate(x+wob,y); ctx.scale(s,s);
  cut('rgba(74,34,34,0.98)',2.8);
  ctx.beginPath(); ctx.ellipse(0,-16,18,20,0,0,7); fillOutline();
  ctx.beginPath(); ctx.ellipse(-14,-2,10,8,0,0,7); fillOutline();
  ctx.beginPath(); ctx.ellipse(14,-2,10,8,0,0,7); fillOutline();
  ctx.beginPath(); ctx.arc(0,-36,11,0,7); fillOutline();
  ctx.beginPath(); ctx.arc(-9,-45,4,0,7); fillOutline();
  ctx.beginPath(); ctx.arc(9,-45,4,0,7); fillOutline();
  ctx.strokeStyle='rgba(20,6,10,0.97)'; ctx.lineWidth=6; ctx.lineCap='round';
  const ax=Math.sin(t*0.7)*16, ay=-Math.abs(Math.cos(t*0.7))*10;
  ctx.beginPath(); ctx.moveTo(6,-24); ctx.quadraticCurveTo(16,-34,18+ax,-40+ay); ctx.stroke();
  ctx.strokeStyle=accent; ctx.lineWidth=2; ctx.globalAlpha=0.8;
  ctx.beginPath(); ctx.moveTo(18+ax,-40+ay); ctx.lineTo(18+ax+6,-44+ay); ctx.stroke();
  ctx.globalAlpha=1;
  ctx.restore();

  // fireflies respond to the conducting motion (authored, not audio)
  const n=14;
  for(let i=0;i<n;i++){
    const ph=i*0.7;
    const orbit=30+ (i%5)*10;
    const a=t*0.7*(0.6+ (i%3)*0.2) + ph;
    const fx=x + Math.cos(a)*orbit + Math.sin(t*0.7)*14;
    const fy=y-40 + Math.sin(a*1.3)*orbit*0.5 - (i%4)*6;
    const blink=Math.min(1,(0.35+0.65*Math.abs(Math.sin(t*3+ph)))*(1+(S.amp||0)*1.3));
    const g=ctx.createRadialGradient(fx,fy,0,fx,fy,7);
    g.addColorStop(0,`rgba(255,230,140,${blink})`); g.addColorStop(1,'rgba(255,200,90,0)');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,7,0,7); ctx.fill();
  }
}

// two bear cubs dancing beside the conductor — bob + rock, bouncing harder on the beat when reactive
// articulated "puppet-style" dancing bear — jointed limbs swing, hips sway, head bobs; bigger on the beat
function bearCub(x,y,s,t,ph,FUR){
  const beat=(S.pulse||0), amp=(S.amp||0), e=1+beat*0.9+amp*0.5;
  const sway=Math.sin(t*2.8+ph)*0.30*e;                 // big lively hip sway
  const bob=Math.abs(Math.sin(t*5.4+ph))*(4.5+amp*4) + beat*15;  // always grooving; big HOP on each beat when reacting
  const step=Math.sin(t*2.8+ph);                        // alternating dance steps
  const armL=Math.sin(t*3.6+ph), armR=Math.sin(t*3.6+ph+Math.PI);  // hands wave overhead, alternating
  FUR = FUR || 'rgba(84,40,38,0.98)';
  ctx.save(); ctx.translate(x,y-bob); ctx.scale(s*1.2,s*1.2); ctx.rotate(sway);
  function limb(px,py,ang,len,w,foot){                  // a jointed, outlined limb
    ctx.save(); ctx.translate(px,py); ctx.rotate(ang); ctx.lineCap='round';
    ctx.strokeStyle=OUTLINE; ctx.lineWidth=w+2.4; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
    ctx.strokeStyle=FUR;     ctx.lineWidth=w;     ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,len); ctx.stroke();
    if(foot){ cut(FUR,1.8); ctx.beginPath(); ctx.ellipse(0,len,3.2,2,0,0,7); fillOutline(); }
    ctx.restore();
  }
  limb(-3.5,-3, 0.12+step*0.7, 12, 6.5, true);          // legs — big alternating dance steps
  limb(3.5,-3, 0.12-step*0.7, 12, 6.5, true);
  cut(FUR,2.6); ctx.beginPath(); ctx.ellipse(0,-12,8.5,11,0,0,7); fillOutline();   // body
  ctx.fillStyle='rgba(255,255,255,0.22)'; ctx.beginPath(); ctx.ellipse(0,-10,4.5,6,0,0,7); ctx.fill(); // belly highlight
  limb(-7,-18, 2.3+armL*0.7+beat*0.45, 11, 5, true);    // arms overhead, big waves + thrust up on the beat
  limb(7,-18, -2.3+armR*0.7-beat*0.45, 11, 5, true);
  cut(FUR,2.4); ctx.beginPath(); ctx.arc(0,-30,7,0,7); fillOutline();              // head
  ctx.beginPath(); ctx.arc(-6,-36,3,0,7); fillOutline();                          // ears
  ctx.beginPath(); ctx.arc(6,-36,3,0,7); fillOutline();
  cut('rgba(150,96,74,0.98)',1.5); ctx.beginPath(); ctx.ellipse(0,-27,3.6,2.7,0,0,7); fillOutline(); // muzzle
  ctx.fillStyle=OUTLINE; ctx.beginPath(); ctx.arc(0,-29,1.1,0,7); ctx.fill();      // nose
  ctx.restore();
}
// a user-sized colorful dancing-bear troupe (1..10), auto-distributed + scaled across the shore (our own design, not the GD trademark)
const BEAR_COLORS=['rgba(206,66,116,0.98)','rgba(232,160,52,0.98)','rgba(72,172,150,0.98)','rgba(124,110,224,0.98)','rgba(226,96,60,0.98)','rgba(96,182,96,0.98)','rgba(232,120,182,0.98)','rgba(82,158,224,0.98)','rgba(214,182,60,0.98)','rgba(154,92,204,0.98)'];
function drawBearCubs(t,scaleA,baseY){
  const W=S.W, N=Math.max(1,Math.min(10,Math.round(S.bears)));
  const margin=W*0.05, span=W-2*margin;
  const size=Math.max(0.9, Math.min(2.1, 13/(N+2.5)))*scaleA;   // fewer bears bigger, more bears smaller to fit
  const mul=_tempoMul();
  const groupDrift=Math.sin(t*0.22*mul)*W*0.05;                 // the whole troupe sways across the shore to tempo
  for(let i=0;i<N;i++){
    const home = (N===1) ? W*0.16 : margin + span*(i/(N-1));
    const step = Math.sin(t*2.0*mul + i*1.3)*(W*0.022+8);       // each cub sidesteps/boogies in time
    const x = Math.max(margin*0.4, Math.min(W-margin*0.4, home + groupDrift + step));
    const yj = ((i*53)%9)-4;                                    // slight stagger so the row isn't a flat line
    bearCub(x, baseY+8+yj, size, t, i*1.3, BEAR_COLORS[i%BEAR_COLORS.length]);
  }
}

// shoreline songbirds that "sing" the music — note-bubbles pop on the beat (♪♫♬, never lyrics)
const NOTES=['♪','♫','♬'];
function songbird(x,y,s,t,ph){
  const bob=Math.sin(t*3+ph)*1.4;
  ctx.save(); ctx.translate(x,y+bob); ctx.scale(s,s);
  cut('rgba(158,74,56,0.98)',1.8);
  ctx.beginPath(); ctx.ellipse(0,0,6,5,0,0,7); fillOutline();               // body
  ctx.beginPath(); ctx.arc(4,-5,3,0,7); fillOutline();                      // head
  ctx.beginPath(); ctx.moveTo(-5,0); ctx.lineTo(-12,-3); ctx.lineTo(-6,3); ctx.closePath(); fillOutline(); // tail
  ctx.fillStyle='rgba(242,182,74,0.95)';                                    // beak
  ctx.beginPath(); ctx.moveTo(6,-5); ctx.lineTo(11,-4); ctx.lineTo(6,-3); ctx.closePath(); ctx.fill();
  ctx.restore();
  // song bubble
  const beat=(S.pulse||0), amp=(S.amp||0), reacting=!!(window.SRM&&window.SRM.react);
  let show,pop;
  if(reacting){ show = amp>0.24||beat>0.15; pop = 0.7+beat*1.0+amp*0.3; }
  else { const c=Math.sin(t*1.7+ph); show=c>-0.5; pop=0.82+Math.max(0,c)*0.4; }
  if(show){
    ctx.save(); ctx.translate(x+10*s, y-17*s); ctx.scale(pop*s*0.95, pop*s*0.95);
    cut('rgba(244,231,214,0.96)',1.6);
    ctx.beginPath(); ctx.ellipse(0,0,11,8,0,0,7); fillOutline();
    ctx.beginPath(); ctx.moveTo(-4,6); ctx.lineTo(-9,12); ctx.lineTo(-1,7); ctx.closePath(); fillOutline(); // tail toward bird
    ctx.fillStyle='rgba(30,10,14,0.95)';
    ctx.font='bold 12px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText(NOTES[Math.abs((t*2+ph)|0)%3], 0, 0);
    ctx.restore();
  }
}
function drawSongbirds(t,scaleA,baseY){
  const W=S.W, H=S.H, N=5, mul=_tempoMul();   // songbirds now FLY around the sky, facing their heading
  for(let i=0;i<N;i++){
    const r=_roam(10+i, 0.85+i*0.14);
    const lane=0.16+0.30*(0.5+0.5*Math.sin(i*1.7));            // spread across sky lanes
    const y=H*lane + Math.sin(t*2.2*mul + i*1.5)*24;           // rise/dip flight-path, cadence follows tempo
    ctx.save(); ctx.translate(r.x, y); ctx.scale(r.dir,1);
    songbird(0, 0, (0.85+0.2*Math.sin(i))*scaleA, t, i*1.3);
    ctx.restore();
  }
}

// ---------- River foreground with parallax reflections ----------
function drawRiver(){
  const W=S.W,H=S.H;
  const top=H*0.72;
  const g=ctx.createLinearGradient(0,top,0,H);
  g.addColorStop(0, pal('river',0));
  g.addColorStop(1, shade(pal('river',0),0.5));
  ctx.fillStyle=g; ctx.fillRect(0,top,W,H-top);
  const hi=pal('riverHi',0);
  ctx.strokeStyle=hi;
  for(let layer=0;layer<3;layer++){
    const alpha=0.16-layer*0.04, speed=(layer+1)*0.5;
    ctx.globalAlpha=alpha; ctx.lineWidth=1.5+layer;
    for(let i=0;i<12;i++){
      const y=top+ (H-top)*((i+ (S.t*speed*0.02*(layer+1))%1)/12);
      const amp=(y-top)*0.06;
      ctx.beginPath();
      for(let x=0;x<=W;x+=24){
        const yy=y+Math.sin((x*0.02)+S.t*speed+layer)*amp*(S.reduced?0.3:1);
        x===0?ctx.moveTo(x,yy):ctx.lineTo(x,yy);
      }
      ctx.stroke();
    }
  }
  ctx.globalAlpha=1;
  if(S.lantern>0.05 && S.cam!=='over'){
    const {cx}=boatTransform();
    const rg=ctx.createRadialGradient(cx,top+30,4,cx,top+30,120);
    rg.addColorStop(0,`rgba(255,190,90,${Math.min(0.6,0.22*S.lantern*(1+(S.amp||0)))})`); rg.addColorStop(1,'rgba(0,0,0,0)');
    ctx.fillStyle=rg; ctx.beginPath(); ctx.ellipse(cx,top+40,120,40,0,0,7); ctx.fill();
  }
}

// ---------- Beat-triggered fireworks (only when React to Music is on) ----------
let fireworks=[];
function spawnFirework(px,py){
  if(fireworks.length>600) return;
  const W=S.W,H=S.H;
  const x=(px!==undefined)?px:W*(0.30+Math.random()*0.40);
  const y=(py!==undefined)?py:H*(0.14+Math.random()*0.20);
  const color=pal('accent',0), warm=pal('lantern',0);
  const n=20+((Math.random()*12)|0);
  for(let i=0;i<n;i++){
    const a=(i/n)*Math.PI*2 + Math.random()*0.2, sp=1.4+Math.random()*2.6;
    fireworks.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:1,color: i%3?color:warm});
  }
}
function drawFireworks(dt){
  if(!fireworks.length) return;
  ctx.save(); ctx.globalCompositeOperation='lighter';
  for(let i=fireworks.length-1;i>=0;i--){
    const p=fireworks[i];
    p.x+=p.vx; p.y+=p.vy; p.vy+=0.03; p.vx*=0.98; p.vy*=0.98; p.life-=dt*0.85;
    if(p.life<=0){ fireworks.splice(i,1); continue; }
    ctx.globalAlpha=Math.max(0,p.life);
    ctx.fillStyle=p.color;
    ctx.beginPath(); ctx.arc(p.x,p.y,2.2*p.life+0.6,0,7); ctx.fill();
  }
  ctx.restore(); ctx.globalAlpha=1;
}
let _seenBeat=0;

// ---------- Pterodactyl fly-over (button / key 'p') ----------
let ptero=null;
function launchPtero(){
  ptero={ p:0, y:S.H*(0.12+Math.random()*0.14), dir:(Math.random()<0.5?1:-1), scale:1.3+Math.random()*0.7 };
  announce('A pterodactyl glides across the canyon.');
}
function drawPtero(dt){
  if(!ptero) return;
  ptero.p += dt*0.16;                         // crosses in ~6s
  if(ptero.p>=1){ ptero=null; return; }
  const W=S.W, span=W+280;
  const x = ptero.dir>0 ? -140+ptero.p*span : W+140-ptero.p*span;
  const y = ptero.y + Math.sin(ptero.p*Math.PI*2.4)*22;   // soaring undulation
  const flap = S.reduced?0.5:(Math.sin(S.t*7)*0.5+0.5);   // wingbeat
  ctx.save(); ctx.translate(x,y); ctx.scale(ptero.dir*ptero.scale, ptero.scale);
  ctx.fillStyle='rgba(14,4,8,0.9)';
  // wings (flapping) + body
  ctx.beginPath();
  ctx.moveTo(0,0);
  ctx.quadraticCurveTo(-16,-8-flap*22,-44,-3);
  ctx.quadraticCurveTo(-18,6,-4,4);
  ctx.lineTo(4,4);
  ctx.quadraticCurveTo(18,6,44,-3);
  ctx.quadraticCurveTo(16,-8-flap*22,0,0);
  ctx.closePath(); ctx.fill();
  // long beak forward
  ctx.beginPath(); ctx.moveTo(3,0); ctx.lineTo(22,-3); ctx.lineTo(4,3); ctx.closePath(); ctx.fill();
  // swept-back head crest
  ctx.beginPath(); ctx.moveTo(2,-1); ctx.lineTo(-7,-10); ctx.lineTo(5,-2); ctx.closePath(); ctx.fill();
  // trailing tail
  ctx.strokeStyle='rgba(14,4,8,0.9)'; ctx.lineWidth=1.6; ctx.beginPath(); ctx.moveTo(-2,2); ctx.lineTo(-16,6); ctx.stroke();
  ctx.restore();
}

// ---------- Main loop ----------
let last=performance.now();
function frame(now){
  const dt=Math.min(0.05,(now-last)/1000); last=now;
  S.idleT=(S.idleT||0)+dt;

  // live audio energy (0..1) when "React to Music" is on — otherwise motion stays authored
  const reacting = !!(window.SRM && window.SRM.react);
  S.amp = reacting ? (window.SRM.level||0) : 0;
  S.pulse = reacting ? (window.SRM.pulse||0) : 0;   // sharp per-beat throb for lamps
  // bridge visual state to the separate WebGL orb loop so it can honor reduced motion + pause
  if(window.SRM){ window.SRM.reduced = S.reduced; window.SRM.playing = S.playing; }
  // fire a burst on each new detected beat
  if(reacting && !S.reduced && window.SRM.beat>_seenBeat){ _seenBeat=window.SRM.beat; spawnFirework(); }
  else if(!reacting){ _seenBeat=window.SRM?(window.SRM.beat||0):0; }   // let fireworks decay naturally

  if(S.playing){
    const spd = S.reduced ? 0.28 : 1.0;
    S.t += dt * S.tempo * spd * 1.4;

    // Gentle auto-journey through the three chapters (until the visitor takes over)
    if(S.autoJourney && !S.reduced){
      S.journeyClock += dt;
      if(S.journeyClock >= CHAPTER_SECONDS){
        S.journeyClock = 0;
        setChapter((S.chapter+1)%3, {auto:true});
      }
    }
  }

  // ease chapter mix toward target
  const prevMix=S.chapterMix;
  S.chapterMix += (S.chapter - S.chapterMix)*Math.min(1,dt*2.2);
  if(Math.abs(S.chapter-S.chapterMix)>0.001 || Math.abs(prevMix-S.chapterMix)>0.001) skyDirty=true;

  if(skyDirty) drawSky();

  const W=S.W,H=S.H;
  if(S.trails && !S.reduced && S.playing){
    ctx.fillStyle='rgba(6,2,4,0.16)';
    ctx.fillRect(0,0,W,H);
  } else {
    ctx.clearRect(0,0,W,H);
  }

  // parallax canyon (farthest -> nearest)
  canyonSilhouette(H*0.54, 150, 6.1, shade(pal('canyon',0),0.35), Math.sin(S.t*0.03)*3);
  canyonSilhouette(H*0.62, 120, 1.2, pal('canyon',0), Math.sin(S.t*0.05)*4);
  canyonSilhouette(H*0.70, 90, 4.5, pal('canyon',2), Math.sin(S.t*0.05+2)*3);

  drawRiver();
  drawAnimals();
  drawBoat();
  drawVenue();
  drawSongMotifs(dt);
  drawPtero(dt);
  drawFireworks(dt);

  requestAnimationFrame(frame);
}

// ---------- UI wiring ----------
const tempoWords=['Adagio','Andante','Moderato','Allegro','Vivace'];
const lanternWords=['Doused','Dim','Warm','Bright','Blazing'];
const crowdWords=['Empty','Sparse','Lively','Full','Teeming'];
const animalWords=['Hidden','Few','Some','Most','All'];
function word(arr,v){return arr[Math.min(arr.length-1,Math.floor(v*arr.length))];}

const tempoEl=document.getElementById('tempo');
const tempoV=document.getElementById('tempoV');
tempoEl.addEventListener('input',e=>{
  S.tempo=e.target.value/100; // 0..2
  const w=word(tempoWords, Math.min(0.999,S.tempo/2));
  tempoV.textContent=w;
  e.target.setAttribute('aria-valuetext',w);
});

function wireSlider(id,valId,arr,setter){
  const el=document.getElementById(id), v=document.getElementById(valId);
  el.addEventListener('input',e=>{
    const val=e.target.value/100; setter(val);
    const w=word(arr,val); v.textContent=w; e.target.setAttribute('aria-valuetext',w);
  });
}
wireSlider('lantern','lanternV',lanternWords,val=>S.lantern=val);
wireSlider('crowd','crowdV',crowdWords,val=>S.crowd=val);
wireSlider('animals','animalsV',animalWords,val=>S.animals=val);

// Dancing Bears count (1..10) — plain integer slider
const bearsEl=document.getElementById('bears');
bearsEl.addEventListener('input',e=>{
  S.bears=+e.target.value; const v=String(S.bears);
  document.getElementById('bearsV').textContent=v; e.target.setAttribute('aria-valuetext',v);
  announce(v+' dancing '+(S.bears==1?'bear':'bears')+'.');
});

// Sync every label to its true computed word on load (no hardcoded mismatch)
['tempo','lantern','crowd','animals'].forEach(id=>{
  document.getElementById(id).dispatchEvent(new Event('input'));
});

const playBtn=document.getElementById('playBtn');
playBtn.addEventListener('click',()=>{
  S.playing=!S.playing;
  playBtn.setAttribute('aria-pressed',S.playing);
  playBtn.textContent=S.playing?'❚❚ Pause':'► Play';
  announce(S.playing?'Playing.':'Paused.');
});
document.getElementById('restartBtn').addEventListener('click',()=>{
  S.t=0; S.playing=true; S.autoJourney=true; S.journeyClock=0;
  playBtn.setAttribute('aria-pressed','true'); playBtn.textContent='❚❚ Pause';
  setChapter(0,{silent:true});
  ctx.clearRect(0,0,S.W,S.H);
  announce('Restarted the journey from Departure.');
});
const reduceBtn=document.getElementById('reduceBtn');
reduceBtn.addEventListener('click',()=>{
  S.reduced=!S.reduced; reduceBtn.setAttribute('aria-pressed',S.reduced);
  if(S.reduced) ctx.clearRect(0,0,S.W,S.H);
  announce(S.reduced?'Reduced motion on. Movement gentled.':'Reduced motion off.');
});
if(S.reduced) reduceBtn.setAttribute('aria-pressed','true');
const trailBtn=document.getElementById('trailBtn');
trailBtn.addEventListener('click',()=>{
  S.trails=!S.trails; trailBtn.setAttribute('aria-pressed',S.trails);
  if(!S.trails) ctx.clearRect(0,0,S.W,S.H);
  announce(S.trails?'Motion trails on.':'Motion trails off.');
});

// cameras
document.querySelectorAll('[data-cam]').forEach(b=>{
  b.addEventListener('click',()=>{
    S.cam=b.dataset.cam;
    document.querySelectorAll('[data-cam]').forEach(x=>x.setAttribute('aria-pressed', x===b));
    ctx.clearRect(0,0,S.W,S.H);
    const names={wide:'Wide river',deck:'Deck',over:'Overhead'};
    announce(names[S.cam]+' view.');
  });
});

// chapters
const chapLabel=document.getElementById('chapLabel');
const romans=['I','II','III'];
function setChapter(i, opts){
  opts=opts||{};
  S.chapter=i; skyDirty=true;
  if(!opts.auto){ S.autoJourney=false; } // a manual pick takes the wheel from the auto-journey
  S.journeyClock=0;
  document.querySelectorAll('[data-chapter]').forEach(b=>b.setAttribute('aria-current', String(+b.dataset.chapter===i)));
  chapLabel.textContent=`Chapter ${romans[i]} · ${CHAPTERS[i].name}`;
  if(!opts.silent) announce(`Chapter ${romans[i]}, ${CHAPTERS[i].name}. Palette and choreography shifting.`);
}
document.querySelectorAll('[data-chapter]').forEach(b=>{
  b.addEventListener('click',()=>setChapter(+b.dataset.chapter));
});

// museum label
const labelBtn=document.getElementById('labelBtn');
const museum=document.getElementById('museum');
labelBtn.addEventListener('click',()=>{
  const open=museum.hidden;
  museum.hidden=!open;
  labelBtn.setAttribute('aria-expanded',String(open));
});

function announce(msg){ live.textContent=msg; }

// master Controls toggle — everything starts collapsed; click to reveal / hide the panels
const stageEl=document.getElementById('stage');
const chromeBtn=document.getElementById('chromeBtn');
function toggleChrome(){
  const open=stageEl.classList.toggle('controls-open');
  chromeBtn.setAttribute('aria-pressed', String(open));
  chromeBtn.textContent = open ? '✕ Close' : '☰ Controls';
  announce(open ? 'Controls open.' : 'Controls closed.');
}
chromeBtn.addEventListener('click', toggleChrome);

// Venue backdrop toggle
const venueBtn=document.getElementById('venueBtn');
venueBtn.addEventListener('click',()=>{
  venueOn=!venueOn; venueBtn.setAttribute('aria-pressed',String(venueOn));
  if(window.__vbVenue) window.__vbVenue(venueOn);      // also toggle the concert-video backdrop (TK-11178)
  announce(venueOn?'Concert-video backdrop on.':'Concert-video backdrop off.');
});

// Orb on/off (Steve: "allow user to turn on the orb"). Off on load; drag it to move it around.
const orbBtn=document.getElementById('orbBtn');
if(orbBtn) orbBtn.addEventListener('click',()=>{
  window.__orbOn=!window.__orbOn;
  orbBtn.setAttribute('aria-pressed',String(window.__orbOn));
  document.getElementById('stage').classList.toggle('orb-on', window.__orbOn);
  announce(window.__orbOn?'Orb on — drag it to move it around the room.':'Orb off.');
});

// keyboard shortcuts
window.addEventListener('keydown',e=>{
  const t=e.target;
  // While a form field has focus, let it own every key (arrows in the show <select> must not switch chapters)
  if(t && t.matches && t.matches('input,select,textarea')) return;
  // Let native buttons handle their own Space/Enter activation
  if(t && t.matches && t.matches('button,[role="button"]') && (e.key===' '||e.key==='Enter')) return;
  switch(e.key.toLowerCase()){
    case ' ': e.preventDefault(); playBtn.click(); break;
    case 'r': document.getElementById('restartBtn').click(); break;
    case 'm': reduceBtn.click(); break;
    case 't': trailBtn.click(); break;
    case 'a': { const rb=document.getElementById('reactBtn'); if(rb) rb.click(); } break;
    case 'p': launchPtero(); break;
    case 'c': toggleChrome(); break;
    case 'v': venueBtn.click(); break;
    case '1': document.querySelector('[data-cam="wide"]').click(); break;
    case '2': document.querySelector('[data-cam="deck"]').click(); break;
    case '3': document.querySelector('[data-cam="over"]').click(); break;
    case 'arrowright': e.preventDefault(); setChapter(Math.min(2,S.chapter+1)); break;
    case 'arrowleft': e.preventDefault(); setChapter(Math.max(0,S.chapter-1)); break;
    case 'l': labelBtn.click(); break;
  }
});

// pterodactyl fly-over button
document.getElementById('pteroBtn').addEventListener('click', launchPtero);

// click / tap anywhere on the scene (not on a control) to launch a firework
document.getElementById('stage').addEventListener('pointerdown', e=>{
  if(e.target.closest('button,input,select,a,.dock,.cams,.chapters,.musicroom,.museum,.label-toggle')) return;
  const r=scene.getBoundingClientRect();
  spawnFirework(e.clientX-r.left, e.clientY-r.top);
  spawnFirework(e.clientX-r.left, e.clientY-r.top);  // a fuller double burst on a deliberate tap
});

// init
resize();
requestAnimationFrame(frame);
})();
</script>

<script>
/* ---------- Music Room: streams the Grateful Dead Live Music Archive (archive.org) ----------
   Reuses the curated show catalog from the dead-agentabrams app. Listen-only:
   audio streams straight from the Internet Archive, is user-initiated, and links
   back to each source item. The visual scene stays authored — no audio analysis. */
(() => {
"use strict";
const CATALOG = [
  {id:"gd69-02-27.sbd.16track.kaplan.6315.sbeok.shnf", date:"February 27, 1969", title:"Fillmore West", sub:"San Francisco · primal psychedelic era"},
  {id:"gd70-05-24.sbd.hanno.6481.sbeok.shnf", date:"May 24, 1970", title:"Newcastle-Under-Lyme", sub:"Newcastle · acoustic-and-electric era"},
  {id:"gd71-04-14.sbd.lai.6250.sbefail.shnf", date:"April 14, 1971", title:"Christy Mathewson Stadium, Bucknell U.", sub:"Lewisburg · Skull & Roses era"},
  {id:"gd72-08-27.sbd.orf.3328.sbeok.shnf", date:"August 27, 1972", title:"Veneta", sub:"Oregon · field, heat, and open sky"},
  {id:"gd73-02-15.sbd.hall.1580.sbeok.shnf", date:"February 15, 1973", title:"Dane County Coliseum", sub:"Madison · jazz-loose / Wake of the Flood era"},
  {id:"gd74-06-23.sbd.cribbs.16780.sbeok.shnf", date:"June 23, 1974", title:"Jai-Alai Fronton", sub:"Miami · Wall of Sound era"},
  {id:"gd76-06-10.sbd.bertha-ashley.23370.sbeok.shnf", date:"June 10, 1976", title:"Boston Music Hall", sub:"Boston · return-from-hiatus era"},
  {id:"gd77-05-08.sbd.hicks.4982.sbeok.shnf", date:"May 8, 1977", title:"Cornell", sub:"Ithaca · Barton Hall"},
  {id:"gd78-02-01.sbd.parillo.1017.sbeok.shnf", date:"February 1, 1978", title:"Uptown Theater", sub:"Chicago · tight & funky late-’70s era"},
  {id:"gd81-03-21.sbd.clugston.6666.sbeok.shnf", date:"March 21, 1981", title:"Rainbow Theatre", sub:"London · European-tour era"},
  {id:"gd85-04-27.sbd.jerugim.359.sbeok.shnf", date:"April 27, 1985", title:"Frost Amphitheatre", sub:"Palo Alto · 20th-anniversary / Frost era"},
  {id:"gd1987-01-28.sbd.walker-scotton.miller.83286.flac16", date:"January 28, 1987", title:"S.F Civic Auditorium", sub:"San Francisco · In the Dark comeback era"},
  {id:"gd89-12-31.sbd.bertha-ashley.18674.sbeok.shnf", date:"December 31, 1989", title:"Oakland-Alameda County Coliseum", sub:"Oakland · late-peak / Built to Last era"},
  {id:"gd90-03-29.aud-fob.set2.unknown.1317.sbeok.shnf", date:"March 29, 1990", title:"Nassau", sub:"Uniondale · late-era expansion"},
  {id:"gd91-10-31.sbd.gardner.2897.sbeok.shnf", date:"October 31, 1991", title:"Oakland-Alameda County Coliseum", sub:"Oakland · post-Brent / Bruce Hornsby era"},
  {id:"gd93-03-24.sbd.keyser.25664.sbeok.flacf", date:"March 24, 1993", title:"Dean Smith Center", sub:"Chapel Hill · Vince era / stadium years"},
  {id:"gd94-10-05.sbd.unknown.8030.sbeok.shnf", date:"October 5, 1994", title:"The Spectrum", sub:"Philadelphia · final-years era"},
  {id:"gd95-07-09.sbd.7233.sbeok.shnf", date:"July 9, 1995", title:"Soldier Field", sub:"Chicago · the final show"}
];
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)'}
];

// ---------- Videos panel — browse the concert library; a pick is a MANUAL TRIGGER into the ONE
// VideoBackdrop controller (VB), which plays it as the stage backdrop + audio. (TK-11178
// consolidation: the old separate #vrBackdrop/vrEmbed layer + .vr-bg were removed.) ----------
(function(){
  const vr = document.getElementById('videoroom');
  const vrToggle = document.getElementById('vrToggle');
  const vrList = document.getElementById('vrList');
  if (!vr || !vrToggle || !vrList) return;
  function setOpen(open){
    vr.dataset.open = open ? 'true' : 'false';
    vrToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
    if(open){ const mr=document.getElementById('musicroom'), mt=document.getElementById('mrToggle');   // one panel at a time (no mobile collision)
      if(mr){ mr.setAttribute('data-open','false'); if(mt) mt.setAttribute('aria-expanded','false'); } }
  }
  vrToggle.addEventListener('click', () => setOpen(vr.dataset.open !== 'true'));
  vrToggle.addEventListener('keydown', e => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); vrToggle.click(); }
  });
  // Steve: "it's a movie screen!" — the concert plays on a FIXED movie screen on the stage
  // (.movie-on in CSS), so the old backdrop position/size sliders are gone (they controlled
  // the retired small #venueVideo box and now do nothing). Panel = header + hint + transport + year list.
  const rows = [];
  function selectVideo(v){
    vrList.querySelectorAll('.vr-row.on').forEach(r => r.classList.remove('on'));
    const row = rows.find(r => r.dataset.vid === v.id);
    if (row) { row.classList.add('on'); row.scrollIntoView({ block: 'nearest' }); }
    vr.dataset.curId = v.id;
    playConcertManual(v.id, v.title);   // manual trigger → the one backdrop controller (hoisted from the Music Room IIFE)
  }
  MR_VIDEOS.forEach(v => {
    const row = document.createElement('button');
    row.type = 'button';
    row.className = 'vr-row';
    row.dataset.vid = v.id;
    row.setAttribute('role', 'listitem');
    row.innerHTML = `<span class="ry">${v.year}</span><span class="rt">${v.title}</span>`;
    row.addEventListener('click', () => selectVideo(v));
    vrList.appendChild(row);
    rows.push(row);
  });
  // Prev/Next concert — step through MR_VIDEOS relative to whatever's currently selected.
  function curIndex(){
    const i = MR_VIDEOS.findIndex(v => v.id === vr.dataset.curId);
    return i; // -1 if nothing selected yet
  }
  const prevBtn = document.getElementById('vrPrev');
  const nextBtn = document.getElementById('vrNext');
  if (prevBtn) prevBtn.addEventListener('click', () => {
    const i = curIndex();
    const next = MR_VIDEOS[i <= 0 ? MR_VIDEOS.length - 1 : i - 1];   // wrap from the first to the last
    selectVideo(next);
  });
  if (nextBtn) nextBtn.addEventListener('click', () => {
    const i = curIndex();
    const next = MR_VIDEOS[i < 0 || i >= MR_VIDEOS.length - 1 ? 0 : i + 1];   // wrap from the last back to the first
    selectVideo(next);
  });
  // ±30s seek within whatever's currently playing. (VB is a same-script top-level const,
  // declared further down but already initialized by the time any click can fire.)
  function seekBy(deltaSeconds){
    const p = (typeof VB !== 'undefined') ? VB.player : null;
    if (!p || !p.getCurrentTime || !p.seekTo) return;
    try {
      const dur = p.getDuration ? p.getDuration() : Infinity;
      const t = Math.max(0, Math.min(dur || Infinity, p.getCurrentTime() + deltaSeconds));
      p.seekTo(t, true);
    } catch (e) {}
  }
  const back30 = document.getElementById('vrBack30');
  const fwd30 = document.getElementById('vrFwd30');
  if (back30) back30.addEventListener('click', () => seekBy(-30));
  if (fwd30) fwd30.addEventListener('click', () => seekBy(30));
})();

// ---- Movie screen: drag anywhere to move, drag the gold corner to scale up/down (Steve). Persisted. ----
(function(){
  const stage=document.getElementById('stage'), vv=document.getElementById('venueVideo');
  if(!stage||!vv) return;
  const root=document.documentElement.style, KEY='deadroom.movie.v1';
  const st=Object.assign({dx:0,dy:0,scale:1}, (function(){try{return JSON.parse(localStorage.getItem(KEY))||{};}catch(e){return {};}})());
  const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
  function apply(){ root.setProperty('--movie-dx',st.dx+'px'); root.setProperty('--movie-dy',st.dy+'px'); root.setProperty('--movie-scale',String(st.scale)); }
  function save(){ try{ localStorage.setItem(KEY,JSON.stringify(st)); }catch(e){} }
  apply();
  const mover=vv.querySelector('.mv-move');
  if(mover) mover.addEventListener('pointerdown',e=>{
    e.preventDefault(); try{mover.setPointerCapture(e.pointerId);}catch(_){}
    const sx=e.clientX, sy=e.clientY, ox=st.dx, oy=st.dy;
    const mv=ev=>{ st.dx=clamp(ox+(ev.clientX-sx),-innerWidth*0.45,innerWidth*0.45); st.dy=clamp(oy+(ev.clientY-sy),-innerHeight*0.30,innerHeight*0.60); apply(); };
    const up=()=>{ mover.removeEventListener('pointermove',mv); mover.removeEventListener('pointerup',up); save(); };
    mover.addEventListener('pointermove',mv); mover.addEventListener('pointerup',up);
  });
  const sizer=vv.querySelector('.mv-size');
  if(sizer) sizer.addEventListener('pointerdown',e=>{
    e.preventDefault(); e.stopPropagation(); try{sizer.setPointerCapture(e.pointerId);}catch(_){}
    const sx=e.clientX, os=st.scale;
    const mv=ev=>{ st.scale=clamp(os+(ev.clientX-sx)/400,0.35,3.2); apply(); };
    const up=()=>{ sizer.removeEventListener('pointermove',mv); sizer.removeEventListener('pointerup',up); save(); };
    sizer.addEventListener('pointermove',mv); sizer.addEventListener('pointerup',up);
  });
})();

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'); _stageEl.classList.remove('yt-hero'); }
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){
  if(!window.YT) return;
  // Steve: "wait to hear music then dance" — kick the dancers into motion once the film
  // actually rolls, and make sure the movie screen is showing.
  if(e.data===YT.PlayerState.PLAYING){
    try{ setPlay(true); }catch(_){}
    if(VB.mode==='youtube') _stageEl.classList.add('movie-on');
  }
  // loop the backdrop so the visuals (and, in fallback, the audio) persist for the whole show
  if(e.data===YT.PlayerState.ENDED){ try{ VB.player.seekTo(0); VB.player.playVideo(); }catch(_){} }
}
function _onPlayerError(e){
  // Only hop for GENUINE unavailability — 100 (removed/private), 101/150 (embedding disabled).
  // Codes 2 (bad param) and 5 (HTML5 playback, e.g. headless browsers) are transient → don't cascade the library.
  const code=e&&e.data;
  if(code!==100 && code!==101 && code!==150) return;
  VB._tried=VB._tried||new Set(); VB._tried.add(VB.curId);
  VB._hops=(VB._hops||0)+1;
  const alt=(VB._hops<=3) ? MR_VIDEOS.find(v=>!VB._tried.has(v.id)) : null;  // bounded: at most 3 hops, each video once
  if(!alt){
    if(VB.mode==='youtube'){ const m=$('mrMsg'); if(m){ m.hidden=false; m.textContent='This concert video is unavailable right now — try another night, or check back when archive.org is up.'; } }
    return;
  }
  VB.wantId=alt.id; VB.curId=null; _applyVideo();
  if(VB.mode==='youtube'){                                   // keep the on-screen title + Watch link in step with what's actually playing
    $('mrNow').textContent='▶ Grateful Dead — '+alt.title;
    const m=$('mrMsg'); if(m && !m.hidden){ m.innerHTML=m.innerHTML.replace(/watch\?v=[\w-]+/, 'watch?v='+alt.id); }
  }
}
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._tried=new Set(); VB._hops=0;                         // new intended show → reset the bounded error-hop guard
  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 done=()=>{ document.removeEventListener('pointerdown',go,true); document.removeEventListener('keydown',go,true); _ytUnlockArmed=false; };
  const go=()=>{
    if(VB.mode!=='youtube'){ done(); return; }               // left the fallback — stop listening
    if(VB.forceMuted) return;                                 // a manual Videos-panel pick wants silence — don't let a stray gesture unmute it
    if(!VB.player || !VB.player.unMute){ return; }            // player not built yet — KEEP listening for the next gesture (no wasted tap)
    try{ VB.player.unMute(); VB.player.setVolume(100); VB.player.playVideo(); }catch(e){}
    done();
  };
  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';
  _stageEl.classList.remove('yt-hero');                   // scene is the star when the real SBD tape is the audio
  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, hadGesture){
  VB.mode='youtube'; VB.forceMuted=false;                        // the real outage fallback IS the show — allowed to unmute on a gesture
  try{ audio.pause(); }catch(e){}
  try{ audio.removeAttribute('src'); audio.load(); }catch(e){}   // no archive audio can leak under the video
  // Prefer the gesture captured BEFORE the metadata await (transient activation expires in ~5s); else read it live.
  const hasAct=(hadGesture!=null) ? !!hadGesture : !!(navigator.userActivation ? navigator.userActivation.isActive : false);
  vbSetVideo(show,{play:true,muted:!hasAct});                    // gesture → sound now; cold load → muted+visible
  _stageEl.classList.add('yt-hero');                            // the concert video IS the show now → let it dominate the frame
  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.');
  saveResume();                                            // persist the selected show even in fallback (audio timeupdate won't fire here)
}
// MANUAL trigger from the Videos panel → a MUTED decoration in the small backdrop box behind
// the stage (Steve: "put behind my graphics... the area in back of where singers would sing"),
// NOT the full-screen yt-hero "now watching" mode — that stays reserved for the real
// archive.org-is-down fallback (enterYouTubeFallback), where the video IS the whole show.
function playConcertManual(id, title){
  // Steve: "it's a movie screen!" — a picked concert plays WITH SOUND on the stage movie
  // screen (the vr-row click is a user gesture, so unmuted autoplay is allowed), and the
  // dancers dance in front. No orb.
  VB.mode='youtube'; VB.forceMuted=false;
  try{ audio.pause(); }catch(e){}
  try{ audio.removeAttribute('src'); audio.load(); }catch(e){}   // one audio owner — kill archive audio
  VB.wantId=id; VB.wantPlay=true; VB.wantMuted=false; VB._tried=new Set(); VB._hops=0;
  _loadYTApi();
  if(VB.player && VB.player.loadVideoById) _applyVideo();
  _stageEl.classList.add('movie-on');
  _stageEl.classList.remove('yt-hero','video-bg');
  $('mrNow').textContent='▶ Grateful Dead — '+(title||'concert');
  $('mrSub').textContent='Now playing on the stage screen — official concert footage on YouTube.';
  const msg=$('mrMsg'); if(msg){ msg.hidden=false;
    msg.innerHTML='Playing on the stage movie screen.'+
      ' <a href="https://www.youtube.com/watch?v='+id+'" 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.'; }
  saveResume();
}
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, curShow=null;

CATALOG.forEach((s,i)=>{ const o=document.createElement('option'); o.value=String(i); o.textContent=`${s.date} — ${s.title}`; sel.appendChild(o); });

const fmt = t => { if(!isFinite(t)||t<0) t=0; const m=Math.floor(t/60); return m+':'+String(Math.floor(t%60)).padStart(2,'0'); };
const announce = m => { if(live) live.textContent=m; };

function setFoot(s){
  const short = s.id.split('.')[0];
  $('mrFoot').innerHTML =
    `Source: <a href="https://archive.org/details/${s.id}" target="_blank" rel="noopener noreferrer">archive.org/details/${short}</a> — `+
    `a community recording from the Grateful Dead Live Music Archive, streamed from the Internet Archive. Listen-only; nothing is copied or re-hosted here.`;
}

// Load ANY Grateful Dead archive.org item (curated pick OR a search result)
async function loadShowRef(s, opts){
  opts=opts||{};
  const myToken=++loadToken;               // guard against a stale fetch overwriting a newer selection
  try{ audio.pause(); }catch(e){}          // stop the current night before loading the next
  tracks=[]; trackIdx=-1;
  showIdx = (s && s.venueIdx!=null) ? s.venueIdx : -1;   // TK-11178: real venue index — resume + mid-show failover read this
  curShow = s;                                            // the actual show object (covers search-result shows too)
  const _gestureNow = !!(navigator.userActivation ? navigator.userActivation.isActive : false);  // capture BEFORE the await (transient activation expires ~5s)
  if(window.SRM){ window.SRM.showIdx=(s.venueIdx!=null?s.venueIdx:null); window.SRM.venueLabel=s.venue||[s.title,s.date].filter(Boolean).join(' · '); }
  $('mrNow').textContent=`${s.title} — loading set list…`;
  $('mrSub').textContent=[s.date,s.sub].filter(Boolean).join(' · ');
  $('mrTracks').innerHTML=''; $('mrMsg').hidden=true; $('mrMsg').innerHTML='';
  setFoot(s);
  try{
    if(archiveRecentlyDown()) throw new Error('archive.org recently confirmed down — skip straight to YouTube');
    const r=await fetchT(`https://archive.org/metadata/${s.id}`, 3000);   // fail fast → YouTube fallback (TK-11178; tightened for onload speed)
    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
    let mp3=(j.files||[]).filter(f=>/vbr mp3/i.test(f.format||''));
    if(!mp3.length) mp3=(j.files||[]).filter(f=>/mp3/i.test(f.format||''));
    mp3.sort((a,b)=> (a.name>b.name?1:-1));
    tracks=mp3.map(f=>({
      title:(f.title&&f.title.trim())?f.title.trim():f.name.replace(/\.mp3$/i,''),
      url:`https://archive.org/download/${s.id}/${encodeURIComponent(f.name)}`
    }));
    if(!tracks.length) throw new Error('no audio files');
    renderTracks();
    trackIdx=0; audio.src=tracks[0].url;                 // cue the opener, paused (playback stays user-initiated)
    if(window.SRM) window.SRM.track=tracks[0].title;
    $('mrNow').textContent=`♪ ${tracks[0].title}`;
    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
    enterYouTubeFallback(s, _gestureNow);    // archive.org unreachable → the official YouTube concert becomes the audio (honor the just-made gesture)
  }
}
async function loadShow(i, opts){ if(sel) sel.value=String(i); return loadShowRef(Object.assign({venueIdx:i}, CATALOG[i]), opts); }

// Search the WHOLE Grateful Dead collection on archive.org — type anything (date, venue, city, song)
let searchTimer=null;
// archive.org is a flaky nonprofit — abort a hung request so the UI fails gracefully
// into the error state instead of pulsing "…" forever (5x sweep 2 fix).
// Archive.org health cache (Steve: "Music must start onload fast!!") — archive.org has been
// down for extended stretches, and every fresh page load was re-paying a ~9s round-trip of
// timeouts (pool-build + default-show fetch) before falling back to YouTube. Remember a recent
// outage for a short cooldown so boot skips straight to the fast YouTube path; any successful
// call clears it immediately so recovery is picked up on the very next request, not after a wait.
const ARCHIVE_DOWN_KEY='dead.archiveDownUntil', ARCHIVE_COOLDOWN_MS=10*60*1000;
function archiveRecentlyDown(){ try{ return Date.now() < (+localStorage.getItem(ARCHIVE_DOWN_KEY)||0); }catch(e){ return false; } }
function markArchiveDown(){ try{ localStorage.setItem(ARCHIVE_DOWN_KEY, String(Date.now()+ARCHIVE_COOLDOWN_MS)); }catch(e){} }
function markArchiveUp(){ try{ localStorage.removeItem(ARCHIVE_DOWN_KEY); }catch(e){} }
function fetchT(url, ms=9000, extSignal){
  const ctl = (typeof AbortController!=='undefined') ? new AbortController() : null;
  const t = ctl ? setTimeout(()=>ctl.abort(), ms) : null;
  if(ctl && extSignal){                                   // batch-cancel (e.g. source switch)
    if(extSignal.aborted) ctl.abort();
    else extSignal.addEventListener('abort', ()=>ctl.abort(), {once:true});
  }
  return fetch(url, {mode:'cors', signal: ctl?ctl.signal:undefined})
    .then(r=>{ if(r.ok) markArchiveUp(); return r; })
    .catch(e=>{ markArchiveDown(); throw e; })
    .finally(()=>{ if(t) clearTimeout(t); });
}
// Browse the WHOLE Grateful Dead collection — empty = most-popular shows; text and/or a year narrow it.
async function searchArchive(q){
  const res=$('mrResults'); if(!res) return;
  q=(q||'').trim();
  const yr=($('mrYear')&&$('mrYear').value)||'';
  const coll='collection:'+((window.MR_SOURCES&&window.MR_SOURCES[window.MR_SRC||'gd'])||{id:'GratefulDead'}).id;
  const filters=[]; if(q) filters.push('('+q+')'); if(yr) filters.push('year:'+yr);
  const query = filters.length ? coll+' AND '+filters.join(' AND ') : coll;
  res.innerHTML='<div class="mr-hint">'+(filters.length?'Searching the archive…':'Loading the most-played shows…')+'</div>';
  const sort = 'downloads+desc';
  try{
    const url='https://archive.org/advancedsearch.php?q='+encodeURIComponent(query)+
      '&fl[]=identifier&fl[]=title&fl[]=date&fl[]=venue&fl[]=coverage&fl[]=downloads&sort[]='+sort+'&rows=40&output=json';
    const r=await fetchT(url); const j=await r.json();
    const docs=(j.response&&j.response.docs)||[]; const total=(j.response&&j.response.numFound)||0;
    if(!docs.length){ res.innerHTML='<div class="mr-hint">No shows found. Try a date (1977-05-08), a city, or a song.</div>'; return; }
    res.innerHTML=`<div class="mr-hint">${total.toLocaleString()} shows in the archive${filters.length?' matching':''} · showing ${docs.length}</div>`;
    docs.forEach(d=>{
      const date=(d.date||'').slice(0,10), place=d.venue||d.coverage||d.title||d.identifier;
      const b=document.createElement('button'); b.type='button'; b.className='mr-result'; b.setAttribute('role','listitem');
      b.innerHTML=`<span class="rd">${date||'—'}</span><span class="rv">${place}</span>`;
      b.addEventListener('click',()=>loadShowRef({id:d.identifier, date:date, title:d.venue||d.title||'Grateful Dead', sub:d.coverage||''}, {play:true}));
      res.appendChild(b);
    });
  }catch(e){ res.innerHTML='<div class="mr-hint">Archive browse is unavailable right now — archive.org may be down. Try again in a bit.</div>'; }
}

function fallbackEmbed(s){
  $('mrNow').textContent=s.title;
  const msg=$('mrMsg'); msg.hidden=false;
  const fileNote = location.protocol==='file:' ? ' Opening the file directly (file://) can block cross-site loading — serving it over http fixes it.' : '';
  msg.innerHTML=`Couldn't load the set list here.${fileNote} `+
    `<button class="btn" id="mrEmbedBtn" style="margin-top:8px">▶ Open the archive.org player</button>`;
  $('mrEmbedBtn').onclick=()=>{
    msg.innerHTML=`<iframe title="Internet Archive player for ${s.title}" src="https://archive.org/embed/${s.id}" width="100%" height="150" frameborder="0" style="border-radius:8px;border:1px solid var(--panel-edge)"></iframe>`;
    room.classList.add('playing');
  };
}

function renderTracks(){
  const c=$('mrTracks'); c.innerHTML='';
  tracks.forEach((t,i)=>{
    const b=document.createElement('button'); b.type='button'; b.setAttribute('role','listitem');
    b.innerHTML=`<span class="n">${String(i+1).padStart(2,'0')}</span><span>${t.title}</span>`;
    b.addEventListener('click',()=>playTrack(i));
    c.appendChild(b);
  });
}
function highlight(){ [...$('mrTracks').children].forEach((b,i)=>b.setAttribute('aria-current', String(i===trackIdx))); }

function playTrack(i){
  if(i<0||i>=tracks.length) return;
  if(window.SRM) window.SRM.track=tracks[i].title;   // tell the visuals which song is playing
  if(i!==trackIdx || !audio.src){ trackIdx=i; audio.src=tracks[i].url; }
  audio.play().catch(()=>setPlay(false));
  $('mrNow').textContent=`♪ ${tracks[i].title}`;
  highlight();
  announce(`Now playing ${tracks[i].title}.`);
}
function setPlay(on){
  room.classList.toggle('playing',on);
  const b=$('mrPlay'); b.textContent=on?'❚❚':'▶';
  b.setAttribute('aria-pressed',String(on)); b.setAttribute('aria-label',on?'Pause':'Play');
}

// --- optional audio-reactive bridge: analyzes the LIVE archive.org stream (CORS-open) ---
// Default OFF — visuals stay authored until the visitor flips "React to Music".
audio.crossOrigin='anonymous';                 // set before any src load so the CDN stream is analyzable
window.SRM = window.SRM || { level:0, react:false };
// song-analysis fields the orb + animals read: bpm (detected tempo), vocal (0..1 lyric presence),
// change (a counter that ticks on a NOTICEABLE song change), changeKind ('track'|'tempo'|'vocal-in'|'vocal-out')
Object.assign(window.SRM,{ bpm:0, vocal:0, change:window.SRM.change||0, changeKind:'', changeAt:0 });
let _actx, _analyser, _freq, _looping=false;
// beat-interval ring buffer -> BPM, plus running baselines for change detection
let _beatTimes=[], _bpmBase=0, _vocalSm=0, _vocalState=false, _lastTrack='', _lastChangeClock=-999;
function _median(a){ const b=a.slice().sort((x,y)=>x-y); return b.length? b[(b.length>>1)] : 0; }
function _fireChange(kind){
  if(_clock-_lastChangeClock < 90) return;   // rate-limit: ~1.5s between change events
  _lastChangeClock=_clock;
  window.SRM.change++; window.SRM.changeKind=kind; window.SRM.changeAt=performance.now();
}
function ensureGraph(){
  if(_actx) return true;
  try{
    _actx = new (window.AudioContext||window.webkitAudioContext)();
    const src=_actx.createMediaElementSource(audio);   // once per element
    _analyser=_actx.createAnalyser(); _analyser.fftSize=256; _analyser.smoothingTimeConstant=0.82;
    src.connect(_analyser); _analyser.connect(_actx.destination);
    _freq=new Uint8Array(_analyser.frequencyBinCount);
    return true;
  }catch(e){ return false; }
}
let _avg=0, _clock=0, _lastBeat=-99;
function sampleLevel(){
  // stop the rAF loop when it has nothing to analyze; reactBtn / audio 'play' restart it
  if(!window.SRM.react || audio.paused){ _looping=false; return; }
  if(_analyser){
    _analyser.getByteFrequencyData(_freq);
    const L=_freq.length;                                     // 128 bins, ~0..22kHz
    let sum=0, n=Math.max(1,Math.floor(L/6));                 // low-end (bass) energy
    for(let i=0;i<n;i++) sum+=_freq[i];
    const raw=(sum/n)/255;
    window.SRM.level = window.SRM.level*0.6 + raw*0.4;        // smoothed 0..1
    // vocal-band (~300Hz..3.4kHz -> mid bins) energy, normalised against overall energy:
    // a strong, sustained mid presence over the bass floor reads as "lyrics / a voice is in"
    let vsum=0, vn=0, tsum=0;
    for(let i=3;i<24 && i<L;i++){ vsum+=_freq[i]; vn++; }
    for(let i=0;i<L;i++) tsum+=_freq[i];
    const vocalRaw = tsum>1 ? Math.min(1,(vsum/Math.max(1,vn))/ (tsum/L) * 0.5) : 0;
    _vocalSm = _vocalSm*0.9 + vocalRaw*0.1;
    window.SRM.vocal = _vocalSm;
    // beat detection: a bass spike well above the running average, rate-limited
    _avg = _avg*0.92 + raw*0.08; _clock++;
    if(raw > _avg*1.10 && raw > 0.12 && (_clock-_lastBeat) > 5){
      const now=performance.now();
      if(window.SRM.beat){ const dtb=now-(window.SRM._lastBeatMs||now); if(dtb>240 && dtb<1400){ _beatTimes.push(dtb); if(_beatTimes.length>8) _beatTimes.shift(); } }
      window.SRM._lastBeatMs=now;
      _lastBeat=_clock; window.SRM.beat=(window.SRM.beat||0)+1; window.SRM.pulse=1;
      // BPM from the median beat interval, smoothed
      if(_beatTimes.length>=4){ const bpm=60000/_median(_beatTimes); window.SRM.bpm = window.SRM.bpm? window.SRM.bpm*0.8+bpm*0.2 : bpm; }
    }
    window.SRM.pulse = (window.SRM.pulse||0)*0.86;            // decaying throb (spikes to 1 on a beat)

    // ---- NOTICEABLE-CHANGE detection (drives the orb's UFO-split & celestial morphs) ----
    // 1) a new track started
    const tr=window.SRM.track||'';
    if(tr && tr!==_lastTrack){ _lastTrack=tr; _bpmBase=0; _beatTimes=[]; _fireChange('track'); }
    // 2) a real tempo shift vs the running baseline
    if(window.SRM.bpm>40){
      if(!_bpmBase) _bpmBase=window.SRM.bpm;
      else { if(Math.abs(window.SRM.bpm-_bpmBase)/_bpmBase > 0.18) _fireChange('tempo'); _bpmBase=_bpmBase*0.98+window.SRM.bpm*0.02; }
    }
    // 3) lyrics-vs-just-music crossover (hysteresis so it doesn't flicker)
    if(!_vocalState && _vocalSm>0.42){ _vocalState=true; _fireChange('vocal-in'); }
    else if(_vocalState && _vocalSm<0.26){ _vocalState=false; _fireChange('vocal-out'); }
  }
  requestAnimationFrame(sampleLevel);
}
const reactBtn=$('reactBtn');
if(reactBtn) reactBtn.addEventListener('click',()=>{
  const on=!window.SRM.react;
  if(on){
    if(!ensureGraph()){ announce('Live audio analysis is not available in this browser.'); return; }
    if(_actx.state==='suspended') _actx.resume();
    if(!_looping){ _looping=true; sampleLevel(); }
  }
  window.SRM.react=on;
  reactBtn.setAttribute('aria-pressed',String(on));
  announce(on?'Reacting to the music — lanterns, fireflies, and dancers now move to the live audio.':'Back to authored motion.');
});

audio.addEventListener('play',()=>{ setPlay(true);
  if(window.SRM.react && _actx){ if(_actx.state==='suspended') _actx.resume(); if(!_looping){ _looping=true; sampleLevel(); } }
});
audio.addEventListener('pause',()=>setPlay(false));
audio.addEventListener('ended',()=>{ if(trackIdx<tracks.length-1) playTrack(trackIdx+1); else setPlay(false); });
audio.addEventListener('timeupdate',()=>{
  const d=audio.duration||0, c=audio.currentTime||0;
  $('mrCur').textContent=fmt(c); $('mrDur').textContent=fmt(d);
  if(d>0 && !seeking) $('mrSeek').value=String(Math.round(c/d*1000));
});
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(!curShow) return;                                     // nothing loaded yet
  if(!tracks.length){ enterYouTubeFallback(curShow); return; }   // archive stream died mid-show → YouTube fallback
  // 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();
});
$('mrPrev').addEventListener('click',()=>{ if(tracks.length) playTrack(Math.max(0,trackIdx-1)); });
$('mrNext').addEventListener('click',()=>{ if(tracks.length) playTrack(Math.min(tracks.length-1,trackIdx+1)); });

const seek=$('mrSeek');
seek.addEventListener('input',()=>{ seeking=true; });
seek.addEventListener('change',()=>{ const d=audio.duration||0; if(d>0) audio.currentTime=(+seek.value/1000)*d; seeking=false; });

sel.addEventListener('change',()=>loadShow(+sel.value,{play:true}));

// archive browse wiring — source selector + tappable year-tile grid + debounced search
const searchEl=$('mrSearch'), yearEl=$('mrYear');
const mapEl=$('mrMap'), mapLabel=$('mrMapLabel'), allBtn=$('mrAll'), srcEl=$('mrSrc');

// Two FREE, band-sanctioned Dead archives on archive.org (CORS-open, streamable).
window.MR_SOURCES={
  gd:{id:'GratefulDead',   label:'Grateful Dead', years:'1965–1995', y0:1965, y1:1995},
  dc:{id:'DeadAndCompany', label:'Dead & Company', years:'2015–2023', y0:2015, y1:2023}
};
window.MR_SRC='gd';
const counts={};                              // per (source|year) show-count cache
const ck=(s,y)=>s+':'+y;

function setYear(y){
  yearEl.value=y?String(y):'';
  [...mapEl.children].forEach(c=>c.classList&&c.classList.toggle('on', c.dataset.y===String(y)));
  allBtn.classList.toggle('on', !y);
  const c=counts[ck(window.MR_SRC,y)];
  mapLabel.textContent = y ? `${y}${c!=null?' · '+c.toLocaleString()+' shows':''}` : 'Tap a year';
  searchArchive(searchEl?searchEl.value:'');
}

let buildCtl=null;   // cancels the previous batch of year-count fetches on a source switch
function buildMap(){
  const src=window.MR_SOURCES[window.MR_SRC], mySrc=window.MR_SRC;
  if(buildCtl) buildCtl.abort();                            // kill in-flight fetches from the prior build
  buildCtl=(typeof AbortController!=='undefined')?new AbortController():null;
  const sig=buildCtl?buildCtl.signal:undefined;
  const YEARS=[]; for(let y=src.y0;y<=src.y1;y++) YEARS.push(y);
  mapEl.innerHTML='';
  YEARS.forEach(y=>{
    const b=document.createElement('button'); b.type='button'; b.className='mr-yr loading';
    b.dataset.y=String(y); b.title=y+' — counting…';
    b.innerHTML=`<span class="yl">${y}</span><span class="yc">…</span>`;
    b.addEventListener('click',()=>setYear(y));
    b.addEventListener('mouseenter',()=>{ const c=counts[ck(window.MR_SRC,y)]; if(c!=null) mapLabel.textContent=`${y} · ${c.toLocaleString()} shows`; });
    mapEl.appendChild(b);
  });
  // per-year show counts → heat-color the tiles (sqrt to tame the '77/'89 spikes)
  Promise.all(YEARS.map(y=>
    fetchT('https://archive.org/advancedsearch.php?q='+encodeURIComponent('collection:'+src.id+' AND year:'+y)+'&rows=0&output=json', 9000, sig)
      .then(r=>r.json()).then(j=>({y,n:(j.response&&j.response.numFound)||0})).catch(()=>({y,n:-1}))
  )).then(rows=>{
    if(mySrc!==window.MR_SRC) return;                          // source switched mid-flight
    if(!rows.some(r=>r.n>=0)){ mapEl.innerHTML='<div class="mr-map-err">Couldn’t load the tour history — archive.org may be unreachable. Search still works.</div>'; return; }
    const max=Math.max(1,...rows.filter(r=>r.n>0).map(r=>Math.sqrt(r.n)));
    rows.forEach(({y,n})=>{ const el=mapEl.querySelector('[data-y="'+y+'"]'); if(!el) return;
      el.classList.remove('loading'); const nn=Math.max(0,n); counts[ck(mySrc,y)]=nn;
      el.style.setProperty('--heat',(Math.sqrt(nn)/max).toFixed(3));
      el.querySelector('.yc').textContent=nn.toLocaleString();
      el.title=y+' — '+nn.toLocaleString()+' shows';
    });
    const cur=yearEl.value, c=cur?counts[ck(mySrc,cur)]:null;
    if(cur&&c!=null) mapLabel.textContent=`${cur} · ${c.toLocaleString()} shows`;
  });
}

if(srcEl){
  srcEl.innerHTML=Object.entries(window.MR_SOURCES).map(([k,s])=>
    `<button type="button" data-src="${k}" class="${k===window.MR_SRC?'on':''}" aria-pressed="${k===window.MR_SRC}"><b>${s.label}</b><span class="yr">${s.years}</span></button>`).join('');
  srcEl.addEventListener('click',e=>{
    const btn=e.target.closest('button[data-src]'); if(!btn||btn.dataset.src===window.MR_SRC) return;
    window.MR_SRC=btn.dataset.src;
    [...srcEl.children].forEach(c=>{ const on=c.dataset.src===window.MR_SRC; c.classList.toggle('on',on); c.setAttribute('aria-pressed',String(on)); });
    yearEl.value=''; allBtn.classList.add('on'); mapLabel.textContent='Tap a year';
    buildMap(); searchArchive(searchEl?searchEl.value:'');
  });
}

if(mapEl && yearEl){
  mapEl.addEventListener('mouseleave',()=>{ const on=yearEl.value, c=on?counts[ck(window.MR_SRC,on)]:null; mapLabel.textContent = on?`${on}${c!=null?' · '+c.toLocaleString()+' shows':''}`:'Tap a year'; });
  allBtn.addEventListener('click',()=>setYear(0));
  buildMap();
}

if(searchEl){
  searchEl.addEventListener('input',()=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>searchArchive(searchEl.value),450); });
  searchEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); clearTimeout(searchTimer); searchArchive(searchEl.value); } });
  searchArchive('');   // populate the most-played shows on load
}

$('mrTracksToggle').addEventListener('click',()=>{
  const on=room.classList.toggle('show-tracks');
  $('mrTracksToggle').setAttribute('aria-expanded',String(on));
});

const mrToggle=$('mrToggle');
function toggleRoom(){
  const open=room.getAttribute('data-open')!=='false';
  room.setAttribute('data-open',String(!open));
  mrToggle.setAttribute('aria-expanded',String(!open));
  if(!open){ const vr=document.getElementById('videoroom'), vt=document.getElementById('vrToggle');   // opening Music Room → close Videos (one at a time)
    if(vr){ vr.dataset.open='false'; if(vt) vt.setAttribute('aria-expanded','false'); } }
}
mrToggle.addEventListener('click',toggleRoom);
mrToggle.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); toggleRoom(); } });

// TK-11100 F1 (Steve-approved Option A): on NARROW MOBILE (<=480px) start the Music Room
// COLLAPSED so its position:fixed panel no longer overlays the dock controls (measured
// 0/7 dock buttons tap-reachable while expanded on a 390px screen). One tap on #mrToggle
// re-expands it. Desktop/tablet keep it EXPANDED so revealing the chrome shows a working
// player in one tap (CTA/screenrecord CRITICAL #1) — those concerns don't overlap there.
if (window.matchMedia && matchMedia('(max-width:480px)').matches){
  room.setAttribute('data-open','false');
  mrToggle.setAttribute('aria-expanded','false');
}

// ---- Autostart on load: queue "Me and My Uncle → Bertha → Deal" and play ----
const WISHLIST=['Me and My Uncle','Bertha','Deal'];
function _norm(t){ return (t||'').toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9 ]+/g,' ').replace(/\bthe\b/g,' ').replace(/\s+/g,' ').trim(); }
function _matches(title,want){ const n=_norm(title), w=_norm(want); if(!n||!w) return false;
  return new RegExp('\\b'+w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace(/\s+/g,'\\s+')+'\\b').test(n); }
// fan out to the curated shows in parallel, pool every mp3 track (title + direct url)
async function _poolFromShows(){
  if(archiveRecentlyDown()) return [];   // known-down — don't re-pay a ~6s parallel timeout sweep for an empty result
  const results=await Promise.allSettled(CATALOG.map(s=>
    fetchT(`https://archive.org/metadata/${s.id}`, 3000).then(r=>r.ok?r.json():null).then(j=>({s,j}))));  // fail fast (TK-11178; tightened for onload speed)
  const pool=[];
  for(const res of results){
    if(res.status!=='fulfilled' || !res.value || !res.value.j) continue;
    const s=res.value.s, j=res.value.j;
    let mp3=(j.files||[]).filter(f=>/vbr mp3/i.test(f.format||'')); if(!mp3.length) mp3=(j.files||[]).filter(f=>/mp3/i.test(f.format||''));
    mp3.forEach(f=>pool.push({ title:(f.title&&f.title.trim())?f.title.trim():f.name.replace(/\.mp3$/i,''),
      url:`https://archive.org/download/${s.id}/${encodeURIComponent(f.name)}`, show:s }));
  }
  return pool;
}
// browsers block silent autoplay — try, and if blocked, start on the first tap/keypress
function _tryAutoplay(){
  const go=()=>audio.play().then(()=>setPlay(true)).catch(()=>{});
  audio.play().then(()=>setPlay(true)).catch(()=>{
    const unlock=()=>{ go(); document.removeEventListener('pointerdown',unlock,true); document.removeEventListener('keydown',unlock,true); };
    document.addEventListener('pointerdown',unlock,true);
    document.addEventListener('keydown',unlock,true);
    announce('Tap or press any key to start the music — Me and My Uncle, Bertha, Deal.');
  });
}
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); }
    if(VB.mode!=='youtube') _tryAutoplay();   // YT fallback owns its own unlock + announcement
    return;
  }
  $('mrNow').textContent='Cueing the set…';
  try{
    const pool=await _poolFromShows();
    const list=[]; for(const w of WISHLIST){ const hit=pool.find(p=>_matches(p.title,w)); if(hit) list.push(hit); }
    if(list.length){
      tracks=list; trackIdx=0; showIdx=-1; renderTracks(); highlight();
      audio.src=tracks[0].url; if(window.SRM) window.SRM.track=tracks[0].title;
      $('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(', ')+'.');
      if(VB.mode!=='youtube') _tryAutoplay();
      return;
    }
  }catch(e){}
  await loadShow(DEFAULT_SHOW);   // fallback: archive slow/down → cue the default night, still try to start
  if(VB.mode!=='youtube') _tryAutoplay();   // if archive is down, loadShow already switched to the YouTube fallback (owns its own start)
}
bootMusic();

// reveal the DEAD back-link only when this room is served as part of the exhibition
const _p=location.pathname.replace(/\/(index\.html)?$/,'');
if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
  const bl=$('backlink'); if(bl) bl.classList.add('on');
}
})();
</script>

<!-- 3D orb (Three.js) floating over the boat, pulsing to the LIVE Dead stream. Classic build so it runs from file://; skips if offline. -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script>
(function(){
  if(!window.THREE) return;
  // Steve: "onload no orb" + "allow user to turn on the orb" + "allow orb to move around the
  // room". The orb is OFF by default (window.__orbOn=false, #stage lacks .orb-on so #orb is
  // display:none) and the loop skips rendering; the 🔮 Orb toggle turns it on, and the orb can
  // be dragged around the room.
  if(window.__orbOn===undefined) window.__orbOn=false;
  const canvas=document.getElementById('orb'); if(!canvas) return;
  let W=innerWidth,H=innerHeight;
  const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});
  renderer.setPixelRatio(Math.min(2,devicePixelRatio||1)); renderer.setSize(W,H,false);
  const scene=new THREE.Scene();
  const camera=new THREE.PerspectiveCamera(50,W/H,0.1,100); camera.position.z=5;
  const group=new THREE.Group(); group.position.set(0, 3.1, 0); scene.add(group);   // high in the sky, clear of the stage riser (designer #4)
  // restore a dragged-around orb position (Steve: "allow orb to move around the room")
  try{ const op=JSON.parse(localStorage.getItem('deadroom.orbpos.v1')); if(op){ group.position.x=op.x||0; group.position.y=(op.y!=null?op.y:3.1); } }catch(_){}

  // ---- the core orb (its own sub-group so effects can rotate it WITHOUT tilting the world-down beams) ----
  const orb=new THREE.Group(); group.add(orb);
  const wire=new THREE.Mesh(new THREE.IcosahedronGeometry(0.95,2), new THREE.MeshBasicMaterial({wireframe:true,color:0xf2b64a,transparent:true,opacity:0.8}));
  const core=new THREE.Mesh(new THREE.IcosahedronGeometry(0.76,3), new THREE.MeshStandardMaterial({color:0x3a0f16,emissive:0xc9432b,emissiveIntensity:0.55,roughness:1,metalness:0,transparent:true,opacity:0.92}));   // matte, no photoreal specular — matches the flat paper-cutout world (designer #4)
  orb.add(core,wire);

  // ---- 5 UFO saucers, each casting a downward light beam (searchlight cone, additive) ----
  const ufo=new THREE.Group(); ufo.visible=false; group.add(ufo);
  const saucers=[];
  for(let i=0;i<5;i++){
    const u=new THREE.Group();
    const body=new THREE.Mesh(new THREE.SphereGeometry(0.24,20,12), new THREE.MeshStandardMaterial({color:0x0d1220,emissive:0x24406e,emissiveIntensity:0.7,roughness:0.25,metalness:0.85}));
    body.scale.set(1,0.32,1);
    const dome=new THREE.Mesh(new THREE.SphereGeometry(0.13,16,10,0,Math.PI*2,0,Math.PI/2), new THREE.MeshStandardMaterial({color:0xbdf0ff,emissive:0x46c4ff,emissiveIntensity:1.4,roughness:0.15,metalness:0.4,transparent:true,opacity:0.9}));
    dome.position.y=0.055;
    const rim=new THREE.Mesh(new THREE.TorusGeometry(0.215,0.028,8,26), new THREE.MeshBasicMaterial({color:0x9fe8ff,transparent:true,opacity:0.9}));
    rim.rotation.x=Math.PI/2;
    // searchlight: cone apex at the saucer (top), wide base sweeping DOWN to the boat
    const beam=new THREE.Mesh(new THREE.ConeGeometry(0.42,1.7,22,1,true), new THREE.MeshBasicMaterial({color:0x8fe0ff,transparent:true,opacity:0.14,side:THREE.DoubleSide,depthWrite:false,blending:THREE.AdditiveBlending}));
    beam.position.y=-0.9;                    // hangs below the saucer
    u.add(beam,body,rim,dome);
    ufo.add(u); saucers.push({g:u,beam,dome,rim,ang:i/5*Math.PI*2,spin:0.4+i*0.12});
  }

  // ---- celestial patterns: a morphing point-cloud + constellation lines ----
  const CN=224;
  const cur=new Float32Array(CN*3), targ=new Float32Array(CN*3);
  const pgeo=new THREE.BufferGeometry(); pgeo.setAttribute('position',new THREE.BufferAttribute(cur,3));
  const stars=new THREE.Points(pgeo,new THREE.PointsMaterial({color:0xfff1cf,size:0.055,transparent:true,opacity:0.95,depthWrite:false,blending:THREE.AdditiveBlending}));
  const MAXSEG=110, lpos=new Float32Array(MAXSEG*2*3), lpair=[];
  const lgeo=new THREE.BufferGeometry(); lgeo.setAttribute('position',new THREE.BufferAttribute(lpos,3));
  const lines=new THREE.LineSegments(lgeo,new THREE.LineBasicMaterial({color:0x9fd0ff,transparent:true,opacity:0.28,depthWrite:false,blending:THREE.AdditiveBlending}));
  const celest=new THREE.Group(); celest.visible=false; celest.add(stars,lines); group.add(celest);
  // pattern builders write into targ[] and rebuild the constellation line pairs
  const PATTERNS=['galaxy','orbits','shell','starburst'];
  function buildPattern(name){
    for(let i=0;i<CN;i++){
      let x=0,y=0,z=0; const f=i/CN;
      if(name==='galaxy'){ const arm=i%3, a=f*7.5+arm*2.094, r=0.18+f*1.35; x=Math.cos(a)*r; y=Math.sin(a)*r*0.55; z=Math.sin(i*0.7)*0.14; }
      else if(name==='orbits'){ const ring=i%5, a=(i/5)*0.9+ring*1.3, r=0.5+ring*0.22, tilt=ring*0.5; x=Math.cos(a)*r; y=Math.sin(a)*r*Math.cos(tilt); z=Math.sin(a)*r*Math.sin(tilt); }
      else if(name==='shell'){ const gr=(1+Math.sqrt(5))/2, th=2*Math.PI*i/gr, ph=Math.acos(1-2*(i+0.5)/CN), r=1.25; x=Math.sin(ph)*Math.cos(th)*r; y=Math.cos(ph)*r; z=Math.sin(ph)*Math.sin(th)*r; }
      else { const pt=Math.floor(f*10), sub=(f*10)%1, base=(pt%2?0.45:1.3), a=pt*Math.PI/5; const r=base*(1-sub*0.15); x=Math.cos(a)*r; y=Math.sin(a)*r; z=(Math.random()-0.5)*0.18; } // starburst
      targ[i*3]=x; targ[i*3+1]=y; targ[i*3+2]=z;
    }
    // nearest-neighbour constellation edges (from the target shape)
    lpair.length=0;
    for(let i=0;i<CN && lpair.length<MAXSEG;i++){
      let bj=-1,bd=1e9;
      for(let j=i+1;j<CN;j++){ const dx=targ[i*3]-targ[j*3],dy=targ[i*3+1]-targ[j*3+1],dz=targ[i*3+2]-targ[j*3+2],d=dx*dx+dy*dy+dz*dz; if(d<bd){bd=d;bj=j;} }
      if(bj>=0 && bd<0.16) lpair.push([i,bj]);
    }
  }
  let patIdx=0; buildPattern(PATTERNS[0]);
  for(let i=0;i<CN*3;i++) cur[i]=targ[i]*0.2;   // start collapsed toward the core
  function cyclePattern(){ patIdx=(patIdx+1)%PATTERNS.length; buildPattern(PATTERNS[patIdx]); }

  const l1=new THREE.PointLight(0xff8a4a,1.3,60); l1.position.set(4,3,5); scene.add(l1);
  const l2=new THREE.PointLight(0xf2b64a,1.0,60); l2.position.set(-4,-1,4); scene.add(l2);
  const l3=new THREE.PointLight(0x7fd0ff,0.0,60); l3.position.set(0,3,4); scene.add(l3);   // cool light, rises in UFO/celestial modes
  scene.add(new THREE.AmbientLight(0x402028,0.9));
  const col=new THREE.Color();
  addEventListener('resize',()=>{ W=innerWidth;H=innerHeight; camera.aspect=W/H; camera.updateProjectionMatrix(); renderer.setSize(W,H,false); });

  // ---- effect state machine: 'none' (orb), 'ufo', 'celestial', 'bloom' ----
  let fx='none', fxT=0, fxDur=7, w=0, seenChange=(window.SRM&&window.SRM.change)||0;
  let ai=false, quietT=0;
  function enterFx(name,dur){ fx=name; fxT=0; fxDur=dur||7; if(name==='celestial') cyclePattern(); }
  // AI VJ picks the next effect from the live audio; when off, a noticeable change still splits the orb
  function director(kind,level,vocal,bpm){
    if(ai){
      if(level>0.55 || bpm>132) enterFx('ufo',6);
      else if(vocal<0.24 || kind==='vocal-out') enterFx('celestial',9);
      else enterFx(Math.random()<0.55?'ufo':'bloom', 5);
    } else {
      if(kind==='vocal-out') enterFx('celestial',8);   // instrumental stretch -> stars
      else enterFx('ufo',7);                            // any other noticeable change -> 5 UFOs
    }
  }

  // DRAG the orb to move it around the room; a click (no drag) hands it to the AI VJ.
  function orbScreen(){ const v=group.position.clone().project(camera); return {x:(v.x*0.5+0.5)*W, y:(-v.y*0.5+0.5)*H, r:Math.max(60,Math.min(W,H)*0.16)}; }
  addEventListener('pointerdown', e=>{
    if(!window.__orbOn) return;                                  // orb off → ignore
    const o=orbScreen();
    if(Math.hypot(e.clientX-o.x, e.clientY-o.y) >= o.r) return;  // not on the orb
    e.stopPropagation();
    const sx=e.clientX, sy=e.clientY, px=group.position.x, py=group.position.y, k=(2*5*Math.tan(50*Math.PI/360))/H; // world-units per screen px
    let moved=false;
    const mv=ev=>{
      if(Math.hypot(ev.clientX-sx, ev.clientY-sy)>4) moved=true;
      group.position.x=Math.max(-3.4, Math.min(3.4, px+(ev.clientX-sx)*k));
      group.position.y=Math.max(-1.2, Math.min(3.4, py-(ev.clientY-sy)*k));
    };
    const up=()=>{
      removeEventListener('pointermove',mv,true); removeEventListener('pointerup',up,true);
      if(!moved){                                               // a click → toggle AI VJ
        ai=!ai;
        if(ai){ enterFx('celestial',9); if(window.SRM) window.SRM.aiVJ=true; }
        else { fx='none'; if(window.SRM) window.SRM.aiVJ=false; }
        const lv=document.getElementById('live'); if(lv) lv.textContent = ai ? 'The orb is taken over by the AI VJ — it choreographs UFOs, celestial patterns and blooms to the music.' : 'AI VJ released. The orb responds to song changes on its own.';
      } else {                                                  // a drag → remember where the orb was moved
        try{ localStorage.setItem('deadroom.orbpos.v1', JSON.stringify({x:group.position.x,y:group.position.y})); }catch(_){}
      }
    };
    addEventListener('pointermove',mv,true); addEventListener('pointerup',up,true);
  }, true);

  const easeOut=x=>1-Math.pow(1-x,3);
  let last=performance.now();
  let _orbStatic=false;
  function loop(now){ requestAnimationFrame(loop);
    if(!window.__orbOn){ return; }   // orb off (default) → skip all render work
    const dt=Math.min(0.05,((now||performance.now())-last)/1000); last=now||performance.now();
    const s=window.SRM||{}, t=(now||performance.now())/1000;
    // Honor reduced motion (WCAG 2.3.3): draw one calm static frame, then stop spinning + re-rendering.
    if(s.reduced){
      if(!_orbStatic){ orb.visible=true; ufo.visible=false; celest.visible=false;
        orb.rotation.set(Math.sin(t*0.3)*0.18,0,0); orb.scale.setScalar(1);
        core.material.emissiveIntensity=0.7; l3.intensity=0; renderer.render(scene,camera); _orbStatic=true; }
      return;
    }
    _orbStatic=false;
    const pulse=s.pulse||0, level=(s.react?(s.level||0):0), vocal=(s.react?(s.vocal||0):0), bpm=s.bpm||0;
    const idle=0.5+0.5*Math.sin(t*1.2);
    const energy=s.react ? (level*0.6+pulse*0.7) : idle*0.12;

    // fire effects on a NOTICEABLE song change; the AI VJ also self-triggers on quiet stretches
    const chg=(s.change||0);
    if(chg>seenChange){ seenChange=chg; director(s.changeKind||'', level, vocal, bpm); }
    if(s.react && level<0.16 && vocal<0.18){ quietT+=dt; if(quietT>9 && fx==='none'){ enterFx('celestial', ai?10:7); quietT=0; } } else quietT=0;

    // blend weight w: ramp up while the effect holds, ramp back down, then release to the plain orb
    const active = fx!=='none' && fxT<fxDur;
    if(fx!=='none'){ fxT+=dt; if(fxT>=fxDur+0.7 && w<0.02){ fx='none'; } }
    w += ((active?1:0)-w)*Math.min(1,dt*4.5);
    const ew=easeOut(Math.max(0,Math.min(1,w)));

    // ---- the plain orb (always present; shrinks away as an effect takes hold) ----
    orb.visible = w<0.995;
    orb.rotation.y+=0.008+energy*0.02; orb.rotation.x=Math.sin(t*0.3)*0.18;
    const baseScale=(1+energy*0.35)*(fx==='bloom'? (1+ew*(0.8+pulse*1.4)) : (1-ew));
    orb.scale.setScalar(Math.max(0.0001,baseScale));
    core.material.emissiveIntensity=0.5+energy*1.6 + (fx==='bloom'?ew*2.5:0);
    const hue=(28 + Math.sin(t*0.2)*16 + level*40)%360;
    wire.material.color.setHSL(((hue+18)%360)/360,0.85,0.6);
    col.setHSL(hue/360,0.85,0.5); core.material.emissive.copy(col);
    l3.intensity = ew*1.4;

    // ---- UFO formation ----
    ufo.visible = (fx==='ufo') && w>0.002;
    if(ufo.visible){
      const spread=0.35+ew*0.95, hover=1.6;   // saucers hover above the boat then beam down
      for(let i=0;i<saucers.length;i++){ const S5=saucers[i];
        const a=S5.ang + t*0.25;
        S5.g.position.set(Math.cos(a)*spread, (i-2)*0.06 + Math.sin(t*1.3+i)*0.08, Math.sin(a)*spread*0.65);
        S5.g.rotation.y += (S5.spin+pulse*2.0)*dt*2.2;
        S5.g.scale.setScalar(ew);
        S5.beam.material.opacity = (0.10+pulse*0.22+level*0.14)*ew;   // beams throb on the beat
        S5.beam.scale.y = 1+pulse*0.5 + Math.sin(t*3+i)*0.08;
        const bh=(200+ i*26 + (bpm||0))%360; S5.dome.material.emissive.setHSL(bh/360,0.9,0.6);
      }
    }

    // ---- celestial point-cloud ----
    celest.visible = (fx==='celestial') && w>0.002;
    if(celest.visible){
      for(let i=0;i<CN*3;i++) cur[i]+=(targ[i]-cur[i])*Math.min(1,dt*2.6);
      pgeo.attributes.position.needsUpdate=true;
      for(let k=0;k<lpair.length;k++){ const a=lpair[k][0]*3,b=lpair[k][1]*3,o=k*6;
        lpos[o]=cur[a];lpos[o+1]=cur[a+1];lpos[o+2]=cur[a+2]; lpos[o+3]=cur[b];lpos[o+4]=cur[b+1];lpos[o+5]=cur[b+2]; }
      lgeo.setDrawRange(0,lpair.length*2); lgeo.attributes.position.needsUpdate=true;
      celest.rotation.y += (0.12+energy*0.4)*dt; celest.rotation.x=Math.sin(t*0.2)*0.25;
      celest.scale.setScalar(ew*(1+pulse*0.12));
      stars.material.opacity=0.7+0.3*Math.sin(t*4)+pulse*0.3;
      stars.material.color.setHSL(((t*10+level*60)%360)/360,0.5,0.85);
      // re-morph to the next celestial pattern on a strong beat, a few seconds in
      if(fxT>3 && pulse>0.85 && Math.random()<0.04) cyclePattern();
    }

    renderer.render(scene,camera);
  }
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>