RECORDGOD/site/kiosk.html
type-two 988e6437cb feat(shop): per-track Apple previews (lazy iTunes) + jog mini-player + dead-YouTube check
/shop/audio-tracks/{id}: resolves each track's preview from its Apple song id via iTunes lookup
(au), cached in disc_release_track.apple_preview. /shop/audio validates YouTube via oembed (200=live),
caches dead in disc_release_video.dead, returns one live id. release.html + kiosk.html get a per-track
▶ on each tracklist row feeding a mini-player with a jog/seek bar (falls back to a single Listen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:44:17 +10:00

244 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>RecordGod — browse the crates</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
:root{--primary:#ff2e93;--accent:#46d18a;--bg:#0b0b0e;--panel:#16161c;--text:#f4f4f6;--mut:#9a9aa8;--line:#26262e;--font:system-ui}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif;overflow:hidden;user-select:none}
img{display:block}
.screen{position:fixed;inset:0;display:none;flex-direction:column}
.screen.on{display:flex}
/* ATTRACT */
#attract{align-items:center;justify-content:center;cursor:pointer;overflow:hidden}
.wall{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;gap:18px;opacity:.5;filter:saturate(1.1)}
.wallrow{display:flex;gap:18px;flex-shrink:0}
.wallrow img{width:200px;height:200px;border-radius:14px;object-fit:cover;background:#222}
.row-a{animation:scrollL 60s linear infinite}.row-b{animation:scrollR 75s linear infinite}.row-c{animation:scrollL 90s linear infinite}
@keyframes scrollL{from{transform:translateX(0)}to{transform:translateX(-50%)}}
@keyframes scrollR{from{transform:translateX(-50%)}to{transform:translateX(0)}}
.attract-mid{position:relative;z-index:2;text-align:center;background:rgba(11,11,14,.55);backdrop-filter:blur(8px);padding:48px 60px;border-radius:28px}
.attract-mid .logo{font-weight:800;font-size:64px;letter-spacing:-1px}.attract-mid .logo b{color:var(--primary)}
.attract-mid .logo img{height:96px}
.attract-mid .sub{font-size:26px;color:var(--mut);margin-top:10px}
.pulse{margin-top:28px;display:inline-block;font-size:22px;font-weight:600;color:var(--primary);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
/* BROWSE */
#browse{padding:0}
.top{display:flex;align-items:center;gap:20px;padding:22px 30px;border-bottom:1px solid var(--line);flex-shrink:0}
.top .logo{font-weight:800;font-size:30px;flex-shrink:0}.top .logo b{color:var(--primary)}.top .logo img{height:46px}
.search{flex:1;display:flex;align-items:center;gap:14px;background:var(--panel);border:2px solid var(--line);border-radius:18px;padding:14px 22px}
.search:focus-within{border-color:var(--primary)}
.search input{flex:1;background:none;border:0;color:var(--text);font:500 26px var(--font),system-ui;outline:none}
.search .ic{font-size:28px;color:var(--mut)}
.chips{display:flex;gap:12px;overflow-x:auto;padding:16px 30px;flex-shrink:0;scrollbar-width:none}
.chips::-webkit-scrollbar{display:none}
.chip{flex-shrink:0;padding:13px 24px;border-radius:30px;background:var(--panel);border:1px solid var(--line);font-size:22px;font-weight:600;color:var(--text)}
.chip.on{background:var(--primary);color:#11070c;border-color:var(--primary)}
.grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:22px;padding:14px 30px 40px;align-content:start}
.card{background:var(--panel);border-radius:16px;overflow:hidden;border:1px solid var(--line)}
.card .cov{width:100%;aspect-ratio:1;object-fit:cover;background:#222}
.card .cb{padding:12px 14px}
.card .ti{font-weight:700;font-size:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .ar{color:var(--mut);font-size:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .pr{color:var(--primary);font-weight:800;font-size:22px;margin-top:6px}
.empty{grid-column:1/-1;text-align:center;color:var(--mut);font-size:24px;padding:60px}
/* DETAIL */
#detail{z-index:30;background:rgba(7,7,10,.97);flex-direction:row;padding:46px;gap:46px;align-items:stretch}
#detail .left{flex:0 0 42%;display:flex;flex-direction:column;gap:24px}
#detail .cover{width:100%;aspect-ratio:1;border-radius:22px;object-fit:cover;background:#222;box-shadow:0 24px 70px rgba(0,0,0,.6)}
#detail .qrbox{background:#fff;border-radius:18px;padding:18px;display:flex;gap:18px;align-items:center}
#detail .qrbox #qr{width:120px;height:120px;flex-shrink:0}
#detail .qrbox .qt{color:#111}.qrbox .qt b{color:#d10f7a;font-size:30px}
#detail .right{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
#detail h1{font-size:48px;margin:0 0 4px;line-height:1.05}
#detail .artist{font-size:32px;color:var(--mut);margin-bottom:14px}
#detail .meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}
#detail .pill{background:var(--panel);border:1px solid var(--line);border-radius:24px;padding:8px 18px;font-size:20px;color:var(--mut)}
#detail .locate{background:linear-gradient(90deg,rgba(255,46,147,.18),transparent);border:1px solid var(--primary);border-radius:16px;padding:18px 22px;margin-bottom:18px;font-size:26px}
#detail .locate b{color:var(--primary)}
#detail .tracks{flex:1;overflow-y:auto;border-top:1px solid var(--line);padding-top:14px}
#detail .tk{display:flex;justify-content:space-between;font-size:21px;padding:7px 0;border-bottom:1px solid #1c1c22;color:#d6d6de}
#detail .tk .n{color:var(--mut);width:54px}
#dListen{margin-bottom:16px}
#detail .tk .tkplay{width:40px;flex-shrink:0}
.pbtn{cursor:pointer;border:0;border-radius:50%;width:36px;height:36px;background:var(--primary);color:#11070c;font-size:16px}
.pbtn.on{background:var(--accent)}
#kplayer{position:fixed;left:0;right:0;bottom:0;display:none;align-items:center;gap:18px;background:var(--panel);border-top:1px solid var(--line);padding:18px 30px;z-index:60}
#kplayer #kTitle{min-width:160px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:24px;font-weight:600}
#kplayer input[type=range]{flex:1;accent-color:var(--primary);height:8px}
#kplayer .pp{width:64px;height:64px;border-radius:50%;border:0;background:var(--primary);color:#11070c;font-size:26px;cursor:pointer}
.listenBtn{background:var(--primary);color:#11070c;border:0;border-radius:14px;padding:16px 30px;font:700 26px var(--font),system-ui;display:inline-flex;align-items:center;gap:10px;cursor:pointer}
.listenBtn .muted{color:#5a1030;font-weight:500;font-size:18px}
#dListen iframe{width:100%;height:240px;border:0;border-radius:14px}
.closeX{position:absolute;top:30px;right:36px;font-size:40px;color:var(--mut);z-index:40;width:70px;height:70px;display:flex;align-items:center;justify-content:center;background:var(--panel);border-radius:50%}
.bigprice{font-size:46px;font-weight:800;color:var(--primary)}
</style>
</head>
<body>
<!-- ATTRACT -->
<div id="attract" class="screen on" onclick="enterBrowse()">
<div class="wall" id="wall"></div>
<div class="attract-mid">
<div class="logo" id="aLogo">Record<b>God</b></div>
<div class="sub" id="aSub">Browse the crates · find it in store</div>
<div class="pulse">▸ tap anywhere to start ◂</div>
</div>
</div>
<!-- BROWSE -->
<div id="browse" class="screen">
<div class="top">
<div class="logo" id="bLogo" onclick="goAttract()">Record<b>God</b></div>
<div class="search"><span class="ic">🔍</span><input id="q" placeholder="search artist, title, label…" autocomplete="off"></div>
</div>
<div class="chips" id="chips"></div>
<div class="grid" id="grid"></div>
</div>
<!-- DETAIL -->
<div id="detail" class="screen">
<div class="closeX" onclick="closeDetail()"></div>
<div class="left">
<img class="cover" id="dCover" onerror="this.style.visibility='hidden'">
<div class="qrbox" id="dQrbox" style="display:none"><div id="qr"></div>
<div class="qt"><div>Scan to buy<br>on your phone</div><b id="dQrPrice"></b></div></div>
</div>
<div class="right">
<h1 id="dTitle"></h1>
<div class="artist" id="dArtist"></div>
<div class="meta" id="dMeta"></div>
<div id="dListen"></div>
<div class="locate" id="dLocate"></div>
<div class="tracks" id="dTracks"></div>
</div>
</div>
<div id="kplayer">
<button class="pp" id="kPlay" onclick="pToggle()"></button>
<div id="kTitle"></div>
<input id="kSeek" type="range" min="0" max="100" value="0" oninput="pSeekTo(this.value)">
<span class="muted" id="kTime" style="font-size:22px;font-variant-numeric:tabular-nums">0:00</span>
<span onclick="stopAudio()" style="cursor:pointer;color:var(--mut);font-size:30px"></span>
</div>
<audio id="kAudio"></audio>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const money=n=>n==null?'':'$'+Number(n).toFixed(2);
let CFG={}, IDLE=null;
async function boot(){
CFG=await fetch('/shop/config').then(r=>r.json()).catch(()=>({}));
const t=CFG.theme||{}, R=document.documentElement.style;
for(const [k,v] of Object.entries({'--primary':t.primary,'--accent':t.accent,'--bg':t.bg,'--panel':t.panel,'--text':t.text,'--font':t.font})) if(v) R.setProperty(k,v);
if(t.logo){ const im=`<img src="${esc(t.logo)}">`; $('#aLogo').innerHTML=im; $('#bLogo').innerHTML=im; }
buildWall(); loadChips();
document.addEventListener('pointerdown', resetIdle, true);
}
async function buildWall(){
const d=await fetch('/shop/browse?sort=new&page=1').then(r=>r.json()).catch(()=>({items:[]}));
const ids=(d.items||[]).map(r=>r.release_id);
if(!ids.length) return;
const imgs=ids.concat(ids).map(id=>`<img src="/img/r/${id}" loading="lazy" onerror="this.style.visibility='hidden'">`).join('');
$('#wall').innerHTML=`<div class="wallrow row-a">${imgs}</div><div class="wallrow row-b">${imgs}</div><div class="wallrow row-c">${imgs}</div>`;
}
/* idle: return to attract after 75s of no touch */
function resetIdle(){ clearTimeout(IDLE); IDLE=setTimeout(goAttract, 75000); }
function goAttract(){ clearTimeout(IDLE); stopAudio(); show('attract'); $('#q').value=''; }
function enterBrowse(){ show('browse'); resetIdle(); load(''); setTimeout(()=>$('#q').focus(),100); }
function show(id){ document.querySelectorAll('.screen').forEach(s=>s.classList.remove('on')); $('#'+id).classList.add('on'); }
let GEN=[];
async function loadChips(){
const f=await fetch('/shop/facets').then(r=>r.json()).catch(()=>({genres:[]}));
GEN=(f.genres||[]).slice(0,14);
$('#chips').innerHTML=`<span class="chip on" data-g="" onclick="pickGenre('')">✨ New in</span>`
+GEN.map(g=>`<span class="chip" data-g="${esc(g.name)}" onclick="pickGenre('${esc(g.name).replace(/'/g,"&#39;")}')">${esc(g.name)}</span>`).join('');
}
function pickGenre(g){ document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.dataset.g===g)); load('', g); }
let qt;
document.addEventListener('input',e=>{ if(e.target.id==='q'){ clearTimeout(qt); qt=setTimeout(()=>load(e.target.value.trim()),250); } });
async function load(q, genre){
const p=new URLSearchParams({sort:q?'title':'new', page:1});
if(q) p.set('q',q); if(genre) p.set('genre',genre);
const d=await fetch('/shop/browse?'+p).then(r=>r.json()).catch(()=>({items:[]}));
$('#grid').innerHTML=(d.items||[]).map(r=>`<div class="card" onclick="openDetail(${r.release_id})">
<img class="cov" src="/img/r/${r.release_id}" loading="lazy" onerror="this.style.visibility='hidden'">
<div class="cb"><div class="ti">${esc(r.title||'')}</div><div class="ar">${esc(r.artist||'')}</div>
<div class="pr">${money(r.price)}${r.copies>1?` <span style="color:var(--mut);font-size:16px">· ${r.copies}</span>`:''}</div></div></div>`).join('')
|| '<div class="empty">No records match — try another search</div>';
}
async function openDetail(rid){
resetIdle();
const d=await fetch('/shop/release/'+rid).then(r=>r.json()).catch(()=>({}));
const r=d.release; if(!r) return;
show('detail');
$('#dCover').src='/img/r/'+r.id; $('#dCover').style.visibility='visible';
$('#dTitle').textContent=r.title||'';
$('#dArtist').textContent=r.artist||'';
const meta=[r.label,r.format,r.country,r.year,r.genre].filter(Boolean);
$('#dMeta').innerHTML=meta.map(m=>`<span class="pill">${esc(String(m))}</span>`).join('');
const copies=d.copies||[], c0=copies[0];
if(c0){
const loc = c0.crate ? `📍 <b>Find it:</b> ${esc(c0.crate)}${c0.rack?' · '+esc(c0.rack):''} &nbsp; · &nbsp; <span class="bigprice">${money(c0.price)}</span>`
: `<b>In stock</b> — ask our staff to grab it &nbsp; · &nbsp; <span class="bigprice">${money(c0.price)}</span>`;
$('#dLocate').innerHTML=loc; $('#dLocate').style.display='block';
} else { $('#dLocate').innerHTML='Not currently on the floor — ask us to track it down'; $('#dLocate').style.display='block'; }
$('#dTracks').innerHTML=(d.tracks||[]).map(t=>`<div class="tk"><span class="tkplay" data-pos="${esc(t.position||'')}"></span><span class="n">${esc(t.position||'')}</span><span style="flex:1">${esc(t.title||'')}</span><span class="n" style="text-align:right">${esc(t.duration||'')}</span></div>`).join('')
|| '<div class="tk" style="color:var(--mut)">Tracklist not available</div>';
// QR to buy online (if this copy is linked to the web store)
const qb=$('#dQrbox'), qel=$('#qr'); qel.innerHTML='';
if(c0 && c0.product_url){ qb.style.display='flex'; $('#dQrPrice').textContent=money(c0.price);
try{ new QRCode(qel,{text:c0.product_url,width:120,height:120,correctLevel:QRCode.CorrectLevel.M}); }catch(e){ qb.style.display='none'; } }
else qb.style.display='none';
loadAudio(r.id);
}
async function loadAudio(rid){
const z=$('#dListen'); z.innerHTML='';
const [au, tr] = await Promise.all([
fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})),
fetch('/shop/audio-tracks/'+rid).then(r=>r.json()).catch(()=>({tracks:[]}))]);
const pmap={}; (tr.tracks||[]).forEach(t=>{ if(t.preview&&t.position!=null) pmap[t.position]=t.preview; });
let any=false;
document.querySelectorAll('#dTracks .tkplay').forEach(cell=>{ const url=pmap[cell.dataset.pos]; if(!url) return; any=true;
const title=cell.parentElement.children[2].textContent;
cell.innerHTML='<button class="pbtn">▶</button>';
cell.querySelector('button').onclick=()=>playTrack(url, title, cell.querySelector('button')); });
let html='';
if(!any){
if(au.apple_preview) html=`<button class="listenBtn" onclick="playTrack('${esc(au.apple_preview)}','30s preview')">▶ Listen <span class="muted">· 30s preview</span></button>`;
else if(au.youtube) html=`<button class="listenBtn" onclick="playYT('${au.youtube}')">▶ Watch / listen</button>`;
else if(au.beatport_embed) html=au.beatport_embed;
else if(au.bandcamp_embed) html=au.bandcamp_embed;
}
if(au.apple && au.apple.url) html+=` <a href="${esc(au.apple.url)}" target="_blank" style="color:var(--mut);font-size:18px;margin-left:14px"> Apple Music ↗</a>`;
z.innerHTML=html;
}
let _PBTN=null;
function fmtT(s){ s=s||0; return Math.floor(s/60)+':'+String(Math.floor(s%60)).padStart(2,'0'); }
function playTrack(url, title, btn){ const a=$('#kAudio');
if(a.dataset.src!==url){ a.src=url; a.dataset.src=url; } a.play(); $('#kTitle').textContent=title||''; $('#kplayer').style.display='flex'; $('#kPlay').textContent='⏸';
document.querySelectorAll('.pbtn.on').forEach(b=>{ b.classList.remove('on'); b.textContent='▶'; });
if(btn){ btn.classList.add('on'); btn.textContent='♪'; _PBTN=btn; } }
function pToggle(){ const a=$('#kAudio'); if(a.paused){ a.play(); $('#kPlay').textContent='⏸'; } else { a.pause(); $('#kPlay').textContent='▶'; } }
function pSeekTo(v){ const a=$('#kAudio'); if(a.duration) a.currentTime=a.duration*v/100; }
function playYT(id){ $('#dListen').innerHTML=`<iframe src="https://www.youtube.com/embed/${id}?autoplay=1&rel=0" allow="autoplay" allowfullscreen></iframe>`; }
function stopAudio(){ const a=$('#kAudio'); if(a) a.pause(); $('#kplayer').style.display='none'; document.querySelectorAll('#dListen iframe').forEach(f=>f.remove()); if(_PBTN){ _PBTN.classList.remove('on'); _PBTN.textContent='▶'; _PBTN=null; } }
function closeDetail(){ stopAudio(); show('browse'); resetIdle(); }
$('#kAudio').addEventListener('timeupdate',()=>{ const a=$('#kAudio'); if(a.duration){ $('#kSeek').value=100*a.currentTime/a.duration; $('#kTime').textContent=fmtT(a.currentTime); } });
$('#kAudio').addEventListener('ended',()=>{ $('#kPlay').textContent='▶'; if(_PBTN){ _PBTN.textContent='▶'; _PBTN.classList.remove('on'); } });
boot();
</script>
</body>
</html>