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>
This commit is contained in:
type-two 2026-06-23 23:44:17 +10:00
parent d6e1e15982
commit 988e6437cb
6 changed files with 184 additions and 37 deletions

View File

@ -76,6 +76,10 @@ _STARTUP_DDL = [
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_tendered numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text", "ALTER TABLE sales ADD COLUMN IF NOT EXISTS notes text",
# audio: lazily-fetched per-track Apple previews + YouTube dead-link cache (re-added here in case a sync ran)
"ALTER TABLE disc_release_track ADD COLUMN IF NOT EXISTS apple_preview text",
"ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS dead boolean",
"ALTER TABLE disc_release_video ADD COLUMN IF NOT EXISTS checked_at timestamptz",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)", "ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)",
# auto-id for new POS rows (migrated tables came without sequences) # auto-id for new POS rows (migrated tables came without sequences)

View File

@ -1,9 +1,23 @@
import asyncio
import json import json
import re
import httpx
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
_YT = re.compile(r"(?:v=|youtu\.be/|embed/|/v/)([\w-]{11})")
def _ytid(uri):
if not uri:
return None
m = _YT.search(uri)
if m:
return m.group(1)
return uri if re.fullmatch(r"[\w-]{11}", uri) else None
from .db import get_db from .db import get_db
from .layout_routes import DEFAULTS from .layout_routes import DEFAULTS
@ -172,11 +186,9 @@ async def shop_audio(release_id: int, db=Depends(get_db)):
SELECT apple_id, apple_url, apple_preview_url, beatport_id, beatport_url, beatport_embed, SELECT apple_id, apple_url, apple_preview_url, beatport_id, beatport_url, beatport_embed,
beatport_genre, spotify_id, bandcamp_url, bandcamp_embed, soundcloud_url beatport_genre, spotify_id, bandcamp_url, bandcamp_embed, soundcloud_url
FROM disc_release_audio WHERE release_id = :r"""), {"r": release_id})).mappings().first() FROM disc_release_audio WHERE release_id = :r"""), {"r": release_id})).mappings().first()
vids = [dict(r) for r in (await db.execute(text(""" youtube = await _live_youtube(db, release_id)
SELECT title, uri, duration FROM disc_release_video WHERE release_id = :r AND uri <> '' LIMIT 8
"""), {"r": release_id})).mappings()]
if not a: if not a:
return {"apple": None, "apple_preview": None, "videos": vids} return {"apple": None, "apple_preview": None, "youtube": youtube}
pv = a["apple_preview_url"] pv = a["apple_preview_url"]
previews = [] previews = []
if isinstance(pv, str) and pv.lstrip().startswith("["): if isinstance(pv, str) and pv.lstrip().startswith("["):
@ -191,7 +203,75 @@ async def shop_audio(release_id: int, db=Depends(get_db)):
"beatport_url": a["beatport_url"], "beatport_embed": a["beatport_embed"], "beatport_url": a["beatport_url"], "beatport_embed": a["beatport_embed"],
"beatport_id": a["beatport_id"], "bandcamp_url": a["bandcamp_url"], "beatport_id": a["beatport_id"], "bandcamp_url": a["bandcamp_url"],
"bandcamp_embed": a["bandcamp_embed"], "soundcloud_url": a["soundcloud_url"], "bandcamp_embed": a["bandcamp_embed"], "soundcloud_url": a["soundcloud_url"],
"spotify_id": a["spotify_id"], "videos": vids} "spotify_id": a["spotify_id"], "youtube": youtube}
async def _live_youtube(db, release_id):
"""Pick a NON-dead YouTube clip — validates unchecked ones via YouTube oembed (200=live) and caches `dead`."""
rows = [dict(r) for r in (await db.execute(text(
"SELECT id, uri, dead FROM disc_release_video WHERE release_id=:r AND uri<>'' ORDER BY id LIMIT 8"
), {"r": release_id})).mappings()]
unchecked = [v for v in rows if v["dead"] is None][:4]
if unchecked:
async with httpx.AsyncClient(timeout=8) as c:
async def chk(v):
yid = _ytid(v["uri"])
if not yid:
return v["id"], True
try:
rr = await c.get("https://www.youtube.com/oembed",
params={"url": "https://youtu.be/" + yid, "format": "json"})
return v["id"], rr.status_code != 200
except Exception:
return v["id"], None # network blip — leave unchecked for next time
checked = await asyncio.gather(*[chk(v) for v in unchecked])
wrote = False
for vid, dead in checked:
if dead is not None:
await db.execute(text("UPDATE disc_release_video SET dead=:d, checked_at=now() WHERE id=:i"),
{"d": dead, "i": vid})
for v in rows:
if v["id"] == vid:
v["dead"] = dead
wrote = True
if wrote:
await db.commit()
for v in rows:
if v["dead"] is False:
yid = _ytid(v["uri"])
if yid:
return yid
return None
@router.get("/audio-tracks/{release_id}")
async def shop_audio_tracks(release_id: int, db=Depends(get_db)):
"""Per-track 30s previews — lazily resolved from each track's Apple song id via the iTunes lookup API, then cached."""
rows = [dict(r) for r in (await db.execute(text("""
SELECT sequence, position, title, apple_id, apple_preview
FROM disc_release_track WHERE release_id=:r AND apple_id ~ '^[0-9]+$'
ORDER BY sequence NULLS LAST, position"""), {"r": release_id})).mappings()]
need = [t["apple_id"] for t in rows if not t["apple_preview"]]
if need:
found = {}
try:
async with httpx.AsyncClient(timeout=15) as c:
rr = await c.get("https://itunes.apple.com/lookup",
params={"id": ",".join(need[:190]), "country": "au", "entity": "song"})
found = {str(x.get("trackId")): x.get("previewUrl")
for x in rr.json().get("results", []) if x.get("previewUrl")}
except Exception:
found = {}
for aid, prev in found.items():
await db.execute(text("UPDATE disc_release_track SET apple_preview=:p WHERE release_id=:r AND apple_id=:a"),
{"p": prev, "r": release_id, "a": aid})
if found:
await db.commit()
for t in rows:
if not t["apple_preview"]:
t["apple_preview"] = found.get(t["apple_id"])
return {"tracks": [{"position": t["position"], "title": t["title"], "preview": t["apple_preview"]}
for t in rows if t["apple_preview"]]}
class WantIn(BaseModel): class WantIn(BaseModel):

