RECORDGOD/site/release.html
type-two e8013a4633 feat(shop): DECKS — two-channel preview mixer on release pages (Serato internal-mode vibe)
Pure Web Audio, zero deps (site/decks.js, self-contained UI):
- deck A/B with waveform (client-computed peaks), click-to-seek, play/pause, CUE
- ±8% varispeed pitch (playbackRate — turntable behaviour, deliberately no keylock)
- channel gains + equal-power crossfader, DynamicsCompressor master limiter
- AudioContext lazily created on first load (autoplay policy safe)

Sources:
- local preview MP3s: PREVIEWS_DIR (default ./previews, gitignored) served at
  /previews, discovered via GET /shop/local-previews/{release_id}
  ({release_id}.mp3 / {release_id}-*.mp3|.m4a); on prod bind-mount the collection
- Apple 30s previews: GET /shop/preview-proxy?u= (STRICT whitelist
  *.itunes.apple.com / *.mzstatic.com) — Apple CDN sends no CORS, Web Audio needs it
- YouTube/Beatport/Bandcamp embeds untouched: iframes can never enter a mixer

release.html: A/B load buttons per track preview + local-cut rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:57:47 +10:00

153 lines
11 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:96px;padding:4px 0;white-space:nowrap}
.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)}
.dkbtn{cursor:pointer;border:1px solid var(--line);border-radius:6px;width:26px;height:26px;background:var(--panel);color:var(--mut);font:600 11px var(--font);margin-left:4px;padding:0}
.dkbtn:hover{color:var(--primary);border-color:var(--primary)}
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);
}
// cross-origin (Apple) previews go through the same-origin proxy so the DECKS mixer can decode them
const prox=u=>u.startsWith('/')?u:'/shop/preview-proxy?u='+encodeURIComponent(u);
const dkBtns=(url,title)=>['a','b'].map(s=>`<button class="dkbtn" title="load to deck ${s.toUpperCase()}" onclick="DECKS.load('${s}','${esc(prox(url))}','${esc(title)}')">${s.toUpperCase()}</button>`).join('');
async function setupAudio(rid){
const z=$('#listen'); if(z) z.innerHTML='';
const [au, tr, lp] = await Promise.all([
fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})),
fetch('/shop/audio-tracks/'+rid).then(r=>r.json()).catch(()=>({tracks:[]})),
fetch('/shop/local-previews/'+rid).then(r=>r.json()).catch(()=>({files:[]}))]);
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>'+dkBtns(url,title);
cell.querySelector('.pbtn').onclick=()=>playTrack(url, title, cell.querySelector('.pbtn')); });
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> ${dkBtns(au.apple_preview,'30s preview')}`;
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;
}
// local full-length previews (the shop's own rips) — the premium DECKS source
if(lp.files&&lp.files.length){
html=`<div style="margin-bottom:6px">${lp.files.map((f,i)=>{
const lbl=lp.files.length>1?('cut '+(i+1)):'preview';
return `<button class="btn ghost" onclick="playTrack('${esc(f)}','${esc(lbl)}')">▶ ${esc(lbl)}</button> ${dkBtns(f,lbl)}`;
}).join(' &nbsp; ')}</div>`+html;
}
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>
<script src="/decks.js"></script>
</body>
</html>