Add Sarah's Monkee Madness static page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
606293585c
commit
dcf8488ec2
@@ -0,0 +1,644 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Sarah's Monkee Madness</title>
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Shrikhand&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
/* ---- tokens: 1967 psychedelic pop ----------------------------------- */
|
||||
:root{
|
||||
--night:#1a0f33; /* deep psych indigo base */
|
||||
--night-2:#271a47; /* raised surfaces */
|
||||
--cream:#f7ecd2; /* poster cream */
|
||||
--cream-dim:#c8b48f; /* muted cream */
|
||||
--pink:#ff2d6b; /* hot pop pink (primary) */
|
||||
--tang:#ff7a1a; /* tangerine */
|
||||
--turq:#1ec8b4; /* turquoise */
|
||||
--lemon:#ffd23f; /* lemon (focus / highlight) */
|
||||
--line:#46355f; /* hairline on night */
|
||||
--jam:#ff5a3c;
|
||||
--disp:'Shrikhand',serif;
|
||||
--mono:'Space Mono',ui-monospace,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{height:100%}
|
||||
body{margin:0;min-height:100%;background:var(--night);color:var(--cream);
|
||||
font-family:var(--mono);-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
button{font-family:inherit;cursor:pointer;color:inherit}
|
||||
:focus-visible{outline:3px solid var(--lemon);outline-offset:2px}
|
||||
|
||||
.screen{display:none}
|
||||
.screen.on{display:flex;flex-direction:column}
|
||||
#card.on{min-height:100vh;min-height:100dvh}
|
||||
#play.on{position:fixed;inset:0;z-index:40}
|
||||
|
||||
/* ===== TITLE CARD ==================================================== */
|
||||
#card{overflow-y:visible;position:relative}
|
||||
/* op-art ray burst + color wash behind everything */
|
||||
#card::before{
|
||||
content:"";position:fixed;inset:-20%;z-index:0;
|
||||
background:
|
||||
conic-gradient(from 0deg at 50% 42%,
|
||||
rgba(255,45,107,.16) 0 12deg, transparent 12deg 24deg,
|
||||
rgba(30,200,180,.13) 24deg 36deg, transparent 36deg 48deg,
|
||||
rgba(255,122,26,.13) 48deg 60deg, transparent 60deg 72deg);
|
||||
animation:spin 90s linear infinite;
|
||||
transform-origin:50% 42%;
|
||||
pointer-events:none;
|
||||
}
|
||||
#card::after{
|
||||
content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
|
||||
background:
|
||||
radial-gradient(60% 45% at 50% 40%, rgba(255,210,63,.10), transparent 70%),
|
||||
radial-gradient(90% 70% at 100% 0, rgba(255,45,107,.16), transparent 55%),
|
||||
radial-gradient(90% 70% at 0 100%, rgba(30,200,180,.14), transparent 55%);
|
||||
}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.card-wrap{position:relative;z-index:1;max-width:760px;margin:0 auto;
|
||||
padding:44px 24px 72px;width:100%;text-align:center}
|
||||
|
||||
/* guitar-pick emblem (original, not the band's mark) */
|
||||
.emblem{width:118px;height:132px;margin:0 auto 6px;display:block}
|
||||
.hero-img{width:132px;height:132px;border-radius:50%;object-fit:cover;margin:0 auto 6px;
|
||||
display:none;border:4px solid var(--cream);box-shadow:0 0 0 6px var(--pink)}
|
||||
|
||||
.eyebrow{font-size:12px;letter-spacing:.34em;text-transform:uppercase;
|
||||
color:var(--lemon);font-weight:700;margin:14px 0 18px}
|
||||
|
||||
h1{margin:0;font-family:var(--disp);font-weight:400;line-height:.92;
|
||||
text-transform:uppercase;letter-spacing:.01em}
|
||||
h1 .l1{display:block;font-size:clamp(30px,8vw,60px);color:var(--turq);
|
||||
transform:rotate(-3deg)}
|
||||
h1 .l2{display:block;font-size:clamp(52px,15vw,128px);color:var(--pink);
|
||||
text-shadow:4px 4px 0 var(--night), 8px 8px 0 var(--tang);margin:.02em 0}
|
||||
h1 .l3{display:block;font-size:clamp(30px,8vw,60px);color:var(--lemon);
|
||||
transform:rotate(2deg)}
|
||||
|
||||
.tagline{margin:30px auto 0;max-width:50ch;font-size:15px;line-height:1.55;
|
||||
color:var(--cream);text-align:center}
|
||||
.tagline b{color:var(--lemon);font-weight:400}
|
||||
|
||||
.program{margin:40px 0 0;text-align:left}
|
||||
.program-h{display:flex;align-items:baseline;justify-content:center;gap:10px;margin:0 0 16px;
|
||||
font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--cream-dim)}
|
||||
.program-h .shuffle{color:var(--turq);letter-spacing:0;font-size:14px}
|
||||
.bill{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
||||
.slot{display:flex;align-items:center;gap:14px;background:var(--night-2);
|
||||
border:2px solid var(--line);border-radius:40px;padding:12px 20px}
|
||||
.slot.feature{border-color:var(--pink);background:rgba(255,45,107,.10)}
|
||||
.slot .dot{width:14px;height:14px;border-radius:50%;flex:0 0 auto}
|
||||
.slot.feature .dot{background:var(--pink)}
|
||||
.slot:nth-child(2) .dot{background:var(--turq)}
|
||||
.slot:nth-child(3) .dot{background:var(--tang)}
|
||||
.slot:nth-child(4) .dot{background:var(--lemon)}
|
||||
.slot .t{flex:1;min-width:0;font-family:var(--disp);font-size:19px;color:var(--cream);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.01em}
|
||||
.slot .red{font-family:var(--mono);font-size:15px;color:var(--cream-dim);letter-spacing:.1em}
|
||||
.slot .badge{font-size:10px;letter-spacing:.16em;text-transform:uppercase;font-weight:700;
|
||||
color:var(--pink);white-space:nowrap}
|
||||
.slot .badge.dim{color:var(--cream-dim)}
|
||||
|
||||
.dedication{margin:30px 0 0;font-size:13px;color:var(--cream-dim);font-style:italic}
|
||||
|
||||
.go{margin-top:38px;display:flex;align-items:center;justify-content:center;gap:20px;flex-wrap:wrap}
|
||||
#roll{font-family:var(--disp);font-weight:400;font-size:clamp(26px,6vw,38px);
|
||||
text-transform:uppercase;letter-spacing:.02em;background:var(--pink);color:var(--cream);
|
||||
border:none;border-radius:50px;padding:18px 48px;line-height:1;
|
||||
box-shadow:5px 5px 0 var(--night),9px 9px 0 var(--turq)}
|
||||
#roll:hover{background:var(--tang);box-shadow:5px 5px 0 var(--night),9px 9px 0 var(--lemon)}
|
||||
#roll:active{transform:translate(4px,4px);box-shadow:1px 1px 0 var(--night),3px 3px 0 var(--turq)}
|
||||
.go-note{font-size:12px;color:var(--cream-dim);max-width:24ch;line-height:1.45;text-align:left}
|
||||
|
||||
/* ===== PROJECTION =================================================== */
|
||||
#play{background:#000}
|
||||
#stage{position:relative;flex:1;background:#000;overflow:hidden}
|
||||
#player,#player iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
|
||||
#player::after{content:"";position:absolute;inset:0}
|
||||
|
||||
.topbar{position:absolute;top:0;left:0;right:0;z-index:5;display:flex;align-items:center;
|
||||
gap:8px;padding:14px;background:linear-gradient(var(--night),rgba(26,15,51,0));pointer-events:none}
|
||||
.topbar > *{pointer-events:auto}
|
||||
.icon{background:rgba(26,15,51,.74);border:2px solid var(--line);border-radius:50%;
|
||||
width:46px;height:46px;font-size:18px;line-height:1;display:grid;place-items:center;
|
||||
backdrop-filter:blur(4px)}
|
||||
.icon:hover{border-color:var(--pink)}
|
||||
.icon[aria-pressed="true"]{background:var(--pink);color:var(--cream);border-color:var(--pink)}
|
||||
.exit{background:rgba(26,15,51,.74);border:2px solid var(--line);border-radius:30px;height:46px;
|
||||
padding:0 18px;font-family:var(--disp);font-size:14px;display:inline-flex;align-items:center;
|
||||
gap:8px;backdrop-filter:blur(4px)}
|
||||
.exit:hover{border-color:var(--pink)}
|
||||
.spacer{flex:1}
|
||||
|
||||
#slate{position:absolute;left:14px;top:72px;z-index:6;max-width:min(420px,80vw);
|
||||
background:var(--cream);color:var(--night);border-radius:14px;
|
||||
border:3px solid var(--pink);padding:14px 18px;display:none;
|
||||
box-shadow:5px 5px 0 rgba(0,0,0,.35)}
|
||||
#slate.on{display:block}
|
||||
#slate .lbl{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--pink);font-weight:700}
|
||||
#slate .film{font-family:var(--disp);font-size:23px;line-height:1.05;margin:6px 0 8px}
|
||||
#slate .tc{font-size:13px;color:#6b5a44}
|
||||
#slate .tc b{color:var(--night)}
|
||||
|
||||
.filmbar{position:absolute;left:0;right:0;bottom:0;z-index:5;
|
||||
background:linear-gradient(rgba(26,15,51,0),var(--night) 65%);padding:30px 16px 14px}
|
||||
.counter{display:flex;justify-content:space-between;align-items:flex-end;
|
||||
font-size:12px;color:var(--cream-dim);margin-bottom:8px}
|
||||
.counter .clip{font-family:var(--disp);font-size:15px;color:var(--lemon);letter-spacing:.02em}
|
||||
.counter .tc{font-variant-numeric:tabular-nums}
|
||||
.strip{position:relative;height:18px;background:var(--night-2);border:2px solid var(--line);
|
||||
border-radius:10px;overflow:hidden}
|
||||
.strip .fill{position:absolute;inset:0;width:0;
|
||||
background:linear-gradient(90deg,var(--pink),var(--tang),var(--lemon));transition:width .25s linear}
|
||||
.strip .perfs{position:absolute;inset:0;
|
||||
background:repeating-linear-gradient(90deg,transparent 0 20px,rgba(26,15,51,.7) 20px 26px);
|
||||
pointer-events:none}
|
||||
|
||||
#leader{position:absolute;inset:0;z-index:7;display:none;place-items:center;
|
||||
background:radial-gradient(circle at 50% 50%, var(--night-2), var(--night))}
|
||||
#leader.on{display:grid}
|
||||
#leader .ring{width:min(46vmin,260px);aspect-ratio:1;border-radius:50%;
|
||||
border:4px solid var(--cream);position:relative;display:grid;place-items:center;overflow:hidden}
|
||||
#leader .ring::before,#leader .ring::after{content:"";position:absolute;background:var(--cream)}
|
||||
#leader .ring::before{left:50%;top:0;bottom:0;width:2px;transform:translateX(-50%)}
|
||||
#leader .ring::after{top:50%;left:0;right:0;height:2px;transform:translateY(-50%)}
|
||||
#leader .sweep{position:absolute;inset:0;
|
||||
background:conic-gradient(var(--pink) 0,var(--pink) var(--sweep,0deg),transparent var(--sweep,0deg));
|
||||
opacity:.6}
|
||||
#leader .n{font-family:var(--disp);font-size:clamp(70px,22vmin,150px);z-index:1;color:var(--lemon)}
|
||||
|
||||
#fin{position:absolute;inset:0;z-index:8;display:none;place-items:center;
|
||||
background:radial-gradient(circle at 50% 35%, var(--night-2), var(--night));text-align:center;padding:24px}
|
||||
#fin.on{display:grid}
|
||||
#fin .inner{max-width:440px}
|
||||
#fin h2{font-family:var(--disp);font-size:clamp(36px,9vw,62px);text-transform:uppercase;
|
||||
margin:0 0 12px;line-height:.9;color:var(--pink);
|
||||
text-shadow:3px 3px 0 var(--night),6px 6px 0 var(--turq)}
|
||||
#fin p{color:var(--cream-dim);font-size:14px;line-height:1.55;margin:0 0 24px}
|
||||
#fin .acts{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
#fin button{font-family:var(--disp);font-size:16px;padding:14px 24px;border:none;border-radius:40px}
|
||||
#fin .again{background:var(--pink);color:var(--cream);box-shadow:4px 4px 0 var(--night)}
|
||||
#fin .again:hover{background:var(--tang)}
|
||||
#fin .back{background:var(--night-2);color:var(--cream);border:2px solid var(--line)}
|
||||
#fin .back:hover{border-color:var(--pink)}
|
||||
|
||||
.toast{position:absolute;left:50%;bottom:96px;transform:translateX(-50%) translateY(10px);
|
||||
z-index:9;background:var(--jam);color:var(--night);font-weight:700;font-size:13px;padding:9px 16px;
|
||||
border-radius:20px;opacity:0;transition:opacity .3s,transform .3s;pointer-events:none;white-space:nowrap}
|
||||
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
.keys{position:absolute;right:14px;bottom:14px;z-index:5;font-size:11px;color:var(--cream-dim);
|
||||
text-align:right;line-height:1.7;pointer-events:none}
|
||||
.keys kbd{background:var(--night-2);border:1px solid var(--line);border-radius:4px;padding:1px 6px;
|
||||
font-family:var(--mono);color:var(--cream)}
|
||||
|
||||
@media (max-width:560px){
|
||||
.keys{display:none}
|
||||
.go{flex-direction:column;align-items:stretch}
|
||||
#roll{width:100%}
|
||||
.go-note{text-align:center;max-width:none}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
#card::before{animation:none}
|
||||
.strip .fill{transition:none}
|
||||
#leader .sweep{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===================== TITLE CARD ===================== -->
|
||||
<section id="card" class="screen on" aria-label="Title card">
|
||||
<div class="card-wrap">
|
||||
<!-- original guitar-pick emblem; swap for HERO_IMAGE if set -->
|
||||
<svg class="emblem" id="emblem" viewBox="0 0 118 132" aria-hidden="true">
|
||||
<path d="M59 4 C92 4 114 26 114 56 C114 88 84 128 59 128 C34 128 4 88 4 56 C4 26 26 4 59 4 Z"
|
||||
fill="#ff2d6b" stroke="#f7ecd2" stroke-width="5"/>
|
||||
<g stroke="#f7ecd2" stroke-width="4" fill="none" opacity=".9">
|
||||
<circle cx="59" cy="60" r="30"/>
|
||||
<circle cx="59" cy="60" r="18"/>
|
||||
</g>
|
||||
<circle cx="59" cy="60" r="7" fill="#ffd23f"/>
|
||||
<g stroke="#1ec8b4" stroke-width="5" stroke-linecap="round">
|
||||
<line x1="59" y1="60" x2="59" y2="18"/>
|
||||
<line x1="59" y1="60" x2="92" y2="84"/>
|
||||
<line x1="59" y1="60" x2="26" y2="84"/>
|
||||
</g>
|
||||
</svg>
|
||||
<img class="hero-img" id="hero" alt="" />
|
||||
|
||||
<p class="eyebrow">A cut-up midnight movie, party of one</p>
|
||||
<h1>
|
||||
<span class="l1">Sarah's</span>
|
||||
<span class="l2">Monkee</span>
|
||||
<span class="l3">Madness</span>
|
||||
</h1>
|
||||
|
||||
<p class="tagline">
|
||||
One Monkees movie and three accomplices, chopped into clips of
|
||||
<b>random length</b> and dealt out in <b>random order</b>. Head turns up about
|
||||
three times as often as the rest, since it is, after all, the main feature.
|
||||
You won't know what you're watching until you ask.
|
||||
</p>
|
||||
|
||||
<div class="program">
|
||||
<p class="program-h"><span>Tonight's program</span><span class="shuffle">⇆ in no particular order</span></p>
|
||||
<ul class="bill">
|
||||
<li class="slot feature">
|
||||
<span class="dot"></span>
|
||||
<span class="t">Head (1968)</span>
|
||||
<span class="badge">main feature ×3</span>
|
||||
</li>
|
||||
<li class="slot">
|
||||
<span class="dot"></span>
|
||||
<span class="t"><span class="red">████████</span></span>
|
||||
<span class="badge dim">withheld</span>
|
||||
</li>
|
||||
<li class="slot">
|
||||
<span class="dot"></span>
|
||||
<span class="t"><span class="red">██████</span></span>
|
||||
<span class="badge dim">withheld</span>
|
||||
</li>
|
||||
<li class="slot">
|
||||
<span class="dot"></span>
|
||||
<span class="t"><span class="red">█████████</span></span>
|
||||
<span class="badge dim">withheld</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="dedication">For Sarah, who pitched this as a joke and accidentally wrote the spec.</p>
|
||||
|
||||
<div class="go">
|
||||
<button id="roll" type="button">Roll it</button>
|
||||
<p class="go-note">Sound starts on this click, so the browser lets it play.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== PROJECTION ===================== -->
|
||||
<section id="play" class="screen" aria-label="Projection">
|
||||
<div id="stage">
|
||||
<div id="player"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<button id="exit" class="exit" type="button">◀ Title</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="reveal" class="icon" type="button" aria-pressed="false" aria-label="Reveal what is playing">?</button>
|
||||
<button id="skip" class="icon" type="button" aria-label="Skip to next clip">⏭</button>
|
||||
<button id="mute" class="icon" type="button" aria-pressed="false" aria-label="Mute or unmute">🔊</button>
|
||||
</div>
|
||||
|
||||
<div id="slate" role="status" aria-live="polite">
|
||||
<div class="lbl">Now splicing</div>
|
||||
<div class="film" id="slate-film">—</div>
|
||||
<div class="tc">clip in at <b id="slate-tc">0:00</b> · reel <span id="slate-idx">—</span></div>
|
||||
</div>
|
||||
|
||||
<div id="leader" aria-hidden="true">
|
||||
<div class="ring"><div class="sweep"></div><div class="n" id="leader-n">3</div></div>
|
||||
</div>
|
||||
|
||||
<div class="filmbar">
|
||||
<div class="counter">
|
||||
<span class="clip" id="clip-label">splice 001</span>
|
||||
<span class="tc"><span id="elapsed">0:00</span> / <span id="cliplen">1:30</span></span>
|
||||
</div>
|
||||
<div class="strip"><div class="fill" id="fill"></div><div class="perfs"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="keys" aria-hidden="true">
|
||||
<div><kbd>N</kbd> next splice</div>
|
||||
<div><kbd>?</kbd> reveal · <kbd>M</kbd> mute</div>
|
||||
</div>
|
||||
|
||||
<div id="fin">
|
||||
<div class="inner">
|
||||
<h2>That's the program</h2>
|
||||
<p>Every clip from all four reels has now run. The whole thing, technically, has
|
||||
been watched. Just not in any order a person could reconstruct.</p>
|
||||
<div class="acts">
|
||||
<button class="again" id="again" type="button">Reshuffle & run it again</button>
|
||||
<button class="back" id="fin-back" type="button">Back to title</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
/* =====================================================================
|
||||
Sarah's Monkee Madness
|
||||
Four-reel cut-up player. Each clip is a random length between CLIP_MIN
|
||||
and CLIP_MAX seconds, pulled from a randomly chosen reel that still has
|
||||
runtime. Reels are weighted, so Head (the main feature) comes up about
|
||||
three times as often as its three companions. Playback runs entirely
|
||||
through the YouTube IFrame API, one clip at a time. Nothing downloaded.
|
||||
===================================================================== */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
OPTIONAL: put any image URL here (your own choice of art / photo) and
|
||||
it replaces the guitar-pick emblem on the title card. Leave it blank
|
||||
to keep the original emblem.
|
||||
--------------------------------------------------------------------- */
|
||||
const HERO_IMAGE = ''; // e.g. 'https://your-host/whatever.jpg'
|
||||
|
||||
const CLIP_MIN = 60; // shortest splice, seconds
|
||||
const CLIP_MAX = 120; // longest splice, seconds
|
||||
const REEL_DEFS = [
|
||||
{ id:'OfYob-y2m7o', title:'Head (1968)', weight:3, main:true },
|
||||
{ id:'O4o584brzuc', title:null, weight:1, main:false },
|
||||
{ id:'gu8blmuBFYY', title:null, weight:1, main:false },
|
||||
{ id:'bQD1lM0WEPM', title:null, weight:1, main:false },
|
||||
];
|
||||
|
||||
const state = {
|
||||
reels: [], clipLen: 90, player: null, active: null, pendingFirst: null,
|
||||
clipIndex: 0, tickTimer: null, windowStart: 0,
|
||||
muted: false, revealed: false, running: false,
|
||||
};
|
||||
|
||||
function freshReels(){
|
||||
return REEL_DEFS.map(d => ({
|
||||
id:d.id, title:d.title, weight:d.weight, main:d.main,
|
||||
duration:null, done:false, jammed:false,
|
||||
queue:[], qi:0, built:false, probed:false,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---- helpers -------------------------------------------------------- */
|
||||
function fmt(sec){
|
||||
sec = Math.max(0, Math.floor(sec||0));
|
||||
const m = Math.floor(sec/60), s = sec%60;
|
||||
return m + ':' + String(s).padStart(2,'0');
|
||||
}
|
||||
function randInt(lo,hi){ return Math.floor(Math.random()*(hi-lo+1))+lo; }
|
||||
function shuffle(a){
|
||||
for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; }
|
||||
return a;
|
||||
}
|
||||
/* Once a reel's duration is known, slice the whole film into back-to-back
|
||||
chunks of random length, then shuffle them. Each chunk plays exactly once,
|
||||
but in scrambled order. */
|
||||
function buildQueue(reel){
|
||||
const d = reel.duration, segs = [];
|
||||
let t = 0;
|
||||
while(t < d - 1){
|
||||
let len = randInt(CLIP_MIN, CLIP_MAX);
|
||||
if(t + len > d) len = Math.max(20, Math.round(d - t)); // final partial chunk
|
||||
segs.push({ start: Math.round(t), len });
|
||||
t += len;
|
||||
}
|
||||
if(reel.probed && segs.length) segs.shift(); // opening already shown as the probe
|
||||
shuffle(segs);
|
||||
reel.queue = segs; reel.qi = 0; reel.built = true;
|
||||
}
|
||||
/* Get the next chunk to play for a reel. Before duration is known we play a
|
||||
short "probe" from the top so the player can report the runtime. */
|
||||
function nextClipFor(reel){
|
||||
if(reel.built){
|
||||
if(reel.qi >= reel.queue.length){ reel.done = true; return null; }
|
||||
const seg = reel.queue[reel.qi++];
|
||||
if(reel.qi >= reel.queue.length) reel.done = true;
|
||||
return seg;
|
||||
}
|
||||
if(reel.probed){ reel.done = true; return null; } // duration never resolved; retire it
|
||||
reel.probed = true;
|
||||
return { start: 0, len: randInt(CLIP_MIN, CLIP_MAX) };
|
||||
}
|
||||
function label(reel){
|
||||
if(reel.title) return reel.title;
|
||||
return reel.main ? 'Head (1968)' : 'Undisclosed reel';
|
||||
}
|
||||
|
||||
/* ---- optional hero image ------------------------------------------- */
|
||||
if(HERO_IMAGE){
|
||||
const img = $('hero'); const em = $('emblem');
|
||||
img.src = HERO_IMAGE; img.style.display = 'block'; em.style.display = 'none';
|
||||
}
|
||||
|
||||
/* ---- YouTube IFrame API -------------------------------------------- */
|
||||
window.onYouTubeIframeAPIReady = function(){ state.apiReady = true; };
|
||||
(function loadAPI(){
|
||||
const t = document.createElement('script');
|
||||
t.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(t);
|
||||
})();
|
||||
|
||||
/* ---- start ---------------------------------------------------------- */
|
||||
$('roll').addEventListener('click', startProgram);
|
||||
$('again').addEventListener('click', () => { $('fin').classList.remove('on'); startProgram(); });
|
||||
|
||||
function startProgram(){
|
||||
state.reels = freshReels();
|
||||
state.clipIndex = 0;
|
||||
state.running = true;
|
||||
|
||||
show('play');
|
||||
$('fin').classList.remove('on');
|
||||
|
||||
const first = pickReel();
|
||||
if(!first){ endProgram(); return; }
|
||||
const seg = nextClipFor(first);
|
||||
if(!seg){ endProgram(); return; }
|
||||
|
||||
if(state.player){
|
||||
loadAndRun(first, seg);
|
||||
} else {
|
||||
state.pendingFirst = { reel: first, seg };
|
||||
const httpOrigin = /^https?:$/.test(location.protocol) ? location.origin : undefined;
|
||||
const vars = { autoplay:1, controls:0, rel:0, modestbranding:1,
|
||||
playsinline:1, fs:0, disablekb:1, start:0 };
|
||||
if(httpOrigin) vars.origin = httpOrigin;
|
||||
state.player = new YT.Player('player', {
|
||||
host: 'https://www.youtube-nocookie.com',
|
||||
videoId: first.id,
|
||||
playerVars: vars,
|
||||
events: {
|
||||
onReady: () => { const p = state.pendingFirst; runWindow(p.reel, p.seg); applyMute(); },
|
||||
onStateChange: onState,
|
||||
onError: onError,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* weighted random pick among reels that still have runtime */
|
||||
function pickReel(){
|
||||
const pool = state.reels.filter(r => !r.done && !r.jammed);
|
||||
if(!pool.length) return null;
|
||||
const total = pool.reduce((s,r) => s + r.weight, 0);
|
||||
let x = Math.random() * total;
|
||||
for(const r of pool){ x -= r.weight; if(x <= 0) return r; }
|
||||
return pool[pool.length-1];
|
||||
}
|
||||
|
||||
function loadAndRun(reel, seg){
|
||||
try{
|
||||
state.player.loadVideoById({ videoId: reel.id, startSeconds: seg.start });
|
||||
applyMute();
|
||||
}catch(e){}
|
||||
runWindow(reel, seg);
|
||||
}
|
||||
|
||||
function runWindow(reel, seg){
|
||||
state.active = reel;
|
||||
state.clipLen = seg.len;
|
||||
state.clipIndex += 1;
|
||||
state.windowStart = Date.now();
|
||||
|
||||
$('clip-label').textContent = 'splice ' + String(state.clipIndex).padStart(3,'0');
|
||||
$('cliplen').textContent = fmt(seg.len);
|
||||
$('elapsed').textContent = '0:00';
|
||||
$('fill').style.width = '0%';
|
||||
$('slate-film').textContent = label(reel);
|
||||
$('slate-tc').textContent = fmt(seg.start);
|
||||
$('slate-idx').textContent = (state.reels.indexOf(reel)+1) + ' / ' + state.reels.length;
|
||||
|
||||
clearInterval(state.tickTimer);
|
||||
state.tickTimer = setInterval(tick, 250);
|
||||
setTimeout(captureDuration, 800);
|
||||
}
|
||||
|
||||
function captureDuration(){
|
||||
if(!state.player || !state.active) return;
|
||||
let d = 0;
|
||||
try{ d = state.player.getDuration(); }catch(e){}
|
||||
if(d && d > 0){
|
||||
state.active.duration = d;
|
||||
if(!state.active.built) buildQueue(state.active); // shuffle this film's chunks
|
||||
maybeTitle();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeTitle(){
|
||||
const reel = state.active;
|
||||
if(reel.title) return;
|
||||
try{
|
||||
const data = state.player.getVideoData && state.player.getVideoData();
|
||||
if(data && data.title){
|
||||
reel.title = data.title;
|
||||
if(state.revealed) $('slate-film').textContent = reel.title;
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function tick(){
|
||||
if(!state.running) return;
|
||||
const elapsed = (Date.now() - state.windowStart) / 1000;
|
||||
$('fill').style.width = Math.min(100, (elapsed/state.clipLen)*100) + '%';
|
||||
$('elapsed').textContent = fmt(elapsed);
|
||||
if(state.active && state.active.duration == null) captureDuration();
|
||||
if(elapsed >= state.clipLen) advance();
|
||||
}
|
||||
|
||||
function advance(){
|
||||
clearInterval(state.tickTimer);
|
||||
let reel, seg;
|
||||
while((reel = pickReel())){
|
||||
seg = nextClipFor(reel);
|
||||
if(seg) break; // got a chunk; otherwise that reel just retired, try another
|
||||
}
|
||||
if(!reel || !seg){ endProgram(); return; }
|
||||
leaderThen(() => loadAndRun(reel, seg));
|
||||
}
|
||||
|
||||
function skip(){ if(state.running) advance(); }
|
||||
|
||||
function leaderThen(cb){
|
||||
if(window.matchMedia('(prefers-reduced-motion:reduce)').matches){ cb(); return; }
|
||||
const leader = $('leader'), nEl = $('leader-n');
|
||||
let n = 3, deg = 0;
|
||||
leader.style.setProperty('--sweep','0deg');
|
||||
nEl.textContent = n;
|
||||
leader.classList.add('on');
|
||||
const sweep = setInterval(() => { deg=(deg+36)%360; leader.style.setProperty('--sweep', deg+'deg'); }, 33);
|
||||
const step = setInterval(() => {
|
||||
n -= 1;
|
||||
if(n <= 0){ clearInterval(step); clearInterval(sweep); leader.classList.remove('on'); cb(); }
|
||||
else nEl.textContent = n;
|
||||
}, 360);
|
||||
}
|
||||
|
||||
/* ---- player events -------------------------------------------------- */
|
||||
function onState(e){
|
||||
if(e.data === YT.PlayerState.ENDED){
|
||||
advance(); // a chunk near the end finished early; the reel keeps its other chunks
|
||||
} else if(e.data === YT.PlayerState.PLAYING){
|
||||
captureDuration();
|
||||
}
|
||||
}
|
||||
function onError(){
|
||||
if(state.active){ state.active.jammed = true; toast('Reel jammed. Skipping it.'); }
|
||||
advance();
|
||||
}
|
||||
|
||||
/* ---- end ------------------------------------------------------------ */
|
||||
function endProgram(){
|
||||
state.running = false;
|
||||
clearInterval(state.tickTimer);
|
||||
try{ state.player && state.player.stopVideo(); }catch(e){}
|
||||
$('fin').classList.add('on');
|
||||
}
|
||||
|
||||
/* ---- chrome --------------------------------------------------------- */
|
||||
$('skip').addEventListener('click', skip);
|
||||
$('exit').addEventListener('click', toTitle);
|
||||
$('fin-back').addEventListener('click', toTitle);
|
||||
$('mute').addEventListener('click', toggleMute);
|
||||
$('reveal').addEventListener('click', toggleReveal);
|
||||
|
||||
function toTitle(){
|
||||
state.running = false;
|
||||
clearInterval(state.tickTimer);
|
||||
try{ state.player && state.player.stopVideo(); }catch(e){}
|
||||
$('slate').classList.remove('on');
|
||||
state.revealed = false; $('reveal').setAttribute('aria-pressed','false');
|
||||
show('card');
|
||||
}
|
||||
function applyMute(){ try{ state.muted ? state.player.mute() : state.player.unMute(); }catch(e){} }
|
||||
function toggleMute(){
|
||||
state.muted = !state.muted; applyMute();
|
||||
const b = $('mute');
|
||||
b.textContent = state.muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
||||
b.setAttribute('aria-pressed', String(state.muted));
|
||||
}
|
||||
function toggleReveal(){
|
||||
state.revealed = !state.revealed;
|
||||
if(state.revealed && state.active) $('slate-film').textContent = label(state.active);
|
||||
$('slate').classList.toggle('on', state.revealed);
|
||||
$('reveal').setAttribute('aria-pressed', String(state.revealed));
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg){
|
||||
const t = $('toast');
|
||||
t.textContent = msg; t.classList.add('on');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.remove('on'), 1800);
|
||||
}
|
||||
|
||||
/* ---- keyboard ------------------------------------------------------- */
|
||||
document.addEventListener('keydown', e => {
|
||||
if(!$('play').classList.contains('on')) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if(k === 'n'){ e.preventDefault(); skip(); }
|
||||
else if(k === 'm'){ toggleMute(); }
|
||||
else if(k === '?' || k === '/'){ toggleReveal(); }
|
||||
});
|
||||
|
||||
/* ---- screen switch -------------------------------------------------- */
|
||||
function show(id){
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('on'));
|
||||
$(id).classList.add('on');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user