View File

@ -26,7 +26,8 @@ CREATE TABLE disc_release_audio (
spotify_id text, tidal_id text, deezer_id text, spotify_id text, tidal_id text, deezer_id text,
bandcamp_url text, bandcamp_embed text, soundcloud_url text, allmusic_url text, local_url text); bandcamp_url text, bandcamp_embed text, soundcloud_url text, allmusic_url text, local_url text);
CREATE TABLE disc_release_video ( CREATE TABLE disc_release_video (
release_id bigint, track_id text, title text, uri text, embed int, duration int); release_id bigint, track_id text, title text, uri text, embed int, duration int,
dead boolean, checked_at timestamptz);
""" """
INDEXES = "CREATE INDEX ON disc_release_video (release_id);\nANALYZE disc_release_audio;\n" INDEXES = "CREATE INDEX ON disc_release_video (release_id);\nANALYZE disc_release_audio;\n"

View File

@ -47,7 +47,7 @@ CREATE TABLE disc_release_identifier (release_id bigint, type text, value text);
CREATE TABLE disc_release_artist (release_id bigint, artist_id bigint, artist_name text, role text, join_string text, position text); CREATE TABLE disc_release_artist (release_id bigint, artist_id bigint, artist_name text, role text, join_string text, position text);
CREATE TABLE disc_master_genre (master_id bigint, genre_id int, genre_name text); CREATE TABLE disc_master_genre (master_id bigint, genre_id int, genre_name text);
CREATE TABLE disc_master_style (master_id bigint, style_id int, style_name text); CREATE TABLE disc_master_style (master_id bigint, style_id int, style_name text);
CREATE TABLE disc_release_track (release_id bigint, sequence int, position text, title text, duration text, track_id text, apple_id text, bpm numeric, musical_key text); CREATE TABLE disc_release_track (release_id bigint, sequence int, position text, title text, duration text, track_id text, apple_id text, apple_preview text, bpm numeric, musical_key text);
""" """
INDEXES = """ INDEXES = """

View File

