audio_sync mirrors apple_id ({cc:album_id}) + apple_song_id (kept raw, country preserved).
/shop/audio parses it: AU>US>first → music.apple.com/{cc}/album/{id}, per-track preview array
→ first clean 30s url. Kiosk Listen uses apple_preview + an Apple Music ↗ link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
14 KiB
HTML
215 lines
14 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}
|
|
.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>
|
|
|
|
<script>
|
|
const $=s=>document.querySelector(s);
|
|
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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,"'")}')">${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):''} · <span class="bigprice">${money(c0.price)}</span>`
|
|
: `<b>In stock</b> — ask our staff to grab it · <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="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);
|
|
}
|
|
function ytId(uri){ const m=String(uri||'').match(/(?:v=|youtu\.be\/|embed\/)([\w-]{11})/); return m?m[1]:(/^[\w-]{11}$/.test(uri||'')?uri:null); }
|
|
async function loadAudio(rid){
|
|
const z=$('#dListen'); z.innerHTML='';
|
|
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), v=d.videos||[];
|
|
let html='';
|
|
if(d.apple_preview){
|
|
html=`<button class="listenBtn" id="lbtn" onclick="togglePreview('${esc(d.apple_preview)}')">▶ Listen <span class="muted">· 30s preview</span></button><audio id="kAudio" onended="resetListenBtn()"></audio>`;
|
|
} else { const yt=v.map(x=>ytId(x.uri)).find(Boolean);
|
|
if(yt) html=`<button class="listenBtn" onclick="playYT('${yt}')">▶ Watch / listen</button>`;
|
|
else if(d.beatport_embed) html=d.beatport_embed;
|
|
else if(d.bandcamp_embed) html=d.bandcamp_embed;
|
|
}
|
|
if(d.apple && d.apple.url) html+=` <a href="${esc(d.apple.url)}" target="_blank" style="color:var(--mut);font-size:18px;margin-left:14px"> Apple Music ↗</a>`;
|
|
z.innerHTML=html;
|
|
}
|
|
function resetListenBtn(){ const b=$('#lbtn'); if(b) b.innerHTML='▶ Listen <span class="muted">· 30s preview</span>'; }
|
|
function togglePreview(url){ const au=$('#kAudio'), b=$('#lbtn'); if(!au) return;
|
|
if(au.paused){ if(au.src!==url) au.src=url; au.play(); b.innerHTML='⏸ Pause'; } else { au.pause(); resetListenBtn(); } }
|
|
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(); document.querySelectorAll('#dListen iframe').forEach(f=>f.remove()); }
|
|
function closeDetail(){ stopAudio(); show('browse'); resetIdle(); }
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|