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>
153 lines
11 KiB
HTML
153 lines
11 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"'}[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(' ')}</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>
|