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 amount_tendered numeric(10,2)",
"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 original_price numeric(10,2)",
# auto-id for new POS rows (migrated tables came without sequences)

View File

@ -1,9 +1,23 @@
import asyncio
import json
import re
import httpx
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
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 .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,
beatport_genre, spotify_id, bandcamp_url, bandcamp_embed, soundcloud_url
FROM disc_release_audio WHERE release_id = :r"""), {"r": release_id})).mappings().first()
vids = [dict(r) for r in (await db.execute(text("""
SELECT title, uri, duration FROM disc_release_video WHERE release_id = :r AND uri <> '' LIMIT 8
"""), {"r": release_id})).mappings()]
youtube = await _live_youtube(db, release_id)
if not a:
return {"apple": None, "apple_preview": None, "videos": vids}
return {"apple": None, "apple_preview": None, "youtube": youtube}
pv = a["apple_preview_url"]
previews = []
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_id": a["beatport_id"], "bandcamp_url": a["bandcamp_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):

View File

@ -26,7 +26,8 @@ CREATE TABLE disc_release_audio (
spotify_id text, tidal_id text, deezer_id text,
bandcamp_url text, bandcamp_embed text, soundcloud_url text, allmusic_url text, local_url text);
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"

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_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_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 = """

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 .n{color:var(--mut);width:54px}
#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 .muted{color:#5a1030;font-weight:500;font-size:18px}
#dListen iframe{width:100%;height:240px;border:0;border-radius:14px}
@ -111,6 +118,15 @@
</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>
const $=s=>document.querySelector(s);
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>`;
$('#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('')
$('#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>';
// QR to buy online (if this copy is linked to the web store)
const qb=$('#dQrbox'), qel=$('#qr'); qel.innerHTML='';
@ -186,27 +202,40 @@ async function openDetail(rid){
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||[];
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='';
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(!any){
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 if(au.youtube) html=`<button class="listenBtn" 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(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;
}
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(); } }
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=$('#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 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(); }
$('#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();
</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}
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>
@ -36,6 +42,14 @@
<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]));
@ -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>
</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>`:''}`;
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 loadAudio(rid){
const z=$('#listen'); if(!z) return; z.innerHTML='';
const d=await fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), v=d.videos||[];
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(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>`; }
else { const yt=v.map(x=>ytId(x.uri)).find(Boolean);
if(yt) html=`<button class="btn" 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(!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(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>`;
z.innerHTML=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;
}
function resetLbtn(){ const b=$('#lbtn'); if(b) b.innerHTML='▶ Listen <span style="opacity:.7;font-weight:500">· 30s</span>'; }
function togglePreview(url){ const au=$('#aud'), b=$('#lbtn'); if(!au) return;
if(au.paused){ if(au.src!==url) au.src=url; au.play(); b.textContent='⏸ Pause'; } else { au.pause(); resetLbtn(); } }
/* ── 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>