RECORDGOD/site/release.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

139 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Release</title>
<style>
:root{--primary:#ff5db1;--accent:#46d18a;--bg:#0c0c0e;--panel:#141418;--text:#f0f0f2;--mut:#8a8a98;--line:#26262c;--radius:12px;--font:system-ui}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif}
a{color:inherit;text-decoration:none}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
.logo{font-weight:800;font-size:20px}.logo b{color:var(--primary)}.logo img{height:30px;vertical-align:middle}
nav.menu{display:flex;gap:16px;flex:1}nav.menu a{color:var(--mut);font-size:14px}nav.menu a:hover{color:var(--text)}
button,.btn{cursor:pointer;border:0;border-radius:9px;font:600 14px var(--font),system-ui;padding:11px 16px;background:var(--primary);color:#10070c}
.ghost{background:var(--panel);color:var(--text);border:1px solid var(--line)}
.wrap{max-width:1080px;margin:0 auto;padding:24px}
.crumb{color:var(--mut);font-size:13px;margin-bottom:14px}.crumb a:hover{color:var(--text)}
.top{display:grid;grid-template-columns:340px 1fr;gap:28px}
.cov{width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--radius);background:#222;border:1px solid var(--line)}
h1{font-size:26px;margin:0 0 2px}.artist{font-size:18px;color:var(--mut);margin-bottom:14px}
.meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
.pill{background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:4px 11px;font-size:12px;color:var(--mut)}
.copies{margin:16px 0}
.copy{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:12px 14px;margin-bottom:8px}
.copy .cd{flex:1}.copy .pr{font-size:20px;font-weight:800;color:var(--primary)}
.sec{margin-top:26px}.sec h3{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px}
table{width:100%;border-collapse:collapse;font-size:14px}td{padding:6px 4px;border-bottom:1px solid var(--line)}
.muted{color:var(--mut)}.desc{color:#c8c8d0;line-height:1.6;font-size:14px;white-space:pre-wrap}
td.play{width:34px;padding:4px 0}
.pbtn{cursor:pointer;border:0;border-radius:50%;width:30px;height:30px;background:var(--primary);color:#10070c;font-size:12px}
.pbtn.on{background:var(--accent)}
input[type=range]{accent-color:var(--primary);height:5px}
#player{position:fixed;left:0;right:0;bottom:0;display:none;align-items:center;gap:14px;background:var(--panel);border-top:1px solid var(--line);padding:12px 22px;z-index:50}
#player #pTitle{min-width:120px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
@media(max-width:760px){.top{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<a class="logo" href="/records" id="logo">Record<b>God</b></a>
<nav class="menu" id="menu"></nav>
</header>
<div class="wrap" id="app"><div class="muted">loading…</div></div>
<div id="player">
<button id="pPlay" class="btn" onclick="pToggle()"></button>
<div id="pTitle"></div>
<input id="pSeek" type="range" min="0" max="100" value="0" style="flex:1" oninput="pSeekTo(this.value)">
<span class="muted" id="pTime" style="font-variant-numeric:tabular-nums">0:00</span>
<span onclick="pClose()" style="cursor:pointer;color:var(--mut);font-size:18px"></span>
</div>
<audio id="aud"></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);
const RID=(location.pathname.match(/\/release\/(\d+)/)||[])[1] || new URLSearchParams(location.search).get('id');
let CFG={};
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.radius) R.setProperty('--radius',t.radius+'px');
if(t.logo) $('#logo').innerHTML=`<img src="${esc(t.logo)}">`;
$('#menu').innerHTML=(CFG.menu||[]).map(m=>`<a href="${esc(m.href||'#')}">${esc(m.label||'')}</a>`).join('');
render();
}
async function render(){
if(!RID){ $('#app').innerHTML='<div class="muted">no release</div>'; return; }
const d=await fetch('/shop/release/'+RID).then(r=>r.json());
const r=d.release;
const show=k=>(CFG.product_page||['cover','title','artist','price','condition','genre','tracklist','cart']).includes(k);
if(!r){ $('#app').innerHTML=`<div class="muted">release ${esc(RID)} not found</div>`; return; }
const meta=[]; if(show('label')&&r.label)meta.push(r.label); if(show('format')&&r.format)meta.push(r.format);
if(show('country')&&r.country)meta.push(r.country); if(show('year')&&r.year)meta.push(r.year);
const tags=[]; if(show('genre')&&r.genre)r.genre.split(', ').forEach(g=>tags.push({n:g,k:'genre'})); if(r.style)r.style.split(', ').forEach(s=>tags.push({n:s,k:'style'}));
const aHtml=r.artist_id?`<a href="/artist/${r.artist_id}">${esc(r.artist||'')}</a>`:esc(r.artist||'');
const buy=c=> c.product_url ? `<a class="btn" href="${esc(c.product_url)}" target="_blank">Buy online</a>` : `<span class="muted">in store</span>`;
$('#app').innerHTML=`
<div class="crumb"><a href="/records">Records</a> ${aHtml}</div>
<div class="top">
<div>${show('cover')?`<img class="cov" src="/img/r/${r.id}" onerror="this.style.visibility='hidden'">`:''}</div>
<div>
${show('title')?`<h1>${esc(r.title||'')}</h1>`:''}${show('artist')?`<div class="artist">${aHtml}</div>`:''}
<div class="meta">${meta.map(m=>`<span class="pill">${esc(String(m))}</span>`).join('')}</div>
<div class="meta">${tags.map(t=>`<a class="pill" href="/${t.k}/${encodeURIComponent(t.n)}">${esc(t.n)}</a>`).join('')}</div>
<div id="listen" style="margin:4px 0 10px"></div>
${show('cart')||show('price')?`<div class="copies">${(d.copies||[]).map(c=>`<div class="copy">
<div class="cd"><div class="pr">${money(c.price)}</div><div class="muted">${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} · ${esc(c.sku)}</div></div>${buy(c)}</div>`).join('')||'<div class="muted">no copies in stock</div>'}</div>`:''}
<div style="margin-top:10px"><a href="/wantlist?artist=${encodeURIComponent(r.artist||'')}&title=${encodeURIComponent(r.title||'')}&format=${encodeURIComponent((r.format||'').split(',')[0]||'')}&release_id=${r.id}" class="muted" style="font-size:13px;text-decoration:underline">${(d.copies||[]).length?"Want a different copy? Request it":"Not in stock — request this record"} →</a></div>
</div>
</div>
${show('tracklist')&&d.tracks.length?`<div class="sec"><h3>Tracklist</h3><table>${d.tracks.map(t=>`<tr><td class="play" data-pos="${esc(t.position||'')}"></td><td class="muted" style="width:40px">${esc(t.position||'')}</td><td>${esc(t.title||'')}</td><td class="muted" style="width:50px;text-align:right">${esc(t.duration||'')}</td></tr>`).join('')}</table></div>`:''}
${show('description')&&r.notes?`<div class="sec"><h3>Notes</h3><div class="desc">${esc(r.notes)}</div></div>`:''}`;
setupAudio(r.id);
}
async function setupAudio(rid){
const z=$('#listen'); if(z) 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('#app td.play').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="btn" onclick="playTrack('${esc(au.apple_preview)}','30s preview')">▶ Listen <span style="opacity:.7;font-weight:500">· 30s</span></button>`;
else if(au.youtube) html=`<button class="btn" 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" class="muted" style="font-size:13px;margin-left:12px">Apple Music ↗</a>`;
if(z) z.innerHTML=html;
}
/* ── mini player + jog ── */
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=$('#aud');
if(a.dataset.src!==url){ a.src=url; a.dataset.src=url; }
a.play(); $('#pTitle').textContent=title||''; $('#player').style.display='flex'; $('#pPlay').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=$('#aud'); if(a.paused){ a.play(); $('#pPlay').textContent='⏸'; } else { a.pause(); $('#pPlay').textContent='▶'; } }
function pSeekTo(v){ const a=$('#aud'); if(a.duration) a.currentTime=a.duration*v/100; }
function pClose(){ $('#aud').pause(); $('#player').style.display='none'; if(_PBTN){ _PBTN.classList.remove('on'); _PBTN.textContent='▶'; _PBTN=null; } }
function playYT(id){ $('#listen').innerHTML=`<iframe style="width:100%;max-width:560px;height:300px;border:0;border-radius:12px" src="https://www.youtube.com/embed/${id}?autoplay=1&rel=0" allow="autoplay" allowfullscreen></iframe>`; }
$('#aud').addEventListener('timeupdate',()=>{ const a=$('#aud'); if(a.duration){ $('#pSeek').value=100*a.currentTime/a.duration; $('#pTime').textContent=fmtT(a.currentTime); } });
$('#aud').addEventListener('ended',()=>{ $('#pPlay').textContent='▶'; if(_PBTN){ _PBTN.textContent='▶'; _PBTN.classList.remove('on'); } });
boot();
</script>
</body>
</html>