@ -64,6 +64,13 @@
#detail .tk{display:flex;justify-content:space-between;font-size:21px;padding:7px 0;border-bottom:1px solid #1c1c22;color:#d6d6de} #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} #detail .tk .n{color:var(--mut);width:54px}
#dListen{margin-bottom:16px} #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{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} .listenBtn .muted{color:#5a1030;font-weight:500;font-size:18px}
#dListen iframe{width:100%;height:240px;border:0;border-radius:14px} #dListen iframe{width:100%;height:240px;border:0;border-radius:14px}
@ -111,6 +118,15 @@
</div> </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> <script>
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@ -177,7 +193,7 @@ async function openDetail(rid){
: `<b>In stock</b> — ask our staff to grab it &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'; $('#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'; } } 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('') $('#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>'; || '<div class="tk" style="color:var(--mut)">Tracklist not available</div>';
// QR to buy online (if this copy is linked to the web store) // QR to buy online (if this copy is linked to the web store)
const qb=$('#dQrbox'), qel=$('#qr'); qel.innerHTML=''; const qb=$('#dQrbox'), qel=$('#qr'); qel.innerHTML='';
@ -186,27 +202,40 @@ async function openDetail(rid){
else qb.style.display='none'; else qb.style.display='none';
loadAudio(r.id); 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){ async function loadAudio(rid){
const z=$('#dListen'); z.innerHTML=''; const z=$('#dListen'); z.innerHTML='';
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), v=d.videos||[]; 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=''; let html='';
if(d.apple_preview){ if(!any){
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>`; 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 { const yt=v.map(x=>ytId(x.uri)).find(Boolean); else if(au.youtube) html=`<button class="listenBtn" onclick="playYT('${au.youtube}')">▶ Watch / listen</button>`;
if(yt) html=`<button class="listenBtn" onclick="playYT('${yt}')">▶ Watch / listen</button>`; else if(au.beatport_embed) html=au.beatport_embed;
else if(d.beatport_embed) html=d.beatport_embed; else if(au.bandcamp_embed) html=au.bandcamp_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>`; 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; z.innerHTML=html;
} }
function resetListenBtn(){ const b=$('#lbtn'); if(b) b.innerHTML='▶ Listen <span class="muted">· 30s preview</span>'; } let _PBTN=null;
function togglePreview(url){ const au=$('#kAudio'), b=$('#lbtn'); if(!au) return; function fmtT(s){ s=s||0; return Math.floor(s/60)+':'+String(Math.floor(s%60)).padStart(2,'0'); }
if(au.paused){ if(au.src!==url) au.src=url; au.play(); b.innerHTML='⏸ Pause'; } else { au.pause(); resetListenBtn(); } } 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 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 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(); } 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(); boot();
</script> </script>

View File

@ -27,6 +27,12 @@
.sec{margin-top:26px}.sec h3{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px} .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)} 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} .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}} @media(max-width:760px){.top{grid-template-columns:1fr}}
</style> </style>
</head> </head>
@ -36,6 +42,14 @@
<nav class="menu" id="menu"></nav> <nav class="menu" id="menu"></nav>
</header> </header>
<div class="wrap" id="app"><div class="muted">loading…</div></div> <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> <script>
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@ -77,28 +91,47 @@ async function render(){
<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 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>
</div> </div>
${show('tracklist')&&d.tracks.length?`<div class="sec"><h3>Tracklist</h3><table>${d.tracks.map(t=>`<tr><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('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>`:''}`; ${show('description')&&r.notes?`<div class="sec"><h3>Notes</h3><div class="desc">${esc(r.notes)}</div></div>`:''}`;
loadAudio(r.id); setupAudio(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 setupAudio(rid){
async function loadAudio(rid){ const z=$('#listen'); if(z) z.innerHTML='';
const z=$('#listen'); if(!z) return; z.innerHTML=''; const [au, tr] = await Promise.all([
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), v=d.videos||[]; 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=''; let html='';
if(d.apple_preview){ html=`<button class="btn" id="lbtn" onclick="togglePreview('${esc(d.apple_preview)}')">▶ Listen <span style="opacity:.7;font-weight:500">· 30s</span></button><audio id="aud" onended="resetLbtn()"></audio>`; } if(!any){
else { const yt=v.map(x=>ytId(x.uri)).find(Boolean); 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>`;
if(yt) html=`<button class="btn" onclick="playYT('${yt}')">▶ Watch / listen</button>`; else if(au.youtube) html=`<button class="btn" onclick="playYT('${au.youtube}')">▶ Watch / listen</button>`;
else if(d.beatport_embed) html=d.beatport_embed; else if(au.beatport_embed) html=au.beatport_embed;
else if(d.bandcamp_embed) html=d.bandcamp_embed; else if(au.bandcamp_embed) html=au.bandcamp_embed;
} }
if(d.apple && d.apple.url) html+=` <a href="${esc(d.apple.url)}" target="_blank" class="muted" style="font-size:13px;margin-left:12px">Apple Music ↗</a>`; 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>`;
z.innerHTML=html; if(z) z.innerHTML=html;
} }
function resetLbtn(){ const b=$('#lbtn'); if(b) b.innerHTML='▶ Listen <span style="opacity:.7;font-weight:500">· 30s</span>'; } /* ── mini player + jog ── */
function togglePreview(url){ const au=$('#aud'), b=$('#lbtn'); if(!au) return; let _PBTN=null;
if(au.paused){ if(au.src!==url) au.src=url; au.play(); b.textContent='⏸ Pause'; } else { au.pause(); resetLbtn(); } } 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>`; } 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(); boot();
</script> </script>
</body> </body>