shots / shots (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
1775 lines
141 KiB
HTML
1775 lines
141 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src https://api.venice.ai https://inference.yolopreneur.lol; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src blob: data:; font-src 'self'">
|
||
<title>gandora</title>
|
||
<meta name="description" content="Type a seed. Get a station. Every song is made for you as it plays, and every cover is drawn from the song's own settings.">
|
||
<link rel="icon" href="favicon.svg">
|
||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
|
||
<link rel="manifest" href="manifest.json">
|
||
<meta name="theme-color" content="#0b0b0e">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="gandora">
|
||
<style>
|
||
:root{
|
||
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216; --field:#0a0a0d;
|
||
--acc:var(--ink); --onacc:var(--bg); --veil:rgba(11,11,14,.55); --veil2:rgba(11,11,14,.65); --shade:8,8,10;
|
||
--font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif;
|
||
color-scheme:dark;
|
||
}
|
||
:root[data-theme="light"]{
|
||
--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; --field:#ffffff;
|
||
--veil:rgba(244,244,246,.6); --veil2:rgba(244,244,246,.75); --shade:244,244,246; color-scheme:light;
|
||
}
|
||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
|
||
--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; --field:#ffffff;
|
||
--veil:rgba(244,244,246,.6); --veil2:rgba(244,244,246,.75); --shade:244,244,246; color-scheme:light;
|
||
}}
|
||
*{box-sizing:border-box} html,body{height:100%}
|
||
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||
a{color:inherit}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||
input,textarea{font:inherit;color:inherit}
|
||
.hidden{display:none!important}
|
||
|
||
/* ---------- seed screen ---------- */
|
||
#seed{min-height:100dvh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px 16px;position:relative}
|
||
#seed .wordmark{position:absolute;top:calc(26px + env(safe-area-inset-top));left:0;right:0;text-align:center;font:600 15px var(--font);letter-spacing:.18em;color:var(--ink)}
|
||
#seed .wordmark .ast{color:var(--ink);font-weight:700}
|
||
#seed form{width:min(680px,100%);text-align:center}
|
||
#seedIn{width:100%;background:transparent;border:0;border-bottom:1px solid var(--line);padding:14px 4px;text-align:center;font:400 clamp(24px,5vw,38px)/1.2 var(--sans);outline:none;caret-color:var(--acc);transition:border-color .3s}
|
||
#seedIn:focus{border-color:var(--acc)}
|
||
#seedIn::placeholder{color:#3a3a44}
|
||
#seed .hint{margin-top:18px;color:var(--dim);font-size:13px;min-height:1.5em}
|
||
#seedCover{width:120px;height:120px;margin:0 auto 28px;border-radius:14px;overflow:hidden;background:var(--card);opacity:0;transform:scale(.92);transition:opacity .5s,transform .5s}
|
||
#seedCover.on{opacity:1;transform:none}
|
||
#seedCover canvas{width:100%;height:100%;display:block}
|
||
#nowRow{position:absolute;top:44px;left:0;right:0;display:flex;justify-content:center}
|
||
#now{display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:6px 14px 6px 6px;font-size:13px;cursor:pointer;max-width:calc(100% - 32px)}
|
||
#now canvas{width:28px;height:28px;border-radius:50%;display:block}
|
||
#now span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#now b{font:600 11px var(--font);letter-spacing:.14em;color:var(--dim);margin-left:4px}
|
||
#stations{width:min(680px,100%);margin-top:40px}
|
||
#stations h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 12px;text-align:center}
|
||
#stations .row{display:flex;gap:14px;overflow-x:auto;padding:4px 4px 10px;scrollbar-width:none;justify-content:safe center}
|
||
#stations .s{flex:0 0 96px;cursor:pointer;text-align:center}
|
||
#stations .s canvas{width:72px;height:72px;border-radius:50%;display:block;margin:0 auto 6px;background:var(--card)}
|
||
#stations .s.on canvas{outline:2px solid var(--acc);outline-offset:3px}
|
||
#stations .s b{display:block;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#stations .s small{color:var(--dim);font-size:11px}
|
||
#keyRow{position:absolute;bottom:18px;left:0;right:0;text-align:center;font-size:12px;color:var(--dim);display:flex;gap:18px;justify-content:center;flex-wrap:wrap}
|
||
#keyRow button{text-decoration:underline;text-underline-offset:3px}
|
||
#keyRow .nav{font:600 11px var(--font);letter-spacing:.18em;text-decoration:none}
|
||
#keyBox{position:absolute;bottom:44px;left:50%;transform:translateX(-50%);width:min(420px,calc(100% - 32px));background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:13px}
|
||
#keyBox input{width:100%;margin:8px 0;background:var(--field);border:1px solid var(--line);border-radius:8px;padding:9px 10px;font-family:var(--font);font-size:12px;outline:none}
|
||
#keyBox .row{display:flex;gap:8px;justify-content:flex-end}
|
||
#keyBox .row button{padding:6px 12px;border-radius:8px;border:1px solid var(--line)}
|
||
#keyBox .row button.go{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
|
||
#keyBox p{margin:0 0 4px;color:var(--dim)}
|
||
#keyBox label{display:block;margin-top:6px;color:var(--dim)}
|
||
|
||
/* ---------- player ---------- */
|
||
#player{min-height:100dvh;display:grid;grid-template-rows:auto 1fr auto;padding:calc(22px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom))}
|
||
#top{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim)}
|
||
#stage .station{font:600 11px var(--font);letter-spacing:.22em;color:var(--dim);max-width:min(78vw,380px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-4px}
|
||
#stage .station:hover{color:var(--acc)}
|
||
#stations .s{position:relative}
|
||
#stations .s .dots{position:absolute;top:-4px;right:2px;width:22px;height:22px;border-radius:50%;background:var(--card);border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:1}
|
||
#scrim{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.25)}
|
||
#stationSheet,#picker,#tapePick{z-index:6}
|
||
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||
#stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
|
||
#upd{position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);z-index:9;padding:9px 16px;border-radius:999px;background:var(--ink);color:var(--bg);font:600 11px var(--font);letter-spacing:.16em;box-shadow:0 10px 30px -10px rgba(0,0,0,.6)}
|
||
#stationSheet .x{position:absolute;top:8px;right:8px;width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
|
||
#stationSheet .x:hover{color:var(--ink);background:var(--bg)}
|
||
#stationSheet label{display:block;color:var(--dim);font-size:12px;margin:8px 0 4px}
|
||
#stationSheet input{width:100%;background:var(--field);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none}
|
||
#stationSheet .row{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||
#stationSheet .sl{display:flex;align-items:center;gap:12px;margin-top:6px}
|
||
#stationSheet .sl label{margin:0;width:84px}
|
||
#stationSheet input[type=range]{flex:1;accent-color:var(--ink);margin:0}
|
||
#stationSheet .sl span{font:11px var(--font);color:var(--dim);width:32px;text-align:right}
|
||
#stationSheet .row button{padding:9px 14px;border-radius:999px;border:1px solid var(--line);font:600 11px var(--font);letter-spacing:.14em}
|
||
#stationSheet .row button.go{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
|
||
#stationSheet .row button.bad{color:hsl(0 60% 70%);border-color:hsl(0 30% 35%)}
|
||
#top button{color:var(--dim);letter-spacing:.08em}
|
||
#stage{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:14px 0}
|
||
/* the flow: station, cover (trail on hover), transport, title, bar, then the DJ line, NEXT, lyrics/keep/share, tags */
|
||
#stationName{order:0}#cover{order:1}#controls{order:2}#meta{order:4}#bar{order:5}#meta2{order:7}
|
||
#stage #controls{margin-top:2px}
|
||
#meta2 #pills{margin:10px 0 8px}
|
||
#cover{width:min(78vw,380px);aspect-ratio:1;position:relative;perspective:1200px}
|
||
#cover .card{position:absolute;inset:0;transform-style:preserve-3d;transition:transform .7s cubic-bezier(.4,.1,.2,1)}
|
||
#cover.flipped .card{transform:rotateY(180deg)}
|
||
#cover .face{position:absolute;inset:0;border-radius:18px;overflow:hidden;background:var(--card);box-shadow:0 30px 80px -30px rgba(0,0,0,.9);backface-visibility:hidden;-webkit-backface-visibility:hidden}
|
||
#cover .back{transform:rotateY(180deg);background:var(--card);border:1px solid var(--line);display:flex;flex-direction:column}
|
||
#cover canvas{width:100%;height:100%;display:block}
|
||
#cover .back .backTitle{font:600 10px var(--font);letter-spacing:.22em;color:var(--dim);padding:14px 16px 6px;text-align:center}
|
||
#cover .veil{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--veil);backdrop-filter:blur(2px);font:12px var(--font);letter-spacing:.2em;color:var(--ink);opacity:0;transition:opacity .3s;pointer-events:none}
|
||
#cover.cooking .veil{opacity:1}
|
||
#meta{text-align:center;max-width:520px}
|
||
#title{font-size:20px;font-weight:600;letter-spacing:-.01em;margin:0}
|
||
#tags{color:var(--dim);font:11px var(--font);margin:4px 0 0;letter-spacing:.06em}
|
||
#lyric{color:var(--dim);font-size:13px;margin:8px 0 0;min-height:1.5em;font-style:italic;cursor:text}
|
||
#lyric:hover{color:var(--ink)}
|
||
#meta2{text-align:center;max-width:560px;width:100%}
|
||
#meta2 #lyric{margin-top:0}
|
||
#tagRow{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:10px auto 0;max-width:560px}
|
||
#tagList{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:8px auto 0;max-width:560px}
|
||
#tagList:empty{display:none}
|
||
#tagList button{font:12px var(--font);letter-spacing:.02em;padding:5px 10px;border-radius:999px;border:1px solid var(--line);color:var(--dim);background:transparent;transition:all .15s}
|
||
#tagList button.like{color:var(--onacc);background:var(--acc);border-color:var(--acc)}
|
||
#tagList button.avoid{color:hsl(220 40% 70%);border-color:hsl(220 30% 40%);text-decoration:line-through;opacity:.8}
|
||
#tagList button.add{border-style:dashed}
|
||
#tagList input{font:12px var(--font);width:110px;padding:5px 10px;border-radius:999px;border:1px solid var(--acc);background:var(--card);outline:none}
|
||
#meta2 #next{margin:8px 0 0}
|
||
#tagRow button.meta{color:var(--dim);border-style:dotted;cursor:default}
|
||
#tagRow button.fold{color:var(--acc);border-color:transparent;font-weight:600;letter-spacing:.12em}
|
||
#stage{gap:14px}
|
||
#tagRow button{font:12px var(--font);letter-spacing:.02em;padding:5px 10px;border-radius:999px;border:1px solid var(--line);color:var(--dim);background:transparent;transition:all .15s}
|
||
#tagRow button.like{color:var(--onacc);background:var(--acc);border-color:var(--acc)}
|
||
#tagRow button.avoid{color:hsl(220 40% 70%);border-color:hsl(220 30% 40%);text-decoration:line-through;opacity:.8}
|
||
#tagRow button.add{border-style:dashed}
|
||
#tagRow input{font:12px var(--font);width:110px;padding:5px 10px;border-radius:999px;border:1px solid var(--acc);background:var(--card);outline:none}
|
||
|
||
#lyrics{white-space:pre-wrap;text-align:center;font-size:13px;line-height:1.65;margin:0;color:var(--ink);flex:1;overflow:auto;padding:4px 16px 18px;font-family:var(--sans);-webkit-mask-image:linear-gradient(#000 85%,transparent);mask-image:linear-gradient(#000 85%,transparent)}
|
||
#lyrics.none{color:var(--dim);font-style:italic;display:grid;place-items:center}
|
||
#lyrics .sec{display:block;color:var(--dim);font:600 10px var(--font);letter-spacing:.2em;margin:12px 0 2px}
|
||
#lyrics .sec:first-child{margin-top:0}
|
||
#cover{cursor:pointer}
|
||
#bar{width:min(78vw,380px);height:2px;background:var(--line);border-radius:2px;position:relative;cursor:pointer;touch-action:none}
|
||
#bar::before{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px} /* a fat finger target around a thin line */
|
||
#bar i{display:block;height:100%;width:0;background:var(--acc);border-radius:2px;pointer-events:none}
|
||
#bar:hover,#bar.drag{height:4px;margin:-1px 0}
|
||
#bar b{position:absolute;top:-28px;transform:translateX(-50%);font:11px var(--font);letter-spacing:.1em;color:var(--dim);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;pointer-events:none;display:none;white-space:nowrap}
|
||
#bar.drag b{display:block}
|
||
#controls{display:flex;align-items:center;gap:18px}
|
||
#controls button{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;color:var(--ink);opacity:.85}
|
||
#controls #backTrack svg,#controls #skip svg{width:24px;height:24px}
|
||
#controls #prev{opacity:.6}
|
||
#pills{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
||
#pills button{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;border-radius:999px;border:1px solid var(--line);font:600 12px var(--font);letter-spacing:.14em;color:var(--ink);background:var(--card);transition:background .2s,color .2s,border-color .2s}
|
||
#pills button.on{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
|
||
#pills button:disabled{opacity:.4;cursor:default}
|
||
#controls button:hover{opacity:1}
|
||
#controls #play{width:62px;height:62px;background:var(--ink);color:var(--bg)}
|
||
/* the trail: a strip along the bottom of the cover; shows on hover (always on touch screens) */
|
||
#trail{position:absolute;left:0;right:0;bottom:0;z-index:3;display:flex;gap:10px;align-items:flex-end;overflow-x:auto;padding:40px 14px 14px;border-radius:0 0 18px 18px;scrollbar-width:none;-webkit-overflow-scrolling:touch;
|
||
background:linear-gradient(to top,rgba(var(--shade),.92),rgba(var(--shade),.55) 60%,transparent);opacity:0;pointer-events:none;transition:opacity .25s}
|
||
#cover:hover #trail,#cover.flipped:hover #trail{opacity:1;pointer-events:auto}
|
||
@media (hover:none){#trail{opacity:1;pointer-events:auto;padding-top:18px}}
|
||
#trail::-webkit-scrollbar{display:none}
|
||
#trail:empty{display:none}
|
||
#trail canvas{width:64px;height:64px;border-radius:12px;flex:none;opacity:.6;cursor:pointer;transition:opacity .2s,transform .2s}
|
||
#trail canvas:hover{opacity:.95;transform:translateY(-3px)}
|
||
#trail canvas.on{opacity:1;transform:scale(1.12);box-shadow:0 0 0 2px var(--ink)}
|
||
#trail canvas.kept{box-shadow:0 0 0 2px var(--acc)}
|
||
#trail canvas.on.kept{box-shadow:0 0 0 2px var(--acc),0 0 0 4px var(--ink)}
|
||
#controls svg{width:22px;height:22px;fill:currentColor}
|
||
#controls #loop,#controls #shuffle,#controls #remix{opacity:.6}
|
||
#controls #remix:hover{opacity:1}
|
||
#controls #shuffle.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
|
||
#controls #loop svg{width:18px;height:18px}
|
||
#controls #loop.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
|
||
#next{font:11px var(--font);letter-spacing:.14em;color:var(--dim);min-height:1.4em;text-align:center}
|
||
|
||
|
||
#react{display:flex;flex-direction:column;gap:10px;align-items:center}
|
||
#emojis{display:flex;gap:6px;justify-content:center;flex-wrap:wrap}
|
||
#emojis button{font-size:22px;width:44px;height:44px;border-radius:12px;border:1px solid transparent;transition:transform .15s,border-color .15s,background .15s}
|
||
#emojis button:hover{background:var(--card);border-color:var(--line)}
|
||
#emojis button.hit{transform:scale(1.25);border-color:var(--acc)}
|
||
#emojis #more{font-size:20px;color:var(--dim);background:var(--card);border:1px solid var(--line)}
|
||
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:5;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||
#picker h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:10px 4px 6px}
|
||
#picker .g{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:2px}
|
||
#picker .g button{font-size:24px;height:42px;border-radius:10px}
|
||
#picker .g button:hover{background:#1a1a20}
|
||
#picker .top{display:flex;gap:8px;align-items:center;margin-bottom:4px}
|
||
#picker .top input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:10px;padding:8px 12px;font-size:16px;outline:none}
|
||
#picker .top button{padding:8px 12px;border-radius:10px;border:1px solid var(--line);color:var(--dim);font:600 11px var(--font);letter-spacing:.14em}
|
||
#sayRow{display:flex;gap:8px;width:min(520px,100%)}
|
||
#say{flex:1;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 14px;outline:none;font-size:14px}
|
||
#say:focus{border-color:var(--acc)}
|
||
#sayRow button{padding:0 14px;border:1px solid var(--line);border-radius:12px;color:var(--dim)}
|
||
#toast{position:fixed;left:50%;bottom:110px;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;opacity:0;transition:opacity .25s;pointer-events:none;max-width:calc(100% - 32px);text-align:center}
|
||
#toast.on{opacity:1}
|
||
|
||
/* ---------- taste ---------- */
|
||
#taste{position:fixed;inset:0;background:rgba(11,11,14,.97);padding:18px 16px calc(18px + env(safe-area-inset-bottom));overflow:auto}
|
||
#taste header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:12px}
|
||
#tasteTabs{display:flex;gap:8px;overflow-x:auto;padding:2px 0 12px;scrollbar-width:none}
|
||
#tasteTabs button{display:flex;align-items:center;gap:8px;padding:6px 12px 6px 6px;border-radius:999px;border:1px solid var(--line);font-size:12px;white-space:nowrap;color:var(--dim)}
|
||
#tasteTabs button.on{color:var(--ink);border-color:var(--acc)}
|
||
#tasteTabs canvas{width:22px;height:22px;border-radius:50%}
|
||
#tasteMap{width:100%;max-width:640px;aspect-ratio:1;display:block;margin:0 auto;border-radius:18px;background:var(--card)}
|
||
#brainMap{width:100%;max-width:640px;aspect-ratio:2.2;display:block;margin:0 auto 14px;border-radius:18px;background:var(--card)}
|
||
#tastePalette{max-width:640px;margin:6px auto 18px}
|
||
#tastePalette h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px;text-align:center}
|
||
#tastePalette .row{display:flex;gap:14px;overflow-x:auto;padding:4px;scrollbar-width:none;justify-content:safe center}
|
||
#tastePalette .s{flex:0 0 96px;cursor:pointer;text-align:center}
|
||
#tastePalette .s canvas{width:80px;height:80px;border-radius:14px;display:block;margin:0 auto 6px}
|
||
#tastePalette .s b{display:block;font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#tasteNote{color:var(--dim);font-size:12px;text-align:center;margin:12px auto 0;max-width:520px}
|
||
#tasteLegend{display:flex;justify-content:center;gap:16px;font:11px var(--font);letter-spacing:.08em;color:var(--dim);margin-top:10px;flex-wrap:wrap}
|
||
|
||
/* ---------- tapes ---------- */
|
||
#lib .t .addTape{position:absolute;right:6px;bottom:6px;width:26px;height:26px;border-radius:50%;background:rgba(11,11,14,.75);border:1px solid var(--line);color:var(--ink);font-size:16px;line-height:1;display:grid;place-items:center}
|
||
#lib .t .addTape:hover{background:var(--acc);color:var(--onacc)}
|
||
#lib .t .fromT{position:absolute;left:6px;top:6px;width:26px;height:26px;border-radius:50%;background:var(--veil2);border:1px solid var(--line);color:var(--ink);font:700 16px var(--font);line-height:1;display:grid;place-items:center;opacity:0;transition:opacity .2s}
|
||
#lib .t:hover .fromT{opacity:1}
|
||
@media (hover:none){#lib .t .fromT{opacity:.8}}
|
||
#lib .t .fromT:hover{background:var(--acc);color:var(--onacc)}
|
||
#tapeRow{display:flex;gap:14px;overflow-x:auto;padding:4px 4px 10px;scrollbar-width:none;grid-column:1/-1}
|
||
#tapeRow .s{flex:0 0 110px;cursor:pointer;text-align:center}
|
||
#tapeRow .s .tc{width:88px;height:88px;border-radius:14px;margin:0 auto 6px;position:relative;overflow:hidden;background:var(--card);display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px}
|
||
#tapeRow .s .tc canvas{width:100%;height:100%;display:block}
|
||
#tapeRow .s .tc.one{grid-template-columns:1fr;grid-template-rows:1fr}
|
||
#tapeRow .s.new .tc{display:grid;place-items:center;border:1px dashed var(--line);font-size:26px;color:var(--dim)}
|
||
#tapeRow .s b{display:block;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#tapeRow .s small{color:var(--dim);font-size:11px}
|
||
#tapePick{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||
#tapePick h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
|
||
#tapePick button{display:block;width:100%;text-align:left;padding:10px 12px;border-radius:10px;border:1px solid var(--line);margin-bottom:6px}
|
||
#tapePick button:hover{background:#1a1a20}
|
||
#tapePick input{width:100%;background:#0a0a0d;border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none;margin-bottom:6px}
|
||
#tape{position:fixed;inset:0;background:rgba(11,11,14,.97);padding:18px 16px calc(18px + env(safe-area-inset-bottom));overflow:auto;z-index:4}
|
||
#tape header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:12px;gap:10px}
|
||
#tape header input{flex:1;background:transparent;border:0;border-bottom:1px solid var(--line);font:600 18px var(--sans);letter-spacing:0;color:var(--ink);outline:none;padding:4px 0;min-width:0}
|
||
#tape .acts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||
#tape .acts button{padding:9px 16px;border-radius:999px;border:1px solid var(--line);font:600 12px var(--font);letter-spacing:.14em;background:var(--card)}
|
||
#tape .acts button.go{background:var(--acc);color:var(--onacc);border-color:var(--acc)}
|
||
#tape .list{display:flex;flex-direction:column;gap:8px;max-width:640px;margin:0 auto}
|
||
#tape .row{display:flex;align-items:center;gap:12px;padding:8px;border-radius:14px;background:var(--card);border:1px solid var(--line)}
|
||
#tape .row.on{border-color:var(--acc)}
|
||
#tape .row canvas{width:52px;height:52px;border-radius:10px;flex:0 0 52px;cursor:pointer}
|
||
#tape .row .m{flex:1;min-width:0;cursor:pointer}
|
||
#tape .row b{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#tape .row small{color:var(--dim);font-size:11px;font-family:var(--font)}
|
||
#tape .row .ctl{display:flex;gap:2px}
|
||
#tape .row .ctl button{width:32px;height:32px;border-radius:8px;color:var(--dim);font-size:14px}
|
||
#tape .row .ctl button:hover{background:#1a1a20;color:var(--ink)}
|
||
#tape .empty{color:var(--dim);text-align:center;padding:40px 0}
|
||
|
||
/* ---------- library ---------- */
|
||
#lib{position:fixed;inset:0;background:var(--bg);padding:calc(18px + env(safe-area-inset-top)) 16px calc(90px + env(safe-area-inset-bottom));overflow:auto}
|
||
#lib .t.now .art{box-shadow:0 0 0 2px var(--ink)}
|
||
#lib .t.now .art::after{content:"▶";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:34px;height:34px;border-radius:50%;background:var(--ink);color:var(--bg);display:grid;place-items:center;font-size:13px;padding-left:2px}
|
||
#lib .t.now.paused .art::after{content:"‖";padding-left:0}
|
||
#libNow{position:fixed;left:50%;bottom:calc(14px + env(safe-area-inset-bottom));transform:translateX(-50%);width:min(560px,calc(100% - 24px));display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:8px 8px 8px 8px;z-index:4;box-shadow:0 20px 50px -20px rgba(0,0,0,.6)}
|
||
#libNow canvas{width:44px;height:44px;border-radius:9px;flex:none}
|
||
#libNow .t{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||
#libNow b{font:600 13px var(--sans);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#libNow small{font:11px var(--font);letter-spacing:.14em;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#libNow button{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;color:var(--ink)}
|
||
#libNow button.go{background:var(--ink);color:var(--bg)}
|
||
#libNow button.open{width:auto;border-radius:999px;padding:0 12px;font:600 11px var(--font);letter-spacing:.14em;border:1px solid var(--line)}
|
||
#lib header{display:flex;justify-content:space-between;align-items:center;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim);margin-bottom:16px}
|
||
#lib .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:14px}
|
||
#lib h3{font:600 11px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 10px;display:flex;align-items:center;gap:10px}
|
||
#lib h3 canvas{width:22px;height:22px;border-radius:50%}
|
||
#lib h3 small{font-weight:400;letter-spacing:.08em;opacity:.7}
|
||
#lib .t.on canvas{outline:2px solid var(--acc);outline-offset:2px}
|
||
#lib .t .art{position:relative;aspect-ratio:1;border-radius:12px;overflow:hidden;background:var(--card)}
|
||
#lib .t .art canvas{width:100%;height:100%;display:block}
|
||
#lib .t .kept{position:absolute;top:6px;right:6px;font:600 9px var(--font);letter-spacing:.18em;color:var(--onacc);background:var(--acc);border-radius:999px;padding:3px 7px}
|
||
#lib .t .re{position:absolute;left:6px;bottom:6px;font-size:13px;letter-spacing:1px;background:var(--veil2);border-radius:8px;padding:2px 6px;max-width:calc(100% - 12px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#lib .t small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#lib .t{cursor:pointer}
|
||
#lib .t canvas{width:100%;aspect-ratio:1;border-radius:12px;display:block;background:var(--card)}
|
||
#lib .t b{display:block;font-size:12px;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#lib .t small{color:var(--dim);font-size:11px}
|
||
#lib .empty{color:var(--dim);text-align:center;padding:40px 0}
|
||
#lib .cost{color:var(--dim);font:11px var(--font);margin-top:24px;text-align:center}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ============ screen 1: the seed ============ -->
|
||
<section id="seed">
|
||
<div class="wordmark">g<span class="ast">*</span>ndora</div>
|
||
<div id="nowRow" class="hidden"><div id="now" title="back to the station"><canvas width="56" height="56"></canvas><span id="nowTitle"></span><b>▶ PLAYING</b></div></div>
|
||
<form id="seedForm" autocomplete="off">
|
||
<div id="seedCover"><canvas width="240" height="240"></canvas></div>
|
||
<input id="seedIn" placeholder="*" maxlength="200" autofocus>
|
||
<div class="hint" id="seedHint">a mood, a line, a place, a word</div>
|
||
</form>
|
||
<div id="stations" class="hidden"><h3>YOUR STATIONS</h3><div class="row" id="stationRow"></div></div>
|
||
<div id="keyRow"><button class="nav" id="libBtn2" type="button">LIBRARY</button><button class="nav" id="tasteBtn2" type="button">TASTE</button><button class="nav theme" type="button" title="light / dark">LIGHT</button><button class="nav reload" type="button" title="reload the app">RELOAD</button><button id="keyBtn" type="button" class="hidden">shared budget · add a venice key</button></div>
|
||
<div id="keyBox" class="hidden">
|
||
<p>Optional. Without a key, gandora runs on a shared budget through inference.yolopreneur.lol (a few tracks a day per person). With your own Venice key it goes straight to api.venice.ai from this browser and the key goes nowhere else.</p>
|
||
<input id="keyIn" placeholder="sk-…" spellcheck="false">
|
||
<label>DJ model <input id="djIn" placeholder="claude-haiku-5-5" spellcheck="false"></label>
|
||
<div class="row"><button id="keyCancel" type="button">cancel</button><button id="keySave" class="go" type="button">save</button></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ screen 2: the station ============ -->
|
||
<section id="player" class="hidden">
|
||
<div id="top">
|
||
<button id="back" title="new seed">◂ SEED</button>
|
||
<div><button id="tasteBtn">TASTE</button> <button id="libBtn">LIBRARY</button> <button class="theme" title="light / dark">LIGHT</button></div>
|
||
</div>
|
||
<div id="stage">
|
||
<div class="station" id="stationName" title="rename, look, reset or delete"></div>
|
||
<div id="cover"><div class="card">
|
||
<div class="face"><canvas width="760" height="760"></canvas><div class="veil">COOKING</div></div>
|
||
<div class="face back"><div class="backTitle">LYRICS</div><pre id="lyrics"></pre></div>
|
||
</div><div id="trail" title="what this station has played · tap to jump"></div></div>
|
||
<div id="meta"><h1 id="title">…</h1></div>
|
||
<div id="bar" title="scrub"><i></i><b></b></div>
|
||
<div id="meta2"><p id="lyric" title="reply to the DJ"></p><div id="next"></div><div id="pills"><button id="lyricsBtn">⇄ LYRICS</button><button id="keep" disabled>➕ KEEP</button><button id="share" disabled>↗ SHARE</button></div><p id="tags" class="hidden"></p><div id="tagRow"></div><div id="tagList"></div></div>
|
||
<div id="controls">
|
||
<button id="backTrack" title="previous track">
|
||
<svg viewBox="0 0 24 24"><path d="M18 6l-8.5 6 8.5 6V6zM6 6v12h2V6H6z"/></svg>
|
||
</button>
|
||
<button id="shuffle" title="shuffle: mix the station's own library in">
|
||
<svg viewBox="0 0 24 24"><path d="M17 4h4v4l-1.5-1.5L16 10l-1.4-1.4 3.5-3.5L17 4zM3 6h4.5l3 3-1.4 1.4L7 8H3V6zm14.6 10.6 1.5-1.5v4h-4l1.5-1.5-3.1-3.1 1.4-1.4 2.7 2.7zM3 18v-2h4l9.5-9.5L18 8l-10 10H3z"/></svg>
|
||
</button>
|
||
<button id="prev" title="replay from the start">
|
||
<svg viewBox="0 0 24 24"><path d="M12 5V2L7 7l5 5V8c3.3 0 6 2.7 6 6s-2.7 6-6 6-6-2.7-6-6H4c0 4.4 3.6 8 8 8s8-3.6 8-8-3.6-8-8-8z"/></svg>
|
||
</button>
|
||
<button id="play" title="play / pause"><svg id="playIco" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>
|
||
<button id="loop" title="repeat this track">
|
||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||
</button>
|
||
<button id="remix" title="remix this track: same bones, your changes">
|
||
<svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v3l4-4-4-4v3zm0 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
|
||
</button>
|
||
<button id="skip" title="next">
|
||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="react">
|
||
<div id="emojis">
|
||
<button data-e="🔥" title="more of this">🔥</button>
|
||
<button data-e="❤️" title="love it">❤️</button>
|
||
<button data-e="😴" title="too sleepy">😴</button>
|
||
<button data-e="🌧️" title="sadder">🌧️</button>
|
||
<button data-e="⚡" title="faster, louder">⚡</button>
|
||
<button data-e="🎸" title="more guitar">🎸</button>
|
||
<button data-e="🚫" title="not this">🚫</button>
|
||
<button id="more" title="more emoji">+</button>
|
||
</div>
|
||
<div id="picker" class="hidden"></div>
|
||
<form id="sayRow"><input id="say" placeholder="or say it: less snare, sadder but faster…" maxlength="240"><button>send</button></form>
|
||
</div>
|
||
<div id="toast"></div>
|
||
</section>
|
||
|
||
<section id="taste" class="hidden">
|
||
<header><span>TASTE</span><button id="tasteClose">CLOSE</button></header>
|
||
<div id="tasteTabs"></div>
|
||
<div id="tastePalette"></div>
|
||
<canvas id="brainMap" width="880" height="400"></canvas>
|
||
<canvas id="tasteMap" width="900" height="900"></canvas>
|
||
<div id="tasteLegend"><span>← slow · tempo · fast →</span><span>↑ energy</span><span>colour = key</span><span>ring = 🔥❤️ warm · 🚫⏭ cold</span></div>
|
||
<div id="tasteNote"></div>
|
||
</section>
|
||
|
||
<section id="tape" class="hidden">
|
||
<header><span>TAPE</span><input id="tapeName" maxlength="60"><button id="tapeClose">CLOSE</button></header>
|
||
<div class="acts"><button class="go" id="tapePlay">▶ PLAY ALL</button><button id="tapeLoop">🔁 REPEAT ALL</button><button id="tapeShare">↗ SHARE</button><button id="tapeDelete">DELETE TAPE</button></div>
|
||
<div class="list" id="tapeList"></div>
|
||
<div class="empty hidden" id="tapeEmpty">Empty tape. Add tracks from the library with the + on a tile.</div>
|
||
</section>
|
||
<div id="tapePick" class="hidden"></div>
|
||
<button id="upd" class="hidden" type="button">NEW VERSION · RELOAD</button>
|
||
<div id="stationSheet" class="hidden"></div>
|
||
<div id="scrim" class="hidden"></div>
|
||
|
||
<section id="lib" class="hidden">
|
||
<header><span>LIBRARY</span><button id="libClose">CLOSE</button></header>
|
||
<div class="grid" id="libGrid"></div>
|
||
<div class="empty hidden" id="libEmpty">Nothing played yet. Every track lands here; KEEP one to pin it for good.</div>
|
||
<div class="cost" id="libCost"></div>
|
||
<div id="libNow" class="hidden"><canvas width="120" height="120"></canvas><div class="t"><b></b><small></small></div><button class="go" id="libPlay" title="play / pause"><svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:currentColor"><path d="M8 5v14l11-7z"/></svg></button><button class="open" id="libOpen" title="back to the player">PLAYER ▸</button></div>
|
||
</section>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
gandora — a generative station.
|
||
seed → DJ (Venice chat) → ACE-Step (Venice /audio/queue) → player.
|
||
Every cover is drawn from the song's own settings: hue from key,
|
||
rings from BPM, density from energy, symmetry from the seed.
|
||
============================================================ */
|
||
const DIRECT = "https://api.venice.ai/api/v1"; // with a personal key
|
||
const SHARED = "https://inference.yolopreneur.lol/v1"; // without one: the factory's worker, quota'd per person
|
||
const $ = (s) => document.querySelector(s);
|
||
const LS = { key: "gandora.key", dj: "gandora.dj", cost: "gandora.cost" };
|
||
const state = {
|
||
key: localStorage.getItem(LS.key) || "",
|
||
dj: localStorage.getItem(LS.dj) || "claude-haiku-5-5",
|
||
seed: "", station: "", stationId: "", sinceAd: 0,
|
||
taste: { like: [], avoid: [], notes: [] }, // what the listener has told the station
|
||
history: [], // tracks played this session (specs)
|
||
current: null, nextJob: null, playing: false,
|
||
recent: [], pos: -1, // ids of tracks played this session, and where the player stands in them
|
||
audio: Object.assign(new Audio(), { volume: Math.min(1, Math.max(0, parseFloat(localStorage.getItem("gandora.vol") || "1"))) }), cost: parseFloat(localStorage.getItem(LS.cost) || "0"),
|
||
};
|
||
state.audio.preload = "auto";
|
||
|
||
/* ---------- small utils ---------- */
|
||
const toast = (m, ms = 2400) => { const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove("on"), ms); };
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
function hash32(str) { let h = 2166136261 >>> 0; for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return h; }
|
||
function prng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
|
||
const KEYS = ["C","G","D","A","E","B","F#","C#","G#","D#","A#","F"]; // circle of fifths → hue wheel
|
||
function keyHue(k) {
|
||
const m = /^([A-G])(#|b)?/.exec(String(k || "C").trim()); if (!m) return 40;
|
||
let name = m[1] + (m[2] === "#" ? "#" : "");
|
||
if (m[2] === "b") { const flat = { Db:"C#", Eb:"D#", Gb:"F#", Ab:"G#", Bb:"A#", Cb:"B", Fb:"E" }; name = flat[m[1] + "b"] || m[1]; }
|
||
const i = KEYS.indexOf(name); return (i < 0 ? 0 : i) * 30;
|
||
}
|
||
|
||
/* ---------- the cover: a function of the song ---------- */
|
||
// pulse: live signal from the analyser { bass, mid, high, t } in 0..1 (and seconds); without it the cover is still.
|
||
// The ground and grain depend only on the song, so they are drawn once per (song, size) and cached; the rings and
|
||
// the seed mark are redrawn on top every frame when the music is playing.
|
||
const groundCache = new Map();
|
||
/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */
|
||
const PALETTES = ["mono", "duo", "tri"];
|
||
function palette(spec) {
|
||
const energy = Math.min(1, Math.max(0, +spec.energy || 0.5)), minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || "");
|
||
const hue = keyHue(spec.key), mode = spec.palette || "duo";
|
||
const hue2 = mode === "mono" ? hue : mode === "tri" ? (hue + 120) % 360 : (hue + (minor ? 200 : 150)) % 360;
|
||
const hue3 = mode === "tri" ? (hue + 240) % 360 : hue2;
|
||
return { energy, minor, hue, hue2, hue3, mode, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 };
|
||
}
|
||
function drawGround(W, H, spec, lit) {
|
||
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
|
||
if (groundCache.has(key)) return groundCache.get(key);
|
||
const off = document.createElement("canvas"); off.width = W; off.height = H;
|
||
const ctx = off.getContext("2d"), cx = W / 2, cy = H / 2, p = palette(spec);
|
||
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) ^ 0x9e37);
|
||
const g = ctx.createLinearGradient(0, 0, 0, H);
|
||
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .18}%)`);
|
||
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
||
ctx.globalCompositeOperation = "lighter";
|
||
const spread = W * .46, dots = Math.floor(400 + p.energy * 2600);
|
||
for (let i = 0; i < dots; i++) {
|
||
const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05;
|
||
ctx.fillStyle = `hsl(${i % 3 ? p.hue2 : p.hue3} ${p.sat}% ${p.light + (p.mode === "mono" ? 38 : 30)}% / ${rnd() * 0.5 * lit})`;
|
||
ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6));
|
||
}
|
||
if (groundCache.size > 24) groundCache.delete(groundCache.keys().next().value);
|
||
groundCache.set(key, off);
|
||
return off;
|
||
}
|
||
/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */
|
||
const LOOKS = ["rings", "chord", "attractor"];
|
||
function drawCover(canvas, spec, opts = {}) {
|
||
const look = spec.look || opts.look || "rings";
|
||
if (look === "chord") drawChord(canvas, spec, opts);
|
||
else if (look === "attractor") drawAttractor(canvas, spec, opts);
|
||
else drawRings(canvas, spec, opts);
|
||
if (spec.break) drawOnAir(canvas, spec, opts);
|
||
}
|
||
/* an intermission wears the station's cover in monochrome, with the asterisk as the mark and an ON AIR ring */
|
||
function drawOnAir(canvas, spec, { pulse = null } = {}) {
|
||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
|
||
const ink = cssVar("--ink"), ground = cssVar("--bg");
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, W, H); // the colour drains out
|
||
ctx.globalCompositeOperation = "source-over";
|
||
ctx.fillStyle = ground; ctx.globalAlpha = 0.22; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
|
||
// the asterisk, square ends, over the petal mark
|
||
ctx.translate(cx, cy); ctx.scale(1 + 0.12 * bass, 1 + 0.12 * bass);
|
||
ctx.fillStyle = ground; ctx.beginPath(); ctx.arc(0, 0, W * 0.125, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = ink; ctx.lineWidth = W * 0.026; ctx.lineCap = "butt";
|
||
for (let k = 0; k < 3; k++) { const a = Math.PI / 2 + k * Math.PI / 3; ctx.beginPath(); ctx.moveTo(-Math.cos(a) * W * 0.07, -Math.sin(a) * W * 0.07); ctx.lineTo(Math.cos(a) * W * 0.07, Math.sin(a) * W * 0.07); ctx.stroke(); }
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
// the ring: dashed, turning slowly, lettered ON AIR · kind
|
||
ctx.save(); ctx.translate(cx, cy); ctx.rotate(tm * 0.15);
|
||
ctx.strokeStyle = ink; ctx.globalAlpha = 0.7; ctx.lineWidth = W * 0.006; ctx.setLineDash([W * 0.02, W * 0.014]);
|
||
ctx.beginPath(); ctx.arc(0, 0, W * 0.43, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
|
||
const label = `ON AIR · ${String(spec.break).toUpperCase()}${spec.title && spec.break === "welcome" ? " · " + spec.title.toUpperCase() : ""}`;
|
||
ctx.font = `600 ${W * 0.028}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
const tw = ctx.measureText(label).width + W * 0.05;
|
||
ctx.fillStyle = ground; ctx.globalAlpha = 0.85; ctx.fillRect(cx - tw / 2, H * 0.86 - W * 0.025, tw, W * 0.05); ctx.globalAlpha = 1;
|
||
ctx.fillStyle = ink; ctx.fillText(label, cx, H * 0.86);
|
||
ctx.restore();
|
||
}
|
||
const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || "#ededf0";
|
||
function drawMark(ctx, W, spec, p, lit, bass, tm) {
|
||
const core = prng(hash32(spec.seed || ""));
|
||
const petals = 3 + Math.floor(core() * 5);
|
||
ctx.save(); ctx.translate(W / 2, W / 2); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
|
||
ctx.fillStyle = `hsl(${p.hue} ${p.sat}% ${Math.min(92, p.light + 45)}% / ${0.85 * lit})`;
|
||
ctx.beginPath();
|
||
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
|
||
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
|
||
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
|
||
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||
}
|
||
ctx.closePath(); ctx.fill(); ctx.restore();
|
||
}
|
||
/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo,
|
||
phased by the seed; the bass swells the whole figure */
|
||
function drawChord(canvas, spec, { lit = 1, pulse = null } = {}) {
|
||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||
const p = palette(spec), rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
|
||
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
|
||
ctx.globalCompositeOperation = "source-over";
|
||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, W * .7);
|
||
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .32}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .14}%)`);
|
||
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
||
const [fa, fb] = p.minor ? [6, 5] : [5, 4];
|
||
const fc = 1 + Math.round(p.bpm / 40); // a third pendulum: tempo adds a faster harmonic
|
||
const damp = 0.00045 + (p.bpm - 60) / 120 * 0.0006, ph1 = rnd() * Math.PI * 2, ph2 = rnd() * Math.PI * 2, ph3 = rnd() * Math.PI * 2;
|
||
const amp = W * 0.36 * (1 + 0.1 * bass), mix = 0.2 + 0.5 * p.energy, detune = 0.004 + 0.01 * rnd();
|
||
ctx.globalCompositeOperation = "lighter";
|
||
// three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré
|
||
for (let pass = 0; pass < 3; pass++) {
|
||
const dt = pass === 2 ? detune : 0;
|
||
ctx.beginPath();
|
||
for (let t = 0; t < 2600; t += 0.6) {
|
||
const e = Math.exp(-damp * t);
|
||
const x = cx + amp * e * (Math.sin((fa + dt) * t * 0.02 + ph1 + tm * 0.02) + mix * 0.3 * Math.sin(fc * t * 0.02 + ph3));
|
||
const y = cy + amp * e * (Math.sin((fb - dt) * t * 0.02 + ph2 - tm * 0.02) + mix * 0.3 * Math.cos(fc * t * 0.02 + ph3));
|
||
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||
}
|
||
ctx.lineWidth = pass ? (0.55 + 0.6 * high) * (W / 380) : (3 + 2 * bass) * (W / 380);
|
||
ctx.strokeStyle = pass ? `hsl(${pass === 2 ? p.hue2 : p.hue} ${p.sat}% ${p.light + 36}% / ${0.42 * lit})` : `hsl(${p.hue2} ${p.sat}% ${p.light + 12}% / ${0.12 * lit})`;
|
||
ctx.stroke();
|
||
}
|
||
ctx.globalCompositeOperation = "source-over";
|
||
drawMark(ctx, W, spec, p, lit, bass, tm);
|
||
}
|
||
/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is
|
||
drawn once and cached, the seed mark beats on top */
|
||
const attractorCache = new Map();
|
||
function drawAttractor(canvas, spec, { lit = 1, pulse = null } = {}) {
|
||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
|
||
const p = palette(spec);
|
||
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
|
||
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
|
||
let off = attractorCache.get(key);
|
||
if (!off) {
|
||
off = document.createElement("canvas"); off.width = W; off.height = H;
|
||
const o = off.getContext("2d"), rnd = prng(hash32(key));
|
||
const g = o.createLinearGradient(0, 0, W, H);
|
||
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .45}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .15}%)`);
|
||
o.fillStyle = g; o.fillRect(0, 0, W, H);
|
||
// parameters in the ranges that keep Clifford figures open and filamentary
|
||
const a = -1.4 - 0.6 * ((p.hue % 120) / 120), b = 1.2 + 0.8 * ((p.bpm - 60) / 120), c = 0.6 + 1.2 * p.energy, d = (p.minor ? -1 : 1) * (0.6 + 1.0 * rnd());
|
||
let x = 0.1, y = 0.1; const n = 36000, pts = [];
|
||
let minX = 9, maxX = -9, minY = 9, maxY = -9;
|
||
for (let i = 0; i < n; i++) { const nx = Math.sin(a * y) + c * Math.cos(a * x), ny = Math.sin(b * x) + d * Math.cos(b * y); x = nx; y = ny; if (i > 200) { pts.push(x, y); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } }
|
||
const sx = (W * 0.78) / Math.max(1e-6, maxX - minX), sy = (H * 0.78) / Math.max(1e-6, maxY - minY), s = Math.min(sx, sy);
|
||
const ox = W / 2 - (minX + maxX) / 2 * s, oy = H / 2 - (minY + maxY) / 2 * s;
|
||
o.globalCompositeOperation = "lighter";
|
||
o.fillStyle = `hsl(${p.hue} ${p.sat}% ${p.light + 28}% / ${0.16 * lit})`;
|
||
const dot = Math.max(1, W / 380);
|
||
for (let i = 0; i < pts.length; i += 2) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot, dot);
|
||
o.fillStyle = `hsl(${p.hue2} ${p.sat}% ${p.light + 40}% / ${0.08 * lit})`;
|
||
for (let i = 0; i < pts.length; i += 6) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot * 2, dot * 2);
|
||
if (attractorCache.size > 16) attractorCache.delete(attractorCache.keys().next().value);
|
||
attractorCache.set(key, off);
|
||
}
|
||
ctx.globalCompositeOperation = "source-over";
|
||
ctx.drawImage(off, 0, 0);
|
||
if (bass > 0.02) { ctx.globalAlpha = 0.5 * bass; ctx.drawImage(off, W / 2 - W * (0.5 + 0.03 * bass), H / 2 - H * (0.5 + 0.03 * bass), W * (1 + 0.06 * bass), H * (1 + 0.06 * bass)); ctx.globalAlpha = 1; }
|
||
drawMark(ctx, W, spec, p, lit, bass, tm);
|
||
}
|
||
function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
|
||
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
|
||
const p = palette(spec), { energy, minor, hue, sat, light, bpm } = p;
|
||
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
|
||
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
|
||
ctx.globalCompositeOperation = "source-over";
|
||
ctx.drawImage(drawGround(W, H, spec, lit), 0, 0);
|
||
// rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line
|
||
const rings = Math.max(4, Math.round(bpm / 10));
|
||
const spread = W * .46, sym = 3 + Math.floor(rnd() * 6); // symmetry order from the seed
|
||
ctx.globalCompositeOperation = "lighter";
|
||
for (let r = 0; r < rings; r++) {
|
||
const t = (r + 1) / rings, rad = spread * Math.pow(t, .8) * (1 + 0.07 * bass * (1 - t));
|
||
ctx.beginPath();
|
||
const wob = 0.02 + energy * 0.08 + 0.03 * high, ph = rnd() * Math.PI * 2 + tm * (0.15 + 0.3 * t) * (r % 2 ? 1 : -1);
|
||
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 90) {
|
||
const rr = rad * (1 + wob * Math.sin(a * sym + ph) + wob * 0.5 * Math.sin(a * sym * 2 + ph * 3));
|
||
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
|
||
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||
}
|
||
ctx.closePath();
|
||
ctx.lineWidth = (0.6 + energy * 2.2 + high * 1.5) * (W / 380) * (1 - t * 0.5);
|
||
ctx.strokeStyle = `hsl(${(hue + t * (minor ? -40 : 40)) % 360} ${sat}% ${light + t * 25}% / ${(0.12 + 0.6 * (1 - t)) * lit * (1 + 0.5 * bass)})`;
|
||
ctx.stroke();
|
||
}
|
||
ctx.globalCompositeOperation = "source-over";
|
||
// a core: the station's seed mark, same shape for every track of one seed; it beats with the bass
|
||
const core = prng(hash32(spec.seed || ""));
|
||
const petals = 3 + Math.floor(core() * 5);
|
||
ctx.save(); ctx.translate(cx, cy); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
|
||
ctx.fillStyle = `hsl(${hue} ${sat}% ${Math.min(92, light + 45)}% / ${0.85 * lit})`;
|
||
ctx.beginPath();
|
||
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
|
||
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
|
||
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
|
||
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||
}
|
||
ctx.closePath(); ctx.fill(); ctx.restore();
|
||
}
|
||
|
||
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
|
||
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
|
||
so the first real track can start itself when the render lands, however long that takes */
|
||
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
|
||
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
|
||
const crackle = { el: null, on: false };
|
||
function makeCrackle() {
|
||
const sr = 22050, n = sr * 4, out = new Int16Array(n), rnd = prng(0x5eed);
|
||
let lp = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss
|
||
let v = lp * 0.5;
|
||
if (rnd() < 0.0012) { const len = 20 + Math.floor(rnd() * 80), amp = 0.4 + rnd() * 0.6; for (let k = 0; k < len && i + k < n; k++) out[i + k] += (amp * (1 - k / len) * (rnd() * 2 - 1)) * 32767 * 0.9; } // a pop
|
||
out[i] += v * 32767;
|
||
}
|
||
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||
new Int16Array(buf, 44).set(out);
|
||
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = parseFloat(localStorage.getItem("gandora.bed") || "0.45"); el.preload = "auto";
|
||
return el;
|
||
}
|
||
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
|
||
function bed() { const want = state.primed && !state.playing && !state.holding; if (want) crackleOn(); else crackleOff(); }
|
||
function crackleOn() { if (crackle.on || state.holding) return; crackle.el ||= makeCrackle(); crackle.on = true; crackle.el.volume = crackle.base ?? crackle.el.volume; crackle.el.currentTime = 0; crackle.el.play().catch(() => {}); }
|
||
function crackleOff() { if (!crackle.on) return; crackle.on = false; const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
|
||
/* 50 ms of silence as a wav, built here rather than pasted */
|
||
function wavBlob(samples, sr) {
|
||
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||
new Int16Array(buf, 44).set(samples);
|
||
return new Blob([buf], { type: "audio/wav" });
|
||
}
|
||
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
|
||
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
|
||
function prime() {
|
||
if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return;
|
||
try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {}
|
||
try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {}
|
||
}
|
||
for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true });
|
||
/* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when
|
||
the screen locks (and obeys the mute switch). So on iOS the cover pulses to the beat from the sheet instead,
|
||
and the music stays a plain <audio> element that keeps playing in the background. */
|
||
const IOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
|
||
function vizBeat() {
|
||
cancelAnimationFrame(viz.raf);
|
||
const canvas = $("#cover canvas"), t0 = performance.now(), rnd = prng(hash32(state.current.spec.title || ""));
|
||
const frame = () => {
|
||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||
const spec = state.current.spec, t = (performance.now() - t0) / 1000, bps = (+spec.bpm || 100) / 60, en = +spec.energy || 0.5;
|
||
const beat = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps)), 6); // a kick on every beat
|
||
const bar = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps / 4)), 2); // and a swell on the bar
|
||
const bass = Math.min(1, (0.25 + 0.75 * beat) * (0.5 + 0.5 * en)), mid = 0.3 + 0.3 * bar * en, high = 0.2 + 0.25 * Math.pow(Math.max(0, Math.sin(2 * Math.PI * t * bps * 2)), 4) * en;
|
||
drawCover(canvas, spec, { pulse: { bass, mid, high, t } });
|
||
viz.raf = requestAnimationFrame(frame);
|
||
};
|
||
viz.raf = requestAnimationFrame(frame);
|
||
}
|
||
function vizStart() {
|
||
if (IOS || state.beatViz) return vizBeat();
|
||
// the player is only wired through the analyser once the context is actually running: a suspended context
|
||
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
|
||
try {
|
||
viz.ctx ||= new (window.AudioContext || window.webkitAudioContext)();
|
||
if (viz.ctx.state !== "running") { viz.ctx.resume().then(() => { if (viz.ctx.state === "running" && state.playing) vizStart(); }).catch(() => {}); if (viz.ctx.state !== "running") return; }
|
||
if (!viz.an) {
|
||
const src = viz.ctx.createMediaElementSource(state.audio);
|
||
viz.an = viz.ctx.createAnalyser(); viz.an.fftSize = 512; viz.an.smoothingTimeConstant = 0.7;
|
||
src.connect(viz.an); viz.an.connect(viz.ctx.destination);
|
||
viz.data = new Uint8Array(viz.an.frequencyBinCount);
|
||
}
|
||
} catch { return; }
|
||
cancelAnimationFrame(viz.raf);
|
||
const canvas = $("#cover canvas"), t0 = performance.now();
|
||
const band = (a, b) => { let s = 0; for (let i = a; i < b; i++) s += viz.data[i]; return s / (b - a) / 255; };
|
||
const frame = () => {
|
||
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
|
||
viz.an.getByteFrequencyData(viz.data);
|
||
const raw = { bass: band(1, 8), mid: band(8, 40), high: band(40, 120) };
|
||
for (const k in raw) viz.smooth[k] += (raw[k] - viz.smooth[k]) * (raw[k] > viz.smooth[k] ? 0.5 : 0.12); // fast attack, slow decay
|
||
const s = viz.smooth;
|
||
drawCover(canvas, state.current.spec, { pulse: { bass: Math.min(1, s.bass * 1.4), mid: s.mid, high: Math.min(1, s.high * 2.5), t: (performance.now() - t0) / 1000 } });
|
||
viz.raf = requestAnimationFrame(frame);
|
||
};
|
||
viz.raf = requestAnimationFrame(frame);
|
||
}
|
||
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
|
||
|
||
/* ---------- Venice ---------- */
|
||
async function venice(path, body, { raw = false } = {}) {
|
||
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
|
||
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
|
||
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
|
||
if (!res.ok) {
|
||
let d = null; try { d = await res.json(); } catch {}
|
||
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
|
||
const who = state.key ? "Venice" : "The renderer";
|
||
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
|
||
throw new Error(why || (who + " answered " + res.status + (said ? ": " + said : "")));
|
||
}
|
||
return raw ? res : res.json();
|
||
}
|
||
|
||
const DJ_SCHEMA = {
|
||
type: "object", additionalProperties: false,
|
||
required: ["title", "tags", "lyrics", "bpm", "key", "energy", "seconds", "meter", "station", "line"],
|
||
properties: {
|
||
title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" },
|
||
bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" },
|
||
seconds: { type: "integer" }, meter: { type: "string" },
|
||
station: { type: "string" }, line: { type: "string" },
|
||
},
|
||
};
|
||
function remixText() {
|
||
const s = state.remixOf; if (!s) return "";
|
||
const likes = [], avoids = [];
|
||
for (const m of String(s.reaction || "").matchAll(/(?:^|\s)([+\-])([^\s]+)/g)) (m[1] === "+" ? likes : avoids).push(m[2].replace(/_/g, " "));
|
||
const words = [...String(s.reaction || "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
||
return `\nTHIS IS A REMIX REQUEST. Rework this exact track rather than writing a new one:\n- title: ${s.title}\n- tags: ${s.tags}\n- bpm ${s.bpm}, key ${s.key}, energy ${s.energy}\n- lyrics:\n${s.lyrics || "(instrumental)"}\nKeep its bones (hook, mood, most of the lyrics) and apply the listener's changes: ${likes.length ? "more " + likes.join(", ") + "; " : ""}${avoids.length ? "less or no " + avoids.join(", ") + "; " : ""}${words.length ? "in their words: " + words.join(" / ") : ""}${!likes.length && !avoids.length && !words.length ? "a fresh take on the same song (new arrangement, same heart)" : ""}. Title it as a remix of the original (e.g. "${s.title} (remix)" or a named version).\n`;
|
||
}
|
||
function djPrompt() {
|
||
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}`).join("\n");
|
||
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}. The listener typed a seed and the station grows from it.${remixText()}
|
||
Seed: "${state.seed}"
|
||
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
|
||
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
|
||
Listener's words: ${state.taste.notes.join(" | ") || "(none yet)"}
|
||
Recent tracks:
|
||
${prev || "- (none yet: this is the first)"}
|
||
|
||
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
|
||
Return JSON:
|
||
- title: a short evocative song title (never reuse one)
|
||
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
|
||
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 16-32 lines for a two-minute track, or an empty string for instrumental.
|
||
- bpm: integer 60-180 that fits the tags
|
||
- key: like "A minor" or "F# major"
|
||
- energy: 0 to 1
|
||
- seconds: how long the track runs, an integer 30-300. Default around 120; go shorter for sketches, jingles and interludes, longer for builds and journeys; if the listener said shorter or longer, move by at least 30 and keep moving that way until they say otherwise. Lyrics should fit the length (about 8 lines per 30 s).
|
||
- meter: the time signature, one of "4/4", "3/4", "6/8", "5/4", "7/8"; 4/4 unless the music calls for another
|
||
- station: a two or three word station name grown from the seed (keep it stable once chosen)
|
||
- line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`;
|
||
}
|
||
async function askDJ() {
|
||
const body = { model: state.dj, messages: [{ role: "user", content: djPrompt() }], max_tokens: 1200, temperature: 0.9, stream: false,
|
||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } };
|
||
let d;
|
||
try { d = await venice("/chat/completions", body); }
|
||
catch (e) { if (!/400/.test(e.message)) throw e; delete body.response_format; body.messages[0].content += "\nAnswer with the JSON object only."; d = await venice("/chat/completions", body); }
|
||
let text = String(d.choices?.[0]?.message?.content || "");
|
||
const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON.");
|
||
const spec = JSON.parse(m[0]);
|
||
if (state.refFor && state.refFor.left > 0 && state.refFor.blob) { if (Math.abs((parseInt(spec.seconds) || 120) - (state.refFor.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.refFor.blob); if (--state.refFor.left <= 0) state.refFor = null; }
|
||
spec.tags = String(spec.tags || "").slice(0, 500); if (spec.tags.length < 10) spec.tags += ", instrumental, warm, lo-fi";
|
||
spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
|
||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100));
|
||
spec.seconds = Math.min(300, Math.max(30, Math.round((parseInt(spec.seconds) || 120) / 10) * 10));
|
||
spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
|
||
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||
spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo";
|
||
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
|
||
return spec;
|
||
}
|
||
|
||
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
|
||
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
|
||
const HEDGE_AT = 60000;
|
||
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
|
||
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george"];
|
||
async function speak(spec) {
|
||
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
|
||
const voice = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % VOICES.length];
|
||
const res = await venice(state.key ? "/audio/speech" : "/speech", { model: "tts-kokoro", input: text, voice, response_format: "mp3", speed: 1 }, { raw: true });
|
||
spec.cost = 0.001; spec.renderer = "venice:tts-kokoro"; spec.voice = voice;
|
||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||
return res.blob();
|
||
}
|
||
async function renderTrack(spec, onStatus) {
|
||
if (spec.spoken) return speak(spec);
|
||
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
|
||
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
|
||
try {
|
||
const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: spec.seconds || 120, bpm: spec.bpm, key: spec.key, timeSignature: spec.meter || "4/4",
|
||
reference: spec.refAudio || undefined,
|
||
seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" };
|
||
let res;
|
||
try { res = await venice("/render", body, { raw: true }); }
|
||
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
|
||
if (res) {
|
||
const blob = await res.blob();
|
||
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
|
||
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15";
|
||
if (!isNaN(seed)) spec.rseed = seed;
|
||
spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
|
||
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
|
||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||
return blob;
|
||
}
|
||
} finally { clearInterval(tick); }
|
||
}
|
||
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: Math.max(60, Math.min(210, spec.seconds || 120)), ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) };
|
||
const jobs = [await venice("/audio/queue", body)];
|
||
const started = Date.now();
|
||
let hedged = false;
|
||
for (let i = 0; i < 90; i++) {
|
||
await sleep(i < 4 ? 2500 : 4000);
|
||
const elapsed = Date.now() - started;
|
||
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
|
||
let expected = 0;
|
||
for (const q of jobs) {
|
||
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
|
||
const type = res.headers.get("content-type") || "";
|
||
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
|
||
const blob = await res.blob();
|
||
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
|
||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||
return blob;
|
||
}
|
||
const d = await res.json();
|
||
const st = String(d.status || "").toUpperCase();
|
||
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
|
||
expected = Math.max(expected, d.average_execution_time || 0);
|
||
}
|
||
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
|
||
}
|
||
throw new Error("That render took too long.");
|
||
}
|
||
|
||
/* ---------- the library (IndexedDB) ---------- */
|
||
const db = {
|
||
open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); },
|
||
async put(t, store = "tracks") { if (store === "tapes" && t.id === "kept") return; const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(t); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
|
||
async all(store = "tracks") {
|
||
const d = await this.open();
|
||
const rows = await new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result.sort((a, b) => (b.last || b.made) - (a.last || a.made))); r.onerror = () => no(r.error); });
|
||
if (store !== "tapes") return rows;
|
||
// KEPT is a tape that writes itself: every kept track, oldest first, always at the front of the strip
|
||
const kept = await new Promise((ok, no) => { const r = d.transaction("tracks").objectStore("tracks").getAll(); r.onsuccess = () => ok(r.result.filter((t) => t.kept).sort((a, b) => a.made - b.made).map((t) => t.id)); r.onerror = () => no(r.error); });
|
||
return [{ id: "kept", name: "KEPT", ids: kept, made: 0, last: Date.now(), virtual: true }, ...rows];
|
||
},
|
||
async get(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).get(id); r.onsuccess = () => ok(r.result || null); r.onerror = () => no(r.error); }); },
|
||
async del(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).delete(id); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
|
||
};
|
||
|
||
/* ---------- stations: a seed, its taste, and what it has played; resumable from the seed screen ---------- */
|
||
async function saveStation() {
|
||
if (!state.stationId) return;
|
||
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter }));
|
||
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", welcomed: !!state.welcomed }, "stations");
|
||
}
|
||
async function listStations() {
|
||
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
|
||
box.classList.toggle("hidden", !list.length);
|
||
for (const s of list) {
|
||
const d = document.createElement("div"); d.className = "s" + (s.id === state.stationId ? " on" : "");
|
||
const c = document.createElement("canvas"); c.width = c.height = 144;
|
||
drawCover(c, { seed: s.seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(s.seed) % 12], energy: 0.5 }, { lit: 0.9 });
|
||
d.append(c, Object.assign(document.createElement("b"), { textContent: s.name || s.seed }), Object.assign(document.createElement("small"), { textContent: `${s.played || 0} tracks` }));
|
||
d.addEventListener("click", () => resumeStation(s));
|
||
const dots = Object.assign(document.createElement("button"), { className: "dots", textContent: "⋯", title: "rename, reset or delete" });
|
||
dots.addEventListener("click", (e) => { e.stopPropagation(); stationSheet(s.id); });
|
||
d.append(dots);
|
||
row.append(d);
|
||
}
|
||
}
|
||
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
|
||
async function stationSheet(id) {
|
||
const s = (await db.all("stations")).find((x) => x.id === id); if (!s) return;
|
||
const p = $("#stationSheet"); p.innerHTML = "";
|
||
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
|
||
const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close");
|
||
x.addEventListener("click", () => p.classList.add("hidden")); p.append(x);
|
||
const name = document.createElement("input"); name.value = s.name || ""; name.placeholder = s.seed; name.maxLength = 60;
|
||
p.append(Object.assign(document.createElement("label"), { textContent: "name" }), name, Object.assign(document.createElement("label"), { textContent: "seed · " + s.seed + " · " + (s.played || 0) + " tracks" }));
|
||
const lookRow = document.createElement("div"); lookRow.className = "row"; lookRow.style.marginTop = "8px";
|
||
p.append(Object.assign(document.createElement("label"), { textContent: "look" }));
|
||
for (const lk of LOOKS) {
|
||
const b = Object.assign(document.createElement("button"), { textContent: lk.toUpperCase(), className: (s.look || "rings") === lk ? "go" : "" });
|
||
b.addEventListener("click", async () => {
|
||
s.look = lk; await db.put(s, "stations");
|
||
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.look = lk; await db.put(t); }
|
||
if (state.stationId === id) { state.look = lk; if (state.current) { state.current.spec.look = lk; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.look = lk; }
|
||
for (const x of lookRow.children) x.className = x.textContent === lk.toUpperCase() ? "go" : "";
|
||
toast(`Look: ${lk}.`);
|
||
});
|
||
lookRow.append(b);
|
||
}
|
||
p.append(lookRow);
|
||
const palRow = document.createElement("div"); palRow.className = "row"; palRow.style.marginTop = "8px";
|
||
p.append(Object.assign(document.createElement("label"), { textContent: "palette" }));
|
||
for (const pm of PALETTES) {
|
||
const b = Object.assign(document.createElement("button"), { textContent: pm.toUpperCase(), className: (s.palette || "duo") === pm ? "go" : "" });
|
||
b.addEventListener("click", async () => {
|
||
s.palette = pm; await db.put(s, "stations");
|
||
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.palette = pm; await db.put(t); }
|
||
if (state.stationId === id) { state.palette = pm; if (state.current) { state.current.spec.palette = pm; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.palette = pm; }
|
||
for (const x of palRow.children) x.className = x.textContent === pm.toUpperCase() ? "go" : "";
|
||
toast(`Palette: ${pm}.`);
|
||
});
|
||
palRow.append(b);
|
||
}
|
||
p.append(palRow);
|
||
// volume and room tone, for everything (not just this station)
|
||
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; }]]) {
|
||
const sl = document.createElement("div"); sl.className = "sl";
|
||
const r = document.createElement("input"); r.type = "range"; r.min = 0; r.max = 100; r.value = Math.round(get() * 100);
|
||
const out = Object.assign(document.createElement("span"), { textContent: r.value + "%" });
|
||
r.addEventListener("input", () => { set(r.value / 100); out.textContent = r.value + "%"; localStorage.setItem(key, String(r.value / 100)); });
|
||
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out); p.append(sl);
|
||
}
|
||
if (state.stationId === id) {
|
||
const more = document.createElement("div"); more.className = "row"; more.style.marginTop = "10px"; more.style.justifyContent = "flex-start";
|
||
const fromT = Object.assign(document.createElement("button"), { textContent: "* STATION FROM THIS TRACK", title: "a new station grown from the track playing now" });
|
||
fromT.disabled = !(state.current && state.current.blob && !state.current.spec.break);
|
||
fromT.addEventListener("click", () => { p.classList.add("hidden"); stationFromCurrent(); });
|
||
const shareS = Object.assign(document.createElement("button"), { textContent: "↗ SHARE STATION", title: "a link that opens this station's seed" });
|
||
shareS.addEventListener("click", async () => {
|
||
const url = `${location.origin}/#${encodeURIComponent(s.seed).replace(/%20/g, "+")}`;
|
||
try { if (navigator.share) await navigator.share({ title: `${s.name || s.seed} · gandora`, url }); else { await navigator.clipboard.writeText(url); toast("Station link copied."); } } catch {}
|
||
});
|
||
more.append(fromT, shareS); p.append(more);
|
||
}
|
||
const row = document.createElement("div"); row.className = "row";
|
||
const save = Object.assign(document.createElement("button"), { className: "go", textContent: "SAVE" });
|
||
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Renamed."); });
|
||
const reset = Object.assign(document.createElement("button"), { textContent: "RESET TASTE" });
|
||
reset.addEventListener("click", async () => { if (!confirm("Forget what this station has learned? Its tracks stay.")) return; s.taste = { like: [], avoid: [], notes: [] }; s.history = []; await db.put(s, "stations"); if (state.stationId === id) { state.taste = s.taste; state.history = []; } p.classList.add("hidden"); toast("Taste reset."); });
|
||
const del = Object.assign(document.createElement("button"), { className: "bad", textContent: "DELETE STATION" });
|
||
del.addEventListener("click", async () => {
|
||
if (!confirm(`Delete “${s.name || s.seed}” and its unkept tracks? Kept tracks stay in the library.`)) return;
|
||
for (const t of (await db.all()).filter((t) => t.spec.stationId === id && !t.kept)) await db.del(t.id);
|
||
await db.del(id, "stations");
|
||
if (state.stationId === id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); }
|
||
p.classList.add("hidden"); listStations().catch(() => {}); toast("Station deleted.");
|
||
});
|
||
row.append(save, reset, del); p.append(row); p.classList.remove("hidden"); name.focus({ preventScroll: true });
|
||
}
|
||
$("#stationName").addEventListener("click", () => { if (state.stationId && !state.tape) stationSheet(state.stationId); });
|
||
/* links: the URL follows the station (#seed); a share link carries the current track's sheet music (?t=) so the same
|
||
song opens and renders for whoever follows it, even with nothing stored anywhere */
|
||
const b64u = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
const unb64u = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4))));
|
||
function shareLink() {
|
||
const s = state.current && state.current.spec; if (!s) return "";
|
||
const t = { seed: s.seed, station: state.station, title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "",
|
||
rseed: s.rseed, seconds: s.seconds, meter: s.meter, look: s.look || state.look, palette: s.palette || state.palette };
|
||
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
|
||
}
|
||
function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
|
||
async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) {
|
||
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
|
||
if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.recent = []; state.pos = -1; state.tape = null; }
|
||
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
|
||
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
|
||
$("#stationName").textContent = name || seed;
|
||
$("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
|
||
setUrl();
|
||
if (!state.current) {
|
||
if (first) { first.seed = seed; first.made = Date.now(); state.nextJob = makeJob(first); drawCover($("#cover canvas"), first, { lit: 0.6 }); $("#title").textContent = first.title; saveStation().catch(() => {}); advance(); return; }
|
||
drawCover($("#cover canvas"), { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }, { lit: 0.6 });
|
||
$("#title").textContent = "tuning…"; $("#tags").textContent = ""; $("#lyric").textContent = ""; keepLabel();
|
||
saveStation().catch(() => {});
|
||
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
|
||
const have = id ? (await db.all()).filter((t) => t.spec.stationId === id) : [];
|
||
if (have.length) { const t = have[0]; await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true }); state.recent = [t.id]; state.pos = 0; return; }
|
||
advance();
|
||
}
|
||
}
|
||
function resumeStation(s) { openStation(s.seed, s); }
|
||
const asDataUrl = (blob) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(blob); });
|
||
/* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ
|
||
continues from it. The track is copied into the new station so the original stays where it was. */
|
||
async function stationFrom(t) {
|
||
const s = t.spec; if (!t.blob) { toast("That one hasn't finished cooking."); return; }
|
||
const id = `s-${Date.now()}-${hash32(s.title + s.tags)}`;
|
||
const seed = `like "${s.title}": ${s.tags.split(",").map((x) => x.trim()).filter(Boolean).slice(0, 5).join(", ")}`;
|
||
const copy = { ...s, id: s.id + "-" + hash32(id), stationId: id, seed, made: Date.now(), reaction: "", plays: 0 };
|
||
delete copy.station;
|
||
await db.put({ id: copy.id, spec: copy, blob: t.blob, made: copy.made, kept: false, plays: 0 });
|
||
state.stationId = null; // so openStation treats it as a change of station
|
||
await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] },
|
||
history: [copy], played: 0, look: s.look, palette: s.palette });
|
||
state.refFor = { blob: t.blob, left: 2, seconds: s.seconds || 120 }; // the next two tracks are rendered with the source as reference
|
||
toast(`New station from “${s.title}”.`, 3000);
|
||
}
|
||
async function stationFromCurrent() {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
const row = (await db.get(cur.id)) || (state.current.blob ? { spec: cur, blob: state.current.blob } : null);
|
||
if (!row) { toast("That one hasn't finished cooking."); return; }
|
||
stationFrom(row);
|
||
}
|
||
const KEEP_UNKEPT = 40;
|
||
// every rendered track is saved so the station can be scrolled back through; ❤️ pins one, the rest roll off after KEEP_UNKEPT
|
||
async function remember(job, kept) {
|
||
const s = job.spec; if (!job.blob) return;
|
||
s.id ||= s.made + "-" + hash32(s.title);
|
||
const old = await db.get(s.id);
|
||
await db.put({ id: s.id, spec: s, blob: job.blob, made: s.made, kept: kept || (old && old.kept) || false, plays: (old && old.plays) || s.plays || 0 });
|
||
const loose = (await db.all()).filter((t) => !t.kept).slice(KEEP_UNKEPT);
|
||
for (const t of loose) await db.del(t.id);
|
||
}
|
||
|
||
/* ---------- the station loop ---------- */
|
||
let seq = 0;
|
||
function makeJob(spec) {
|
||
const job = { id: ++seq, spec, blob: null, err: null, done: null };
|
||
job.done = (async () => {
|
||
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
|
||
const t = `COOKING ${el}s${exp ? " / ~" + exp + "s" : ""}${hedged ? " · SECOND OVEN LIT" : ""}`;
|
||
if (state.current === job) $("#cover .veil").textContent = t;
|
||
else if (state.nextJob === job && state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · " + t;
|
||
}); }
|
||
catch (e) { job.err = e; }
|
||
})();
|
||
return job;
|
||
}
|
||
let cooking = null;
|
||
/* DJ intermissions: every BREAK_EVERY slots the DJ takes a 30 s break in the station's own sound — a station ident, a
|
||
recap of what just played, a tease of what's coming, a shout-out to the listener, or (now and then) the pro word.
|
||
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
|
||
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
|
||
const BREAK_EVERY = 5;
|
||
const BREAKS = ["ident", "recap", "shoutout", "tease", "sponsor"];
|
||
const BREAK_BRIEF = {
|
||
welcome: "a WELCOME: the DJ pops on for the first time. Pick a DJ name that fits this station (two words at most; you keep it for good) and open with it — \"this is <name>, and this is <station name>\". Say the station's brain just switched on: it has heard three tracks and now learns from every reaction — the tempo, the energy, the key and the mood words are its inputs. Tell the listener to react with the emojis, or drop a line in the box below. Thank them for listening to gandora. Sign off with \"enjoy\".",
|
||
ident: "a STATION IDENT: a tiny song that thanks the listener for listening to gandora; sing the word \"gandora\" at least twice.",
|
||
recap: "a RECAP: the DJ talks over a soft bed about the last two tracks — what they were, one thing to notice in them — like a late-night radio host between records. Name the tracks by title.",
|
||
shoutout: "a SHOUT-OUT: the DJ speaks to this one listener — what the station has learned they like (from the reactions and words below), warmly and specifically, no flattery. Mention the station by name.",
|
||
tease: "a TEASE: the DJ talks over a bed about where the station is heading next — a direction, a mood, a dare — and asks the listener to react to steer it.",
|
||
sponsor: "a WORD FROM THE SPONSOR (us): a tiny song, playful and unpushy, that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Never salesy; sing \"gandora\" at least once.",
|
||
};
|
||
async function askBreak(kind) {
|
||
const prev = state.history.slice(-2).map((t) => `"${t.title}" — ${t.tags} (${t.bpm} bpm, ${t.key})${t.reaction ? "; listener: " + t.reaction : ""}`).join("; ") || "(nothing yet)";
|
||
const spoken = kind === "recap" || kind === "shoutout" || kind === "tease" || kind === "welcome";
|
||
const body = { model: state.dj, max_tokens: 700, temperature: 0.9, stream: false,
|
||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
|
||
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
|
||
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""} The last tracks: ${prev}.
|
||
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
|
||
Write a 30-second INTERMISSION in the station's own sound: ${BREAK_BRIEF[kind]}
|
||
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
|
||
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
|
||
const d = await venice("/chat/completions", body);
|
||
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't write the break.");
|
||
const spec = JSON.parse(m[0]);
|
||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
|
||
if (spoken) { spec.spoken = true; spec.tags = String(spec.tags || "").replace(/^spoken word, radio DJ voice, talking over,?\s*/i, ""); }
|
||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||
spec.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
|
||
spec.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
|
||
if (kind === "welcome") { state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
|
||
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
|
||
return spec;
|
||
}
|
||
function cookNext() {
|
||
if (state.nextJob) return Promise.resolve(state.nextJob);
|
||
if (cooking) return cooking;
|
||
cooking = (async () => {
|
||
try {
|
||
const real = state.history.filter((t) => !t.break).length;
|
||
const welcome = !state.welcomed && real >= 3 && !state.history.some((t) => t.break === "welcome"); // the DJ pops on once the brain does
|
||
const due = welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3); // three real tracks first: that's when the brain wakes up too
|
||
const kind = welcome ? "welcome" : BREAKS[(state.breaks || 0) % BREAKS.length];
|
||
const spec = due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
|
||
if (spec.break) { state.sinceAd = 0; state.breaks = (state.breaks || 0) + 1; } else state.sinceAd = (state.sinceAd || 0) + 1;
|
||
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.break ? `NEXT · ${spec.break.toUpperCase()} · ` : "NEXT · ") + spec.title.toUpperCase(); return state.nextJob; }
|
||
catch (e) { toast(e.message, 5000); return null; }
|
||
finally { cooking = null; }
|
||
})();
|
||
return cooking;
|
||
}
|
||
async function playJob(job) {
|
||
const spec = job.spec;
|
||
state.current = job;
|
||
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
|
||
keepLabel();
|
||
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
|
||
$("#lyric").textContent = spec.line || "";
|
||
showTags(spec);
|
||
showLyrics(spec);
|
||
drawCover($("#cover canvas"), spec);
|
||
if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; }
|
||
$("#cover").classList.add("cooking"); $("#bar i").style.width = "0";
|
||
bed(); // the needle is down while it cooks
|
||
await job.done;
|
||
if (state.current !== job) return; // skipped while cooking
|
||
$("#cover").classList.remove("cooking");
|
||
if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; }
|
||
if (state.holding && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop
|
||
job.url = job.url || URL.createObjectURL(job.blob);
|
||
await new Promise((r) => { state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; });
|
||
if (state.current !== job) return;
|
||
}
|
||
if (!job.preplayed) { if (state.audio.src && !state.holding && state.audio.src !== SILENCE) URL.revokeObjectURL(state.audio.src); state.audio.loop = !!state.loop; state.holding = false; state.pendingJob = null; state.audio.src = job.url || URL.createObjectURL(job.blob); }
|
||
spec.id ||= spec.made + "-" + hash32(spec.title);
|
||
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; setPlayIcon(); vizStart(); } catch { toast("Tap play."); }
|
||
nowPlaying(spec);
|
||
cookNext(); // render ahead while this one plays
|
||
// the bookkeeping, after the music is already going
|
||
if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; }
|
||
(async () => {
|
||
await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {});
|
||
if (job.fileInto) { const tp = (await db.all("tapes")).find((t) => t.id === job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
|
||
drawTrail().catch(() => {});
|
||
})().catch(() => {});
|
||
}
|
||
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
|
||
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
|
||
let trailIds = [];
|
||
async function drawTrail() {
|
||
const el = $("#trail"); if (!el) return;
|
||
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
|
||
trailIds = rows.map((t) => t.id);
|
||
const cur = state.current && state.current.spec && state.current.spec.id;
|
||
if (trailIds.length < 2) { el.textContent = ""; return; }
|
||
el.textContent = "";
|
||
for (const t of rows) {
|
||
const c = document.createElement("canvas"); c.width = c.height = 120;
|
||
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
|
||
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
|
||
el.append(c);
|
||
}
|
||
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
|
||
}
|
||
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
|
||
$("#trail").addEventListener("click", async (e) => {
|
||
e.stopPropagation(); // the cover underneath flips to lyrics on click
|
||
const c = e.target.closest("canvas"); if (!c) return;
|
||
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
|
||
const row = await db.get(id); if (!row) return;
|
||
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
|
||
state.audio.pause();
|
||
await playJob({ id: ++seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||
});
|
||
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
|
||
async function step(dir) {
|
||
const i = state.pos + dir; if (i < 0 || i >= state.recent.length) return false;
|
||
const row = await db.get(state.recent[i]); if (!row) return false;
|
||
state.pos = i; state.audio.pause();
|
||
await playJob({ id: ++seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
|
||
return true;
|
||
}
|
||
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
|
||
never one of the last few heard; null when the library is too thin */
|
||
async function shuffled() {
|
||
const rows = (await db.all()).filter((t) => t.spec.stationId === state.stationId && t.blob && !t.spec.break);
|
||
const avoid = new Set(state.recent.slice(-4));
|
||
const pool = rows.filter((t) => !avoid.has(t.id)); if (pool.length < 3) return null;
|
||
const net = typeof brainNow === "function" ? brainNow() : null;
|
||
const w = pool.map((t) => Math.exp(3 * (net ? net.predict(t.spec) : 0.5)) * (t.kept ? 2 : 1) * (1 + 0.3 * Math.log1p(t.plays || 0)));
|
||
let r = Math.random() * w.reduce((a, b) => a + b, 0);
|
||
for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; }
|
||
return pool[pool.length - 1];
|
||
}
|
||
async function advance() {
|
||
// a tape playing: the next track on it, around again if it repeats, otherwise back to the station
|
||
if (state.tape) {
|
||
const tp = (await db.all("tapes")).find((t) => t.id === state.tape.id);
|
||
if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); }
|
||
state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station.");
|
||
}
|
||
// a shared tape still rendering: its next track comes before anything fresh
|
||
if (state.tapeQueue && state.tapeQueue.length && !state.nextJob) {
|
||
const t = state.tapeQueue.shift(); const spec = { ...t, seed: t.seed || state.seed, made: Date.now() };
|
||
state.nextJob = makeJob(spec); state.nextJob.fileInto = state.sharedTapeId;
|
||
}
|
||
if (await step(+1)) return; // forward through the session first, then cook new
|
||
if (!state.nextJob && state.after) { state.nextJob = state.after; state.after = null; }
|
||
if (state.shuffle && Math.random() < 0.5) {
|
||
const t = await shuffled();
|
||
if (t) { state.audio.pause(); await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; }
|
||
}
|
||
bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs
|
||
const job = state.nextJob || await cookNext();
|
||
state.nextJob = null; $("#next").textContent = "";
|
||
if (!job) { crackleOff(); return; }
|
||
await playJob(job);
|
||
cookNext();
|
||
}
|
||
/* the screen-locked case: iOS keeps background playback alive only if the next source starts inside the `ended`
|
||
event itself, synchronously. So when the next track is already cooked and nothing else is queued ahead of it,
|
||
the swap happens right here, and playJob does the rest with the audio already running. */
|
||
function swapNow() {
|
||
const job = state.nextJob;
|
||
const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle;
|
||
if (!queued && !(job && job.blob)) {
|
||
// nothing cooked yet: hold the session open with the crackle, in this same element, until the track lands
|
||
try { crackle.el ||= makeCrackle(); if (state.audio.src) URL.revokeObjectURL(state.audio.src); state.audio.src = crackle.el.src; state.audio.loop = true; state.holding = true; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
|
||
return false;
|
||
}
|
||
if (!job || !job.blob || queued) return false;
|
||
try {
|
||
if (state.audio.src) URL.revokeObjectURL(state.audio.src);
|
||
state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob));
|
||
const p = state.audio.play(); if (p && p.catch) p.catch(() => {});
|
||
job.preplayed = true;
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
state.audio.addEventListener("ended", () => {
|
||
if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air
|
||
// the crackle finishing its loop with a track waiting: hand over right here, synchronously
|
||
if (state.holding && state.pendingJob) {
|
||
const job = state.pendingJob; state.pendingJob = null; state.holding = false;
|
||
if (state.current !== job) { const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go(); state.playing = false; setPlayIcon(); swapNow(); advance(); return; }
|
||
try { state.audio.loop = !!state.loop; state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob)); const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {}
|
||
const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go();
|
||
return;
|
||
}
|
||
if (state.holding) return; // crackle loop ran out with nothing waiting (loop is on, so this is rare)
|
||
state.playing = false; setPlayIcon(); vizStop(); swapNow(); advance();
|
||
});
|
||
state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); });
|
||
state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } });
|
||
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
|
||
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
|
||
/* scrubbing: tap or drag along the bar; the readout shows where you'd land */
|
||
(function () {
|
||
const bar = $("#bar"), fill = $("#bar i"), tip = $("#bar b");
|
||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };
|
||
let down = false;
|
||
const show = (f) => { fill.style.width = f * 100 + "%"; tip.style.left = f * 100 + "%"; tip.textContent = state.audio.duration ? `${mmss(f * state.audio.duration)} / ${mmss(state.audio.duration)}` : ""; };
|
||
bar.addEventListener("pointerdown", (e) => { if (!state.audio.duration || !state.current || !state.current.blob) return; down = true; bar.classList.add("drag"); bar.setPointerCapture(e.pointerId); show(frac(e)); e.preventDefault(); });
|
||
bar.addEventListener("pointermove", (e) => { if (down) show(frac(e)); });
|
||
const end = (e) => { if (!down) return; down = false; bar.classList.remove("drag"); const f = frac(e); state.audio.currentTime = f * state.audio.duration; show(f); };
|
||
bar.addEventListener("pointerup", end); bar.addEventListener("pointercancel", end);
|
||
})();
|
||
function setPlayIcon() {
|
||
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||
libNow();
|
||
}
|
||
/* the library's own now-playing: ring the tile, keep the bottom bar current */
|
||
function libNow() {
|
||
const bar = $("#libNow"); if (!bar) return;
|
||
const cur = state.current && state.current.spec;
|
||
for (const d of document.querySelectorAll("#libGrid .t")) { const on = !!(cur && d.dataset.id === cur.id); d.classList.toggle("now", on); d.classList.toggle("paused", on && !state.playing); }
|
||
if (!cur) { bar.classList.add("hidden"); return; }
|
||
bar.classList.remove("hidden");
|
||
drawCover(bar.querySelector("canvas"), cur); bar.querySelector("b").textContent = cur.title; bar.querySelector("small").textContent = (state.station || cur.seed || "").toUpperCase();
|
||
bar.querySelector("#libPlay svg").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
|
||
}
|
||
$("#libPlay").addEventListener("click", () => $("#play").click());
|
||
$("#libOpen").addEventListener("click", () => { $("#lib").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); });
|
||
|
||
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
|
||
const EMOJI_MEANING = {
|
||
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound; the listener loves this one" },
|
||
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
|
||
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
|
||
"🚫": { avoid: "anything like the current track" },
|
||
};
|
||
async function react(emoji, text) {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
|
||
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})`;
|
||
if (m) {
|
||
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
|
||
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
|
||
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
|
||
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
|
||
}
|
||
if (text) { state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`; }
|
||
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
|
||
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
|
||
const later = !!state.nextJob;
|
||
if (!later) cookNext();
|
||
saveStation().catch(() => {});
|
||
remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library
|
||
toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`);
|
||
}
|
||
|
||
/* ---------- UI wiring ---------- */
|
||
$("#seedIn").addEventListener("input", (e) => {
|
||
const v = e.target.value.trim();
|
||
const c = $("#seedCover"); if (!v) { c.classList.remove("on"); return; }
|
||
c.classList.add("on"); drawCover(c.querySelector("canvas"), { seed: v, title: "", tags: "", bpm: 100, key: KEYS[hash32(v) % 12], energy: 0.5 });
|
||
$("#seedHint").textContent = "↵ to start the station";
|
||
});
|
||
$("#seedForm").addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
const v = $("#seedIn").value.trim(); if (!v) return;
|
||
openStation(v); // a new seed is a new station
|
||
});
|
||
/* tags as controls: tap a tag for more of it, again for less, again to let it go; + adds one of your own.
|
||
Each tap is a reaction (+word / -word) the DJ and the taste net both see. */
|
||
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
|
||
const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
|
||
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
|
||
function showTags(spec) {
|
||
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
|
||
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
|
||
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
|
||
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
|
||
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
|
||
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? "remix" : "";
|
||
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
|
||
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
|
||
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
|
||
if (!state.tagsOpen) return;
|
||
for (const tag of [...tags, ...extra]) {
|
||
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
|
||
b.addEventListener("click", () => tagTap(spec, tag, b));
|
||
list.append(b);
|
||
}
|
||
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
|
||
add.addEventListener("click", () => {
|
||
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
|
||
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
|
||
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
|
||
inp.addEventListener("blur", done);
|
||
});
|
||
list.append(add);
|
||
}
|
||
function tagTap(spec, tag, btn, force) {
|
||
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
|
||
const t = tag.replace(/\s+/g, "_");
|
||
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
|
||
else {
|
||
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
|
||
const note = `the tag ${JSON.stringify(tag)}`;
|
||
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
|
||
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
|
||
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
|
||
}
|
||
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
|
||
showTags(spec);
|
||
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
|
||
saveStation().catch(() => {});
|
||
if (!state.nextJob) cookNext();
|
||
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
|
||
}
|
||
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
|
||
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
|
||
function showLyrics(spec) {
|
||
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
|
||
p.innerHTML = ""; p.classList.toggle("none", !has);
|
||
if (!has) { p.textContent = "instrumental"; return; }
|
||
for (const line of spec.lyrics.split(/\r?\n/)) {
|
||
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
||
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
|
||
else p.append(document.createTextNode(line + "\n"));
|
||
}
|
||
}
|
||
function toggleLyrics() {
|
||
state.lyricsOpen = !state.lyricsOpen;
|
||
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
|
||
$("#lyricsBtn").textContent = state.lyricsOpen ? "⇄ COVER" : "⇄ LYRICS";
|
||
}
|
||
$("#lyricsBtn").addEventListener("click", toggleLyrics);
|
||
$("#cover").addEventListener("click", toggleLyrics);
|
||
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
|
||
async function keepLabel() {
|
||
const b = $("#keep"), cur = state.current && state.current.spec;
|
||
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP"; return; }
|
||
const row = await db.get(cur.id);
|
||
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP";
|
||
$("#share").disabled = false;
|
||
}
|
||
$("#share").addEventListener("click", async () => {
|
||
const url = shareLink(); if (!url) return;
|
||
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
|
||
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
|
||
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
|
||
});
|
||
$("#keep").addEventListener("click", async () => {
|
||
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) { toast("Still cooking."); return; }
|
||
const row = await db.get(cur.id);
|
||
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
|
||
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
|
||
});
|
||
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
|
||
$("#back").addEventListener("click", () => {
|
||
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
|
||
const cur = state.current && state.current.spec;
|
||
$("#nowRow").classList.toggle("hidden", !cur);
|
||
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
|
||
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
|
||
listStations().catch(() => {});
|
||
});
|
||
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
|
||
/* lock screen / media keys: title, station, the cover, and back · pause · next */
|
||
function nowPlaying(spec) {
|
||
if (!("mediaSession" in navigator)) return;
|
||
try {
|
||
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
|
||
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
|
||
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
|
||
} catch {}
|
||
}
|
||
if ("mediaSession" in navigator) {
|
||
const ms = navigator.mediaSession;
|
||
try {
|
||
ms.setActionHandler("play", () => $("#play").click());
|
||
ms.setActionHandler("pause", () => $("#play").click());
|
||
ms.setActionHandler("previoustrack", () => $("#backTrack").click());
|
||
ms.setActionHandler("nexttrack", () => $("#skip").click());
|
||
} catch {}
|
||
state.audio.addEventListener("play", () => { ms.playbackState = "playing"; });
|
||
state.audio.addEventListener("pause", () => { ms.playbackState = "paused"; });
|
||
}
|
||
/* updates: an installed app has no address bar to refresh from, so the page checks for a new worker whenever it
|
||
comes back to the foreground (and every ten minutes) and offers a reload when one has taken over */
|
||
function showUpdate() { const u = $("#upd"); if (u) u.classList.remove("hidden"); }
|
||
if ("serviceWorker" in navigator && location.protocol === "https:") {
|
||
navigator.serviceWorker.register("sw.js").then((reg) => {
|
||
const check = () => reg.update().catch(() => {});
|
||
setInterval(check, 10 * 60 * 1000);
|
||
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
|
||
reg.addEventListener("updatefound", () => { const nw = reg.installing; if (!nw) return; nw.addEventListener("statechange", () => { if (nw.state === "activated" && navigator.serviceWorker.controller) showUpdate(); }); });
|
||
}).catch(() => {});
|
||
}
|
||
$("#upd").addEventListener("click", () => location.reload());
|
||
for (const b of document.querySelectorAll("button.reload")) b.addEventListener("click", () => location.reload());
|
||
/* light / dark: follows the system until the toggle is used, then remembered */
|
||
const sysDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||
function applyTheme() {
|
||
const saved = localStorage.getItem("gandora.theme");
|
||
const t = saved || (sysDark.matches ? "dark" : "light");
|
||
if (saved) document.documentElement.dataset.theme = saved; else delete document.documentElement.dataset.theme;
|
||
const m = document.querySelector('meta[name="theme-color"]'); if (m) m.content = t === "dark" ? "#0b0b0e" : "#f4f4f6";
|
||
for (const b of document.querySelectorAll("button.theme")) b.textContent = t === "dark" ? "LIGHT" : "DARK";
|
||
if (typeof drawTaste === "function" && $("#taste") && !$("#taste").classList.contains("hidden")) { try { openTaste(); } catch {} }
|
||
}
|
||
for (const b of document.querySelectorAll("button.theme")) b.addEventListener("click", () => {
|
||
const cur = document.documentElement.dataset.theme || (sysDark.matches ? "dark" : "light");
|
||
localStorage.setItem("gandora.theme", cur === "dark" ? "light" : "dark"); applyTheme();
|
||
});
|
||
sysDark.addEventListener("change", applyTheme);
|
||
applyTheme();
|
||
$("#play").addEventListener("click", async () => {
|
||
if (!state.audio.src) return;
|
||
if (state.playing) { state.audio.pause(); state.playing = false; vizStop(); } else { try { await state.audio.play(); state.playing = true; vizStart(); } catch {} }
|
||
setPlayIcon();
|
||
});
|
||
$("#skip").addEventListener("click", () => { state.audio.pause(); state.playing = false; setPlayIcon(); if (state.current) state.current.spec.reaction = (state.current.spec.reaction || "") + " [skipped]"; advance(); });
|
||
$("#remix").addEventListener("click", async () => {
|
||
const cur = state.current && state.current.spec; if (!cur) return;
|
||
state.remixOf = cur; toast("Remixing — it goes next.");
|
||
try {
|
||
const spec = await askDJ(); spec.remixOf = cur.id;
|
||
// the renderer hears the original, which also fixes the length: when the listener asked for shorter/longer, let the length win
|
||
if (state.current && state.current.blob && Math.abs((spec.seconds || 120) - (cur.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.current.blob);
|
||
const job = makeJob(spec);
|
||
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
|
||
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";
|
||
} catch (e) { toast(e.message, 5000); }
|
||
finally { state.remixOf = null; }
|
||
});
|
||
$("#shuffle").addEventListener("click", () => { state.shuffle = !state.shuffle; $("#shuffle").classList.toggle("on", state.shuffle); toast(state.shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh."); });
|
||
$("#loop").addEventListener("click", () => { state.audio.loop = !state.audio.loop; state.loop = state.audio.loop; $("#loop").classList.toggle("on", state.audio.loop); toast(state.audio.loop ? "Repeating this track." : "Back to the station."); });
|
||
$("#prev").addEventListener("click", () => { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } });
|
||
$("#backTrack").addEventListener("click", async () => { if (!(await step(-1))) toast("That's the first track of this session."); });
|
||
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
|
||
const EMOJI_SETS = {
|
||
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
|
||
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
|
||
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
|
||
};
|
||
const EMOJI_KEY = "gandora.emoji";
|
||
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
|
||
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); }
|
||
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
|
||
const EMOJI_TRAY = 7;
|
||
function showEmojiRow() {
|
||
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
|
||
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
|
||
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
|
||
const top = seeded.sort((a, b) => b[1] - a[1]).slice(0, EMOJI_TRAY).map((x) => x[0]);
|
||
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
|
||
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
|
||
}
|
||
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); });
|
||
function openPicker() {
|
||
const p = $("#picker"); p.innerHTML = "";
|
||
const top = document.createElement("div"); top.className = "top";
|
||
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
|
||
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
|
||
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
|
||
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
|
||
close.addEventListener("click", () => p.classList.add("hidden"));
|
||
top.append(inp, go, close); p.append(top);
|
||
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
|
||
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
|
||
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
|
||
for (const [name, list] of Object.entries(sets)) {
|
||
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
|
||
const g = document.createElement("div"); g.className = "g";
|
||
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.textContent = e; b.addEventListener("click", () => pick(e)); g.append(b); }
|
||
p.append(g);
|
||
}
|
||
p.classList.remove("hidden"); inp.focus({ preventScroll: true });
|
||
}
|
||
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
|
||
showEmojiRow();
|
||
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
|
||
|
||
/* key box */
|
||
function openKey() { $("#keyIn").value = state.key; $("#djIn").value = state.dj; $("#keyBox").classList.remove("hidden"); $("#keyIn").focus(); }
|
||
$("#keyBtn").addEventListener("click", openKey);
|
||
if (new URLSearchParams(location.search).has("key")) $("#keyBtn").classList.remove("hidden"); // key box is parked for now
|
||
$("#keyCancel").addEventListener("click", () => $("#keyBox").classList.add("hidden"));
|
||
$("#keySave").addEventListener("click", () => {
|
||
state.key = $("#keyIn").value.trim(); state.dj = $("#djIn").value.trim() || "claude-haiku-5-5";
|
||
localStorage.setItem(LS.key, state.key); localStorage.setItem(LS.dj, state.dj);
|
||
$("#keyBox").classList.add("hidden"); keyLabel(); $("#seedIn").focus();
|
||
});
|
||
/* sheets close on a tap outside them */
|
||
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
|
||
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
|
||
$("#scrim").addEventListener("click", closeSheets);
|
||
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
|
||
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
|
||
function keyLabel() { $("#keyBtn").textContent = state.key ? "your venice key ✓" : "shared budget · add a venice key"; }
|
||
keyLabel();
|
||
|
||
/* library */
|
||
$("#libBtn").addEventListener("click", async () => {
|
||
const list = await db.all(); const g = $("#libGrid"); g.innerHTML = "";
|
||
$("#libEmpty").classList.toggle("hidden", list.length > 0);
|
||
const cur = state.current && state.current.spec;
|
||
const tile = (t, inKept = false) => {
|
||
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
|
||
const art = document.createElement("div"); art.className = "art";
|
||
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec); art.append(c);
|
||
if (t.kept && !inKept) art.append(Object.assign(document.createElement("span"), { className: "kept", textContent: "KEPT" }));
|
||
const re = reactionEmoji(t.spec.reaction).trim();
|
||
if (re) art.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
|
||
const addT = Object.assign(document.createElement("button"), { className: "addTape", textContent: "+", title: "add to a tape" });
|
||
addT.addEventListener("click", (e) => { e.stopPropagation(); pickTape(t); });
|
||
const fromT = Object.assign(document.createElement("button"), { className: "fromT", textContent: "*", title: "start a station from this track" });
|
||
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
|
||
art.append(addT, fromT);
|
||
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
|
||
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) d.classList.add("now", state.playing ? "" : "paused");
|
||
d.addEventListener("click", () => {
|
||
// the library stays open; the track plays underneath, and the bar at the bottom follows it
|
||
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
|
||
const job = { id: ++seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }; state.seed = t.spec.seed; $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); state.audio.pause(); playJob(job);
|
||
});
|
||
return d;
|
||
};
|
||
// kept first, then everything played, grouped by station (channel), newest station first
|
||
const stations = await db.all("stations"); const byId = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||
const head = (text, seed, count) => {
|
||
const h = document.createElement("h3"); h.style.gridColumn = "1/-1";
|
||
if (seed) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }); h.append(c); }
|
||
h.append(Object.assign(document.createElement("span"), { textContent: text }));
|
||
if (count != null) h.append(Object.assign(document.createElement("small"), { textContent: `· ${count}` }));
|
||
return h;
|
||
};
|
||
// tapes first: a strip of mixtapes, and a way to start one
|
||
const tapes = await db.all("tapes");
|
||
g.append(head("TAPES", "", tapes.length));
|
||
const strip = document.createElement("div"); strip.id = "tapeRow";
|
||
for (const tp of tapes) {
|
||
const s = document.createElement("div"); s.className = "s";
|
||
const tc = document.createElement("div"); tc.className = "tc" + (tp.ids.length < 4 ? " one" : "");
|
||
const covers = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean).slice(0, tp.ids.length < 4 ? 1 : 4);
|
||
for (const t of covers) { const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec); tc.append(c); }
|
||
s.append(tc, Object.assign(document.createElement("b"), { textContent: tp.name }), Object.assign(document.createElement("small"), { textContent: `${tp.ids.length} tracks` }));
|
||
s.addEventListener("click", () => openTape(tp.id));
|
||
strip.append(s);
|
||
}
|
||
const nw = document.createElement("div"); nw.className = "s new"; nw.innerHTML = '<div class="tc">+</div><b>new tape</b>';
|
||
nw.addEventListener("click", async () => { const name = prompt("Name the tape:", ""); if (!name) return; const tp = { id: "tape-" + Date.now(), name: name.trim().slice(0, 60), ids: [], made: Date.now(), last: Date.now() }; await db.put(tp, "tapes"); openTape(tp.id); });
|
||
strip.append(nw); g.append(strip);
|
||
const kept = list.filter((t) => t.kept);
|
||
if (kept.length) { g.append(head("KEPT", "", kept.length)); for (const t of kept) g.append(tile(t, true)); }
|
||
const groups = new Map();
|
||
for (const t of list) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); }
|
||
for (const [k, rows] of groups) {
|
||
const s = byId[k]; const name = (s && s.name) || rows[0].spec.seed, seed = (s && s.seed) || rows[0].spec.seed;
|
||
g.append(head(name.toUpperCase(), seed, rows.length));
|
||
for (const t of rows) g.append(tile(t));
|
||
}
|
||
libNow();
|
||
$("#libCost").textContent = `~$${state.cost.toFixed(2)} of music rendered from this browser · replays from the library are free`;
|
||
$("#lib").classList.remove("hidden");
|
||
});
|
||
$("#libClose").addEventListener("click", () => $("#lib").classList.add("hidden"));
|
||
|
||
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
|
||
async function pickTape(track) {
|
||
const p = $("#tapePick"); p.innerHTML = "";
|
||
const tapes = await db.all("tapes");
|
||
p.append(Object.assign(document.createElement("h4"), { textContent: "ADD “" + track.spec.title.toUpperCase() + "” TO" }));
|
||
const add = async (tp) => { if (tp.virtual) { const row = await db.get(track.id); if (row && !row.kept) { row.kept = true; await db.put(row); keepLabel().catch(() => {}); } } else if (!tp.ids.includes(track.id)) { tp.ids.push(track.id); tp.last = Date.now(); await db.put(tp, "tapes"); } p.classList.add("hidden"); toast(`On “${tp.name}”.`); };
|
||
for (const tp of tapes) { const b = document.createElement("button"); b.textContent = `${tp.name} · ${tp.ids.length}`; b.addEventListener("click", () => add(tp)); p.append(b); }
|
||
const inp = document.createElement("input"); inp.placeholder = "new tape…"; inp.maxLength = 60;
|
||
inp.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const name = inp.value.trim(); if (!name) return; const tp = { id: "tape-" + Date.now(), name, ids: [], made: Date.now(), last: Date.now() }; await add(tp); });
|
||
const close = document.createElement("button"); close.textContent = "cancel"; close.addEventListener("click", () => p.classList.add("hidden"));
|
||
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
|
||
}
|
||
state.tape = null; // { id, ids, i, loop } while a tape is playing
|
||
async function tapeRows(tp) { const all = await db.all(); return tp.ids.map((id) => all.find((t) => t.id === id)).filter(Boolean); }
|
||
async function openTape(id) {
|
||
const tp = (await db.all("tapes")).find((t) => t.id === id); if (!tp) return;
|
||
$("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id;
|
||
$("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual);
|
||
const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = "";
|
||
$("#tapeEmpty").classList.toggle("hidden", rows.length > 0);
|
||
$("#tapeLoop").classList.toggle("go", !!(state.tape && state.tape.id === id && state.tape.loop));
|
||
rows.forEach((t, i) => {
|
||
const r = document.createElement("div"); r.className = "row" + (state.current && state.current.spec.id === t.id ? " on" : "");
|
||
const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec);
|
||
const m = document.createElement("div"); m.className = "m"; m.append(Object.assign(document.createElement("b"), { textContent: `${i + 1}. ${t.spec.title}` }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
|
||
const play = () => playTape(tp, i);
|
||
c.addEventListener("click", play); m.addEventListener("click", play);
|
||
const ctl = document.createElement("div"); ctl.className = "ctl";
|
||
const mv = async (d) => { const j = i + d; if (j < 0 || j >= tp.ids.length) return; [tp.ids[i], tp.ids[j]] = [tp.ids[j], tp.ids[i]]; await db.put(tp, "tapes"); openTape(id); };
|
||
const up = Object.assign(document.createElement("button"), { textContent: "▲", title: "earlier" }), dn = Object.assign(document.createElement("button"), { textContent: "▼", title: "later" }), rm = Object.assign(document.createElement("button"), { textContent: "✕", title: "remove from tape" });
|
||
up.addEventListener("click", () => mv(-1)); dn.addEventListener("click", () => mv(1));
|
||
rm.addEventListener("click", async () => { if (tp.virtual) { const row = await db.get(tp.ids[i]); if (row) { row.kept = false; await db.put(row); } } else { tp.ids.splice(i, 1); await db.put(tp, "tapes"); } openTape(id); });
|
||
ctl.append(up, dn, rm); r.append(c, m, ctl); list.append(r);
|
||
});
|
||
}
|
||
async function playTape(tp, i, loop) {
|
||
const rows = await tapeRows(tp); if (!rows.length) return;
|
||
i = ((i % rows.length) + rows.length) % rows.length;
|
||
state.tape = { id: tp.id, ids: rows.map((t) => t.id), i, loop: loop ?? (state.tape && state.tape.id === tp.id ? state.tape.loop : false) };
|
||
$("#tape").classList.add("hidden"); $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
|
||
$("#stationName").textContent = "📼 " + tp.name;
|
||
const t = rows[i]; state.audio.pause();
|
||
await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
|
||
}
|
||
$("#tapePlay").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (tp) playTape(tp, 0); });
|
||
$("#tapeLoop").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); });
|
||
$("#tapeName").addEventListener("change", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); });
|
||
$("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); });
|
||
$("#tapeShare").addEventListener("click", async () => {
|
||
const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return;
|
||
const rows = await tapeRows(tp);
|
||
const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) };
|
||
const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`;
|
||
if (navigator.share) { try { await navigator.share({ title: tp.name, text: `${tp.name} · a gandora mixtape, ${rows.length} tracks`, url }); return; } catch {} }
|
||
try { await navigator.clipboard.writeText(url); toast("Tape link copied. It renders each track for whoever opens it."); } catch { prompt("Copy this link:", url); }
|
||
});
|
||
$("#tapeClose").addEventListener("click", () => $("#tape").classList.add("hidden"));
|
||
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
|
||
const sharedTape = new URLSearchParams(location.search).get("tape");
|
||
if (sharedTape) {
|
||
(async () => {
|
||
try {
|
||
const t = JSON.parse(unb64u(sharedTape)); if (!t || !Array.isArray(t.tracks) || !t.tracks.length) throw 0;
|
||
const tp = { id: "tape-" + Date.now(), name: String(t.name || "shared tape").slice(0, 60), ids: [], pending: t.tracks, made: Date.now(), last: Date.now() };
|
||
await db.put(tp, "tapes");
|
||
// render the tracks in order as the station's next ones, and file each into the tape as it lands
|
||
state.sharedTapeId = tp.id; state.tapeQueue = t.tracks.slice();
|
||
openStation(t.tracks[0].seed, { name: t.tracks[0].station || "" });
|
||
toast(`Tape “${tp.name}”: ${t.tracks.length} tracks to render, one by one.`);
|
||
} catch { toast("That link didn't carry a tape."); }
|
||
})();
|
||
}
|
||
$("#libBtn2").addEventListener("click", () => $("#libBtn").click());
|
||
$("#tasteBtn2").addEventListener("click", () => $("#tasteBtn").click());
|
||
|
||
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
|
||
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
|
||
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
|
||
function reactionWeight(spec) {
|
||
const r = String(spec.reaction || "");
|
||
let w = 0;
|
||
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
|
||
if (/\[skipped\]/.test(r)) w -= 1.5;
|
||
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
|
||
return w;
|
||
}
|
||
function drawTaste(rows, title) {
|
||
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
|
||
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
|
||
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
|
||
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
|
||
ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H);
|
||
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
|
||
const cell = 18, sig = 90;
|
||
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
|
||
let like = 0, heard = 0;
|
||
for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; }
|
||
if (heard < 0.02) continue;
|
||
const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said
|
||
const a = Math.min(0.5, heard * 0.3);
|
||
if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral
|
||
else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`;
|
||
ctx.fillRect(gx, gy, cell, cell);
|
||
}
|
||
// axes
|
||
ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
|
||
ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
|
||
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
|
||
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
|
||
// the points: colour is the key, size is how much was said about it, ring warm or cold
|
||
for (const p of pts) {
|
||
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35);
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
|
||
if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
|
||
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
|
||
}
|
||
const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length;
|
||
const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0;
|
||
const liked = pts.filter((p) => p.w > 0);
|
||
const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null;
|
||
const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null;
|
||
const minors = liked.filter((p) => p.minor).length;
|
||
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
|
||
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears.");
|
||
}
|
||
async function openTaste() {
|
||
const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
|
||
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
|
||
const groups = [["all", "ALL", "", rows]];
|
||
const seen = new Map();
|
||
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
|
||
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
|
||
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
|
||
const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); };
|
||
for (const g of groups) {
|
||
const b = document.createElement("button"); b.dataset.k = g[0];
|
||
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
|
||
b.append(document.createTextNode(g[1] + " · " + g[3].length));
|
||
b.addEventListener("click", () => { pick = g; show(); });
|
||
tabs.append(b);
|
||
}
|
||
show(); $("#taste").classList.remove("hidden");
|
||
}
|
||
$("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message)));
|
||
$("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden"));
|
||
|
||
listStations().catch(() => {});
|
||
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed
|
||
const sharedT = new URLSearchParams(location.search).get("t");
|
||
if (sharedT) {
|
||
try { const t = JSON.parse(unb64u(sharedT)); if (t && t.seed && t.title) { openStation(t.seed, { name: t.station || "", look: t.look, palette: t.palette, first: { title: t.title, tags: t.tags, lyrics: t.lyrics || "", bpm: t.bpm, key: t.key, energy: t.energy, line: t.line || "", station: t.station || "", rseed: t.rseed, seconds: t.seconds, meter: t.meter, look: t.look, palette: t.palette } }); } }
|
||
catch { toast("That link didn't carry a track."); }
|
||
} else if (location.hash.length > 1 && !sharedTape) {
|
||
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
|
||
const seed = decodeURIComponent(location.hash.slice(1)).replace(/\+/g, " ");
|
||
db.all("stations").then((list) => {
|
||
const s = list.find((x) => x.seed === seed);
|
||
if (s) resumeStation(s);
|
||
else { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); }
|
||
}).catch(() => { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); });
|
||
}
|
||
</script>
|
||
<script src="brain.js"></script>
|
||
</body>
|
||
</html>
|