/**
* Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
*
* Two surfaces:
* 1. .audio-player — bottom strip: cover + meta + controls + progress + volume
* 2. .audio-sheet — full-height now-playing panel (slides up from bottom)
*
* Features:
* - Click cover/meta on mini-player → open sheet
* - Sheet handle (pill) or backdrop click → close
* - Touch swipe-down on the drag-zone → close (mobile only; desktop has the X)
* - Reads data-pcms-track-url + data-pcms-track + data-pcms-album from posts
* - body.has-audio-player adds bottom padding when player visible
* - body.audio-sheet-locked prevents body scroll when sheet open
* - Survives HTMX swaps + history-restores via event delegation on document.body
*
* Singleton — guards against double-init.
*/
(function() {
if (window.pcmsAudioPlayer) return;
const SVG = {
play: ' ',
pause: ' ',
prev: ' ',
next: ' ',
vol: ' ',
mute: ' ',
musicNote: ' ',
};
// ============================================================
// 1. Build DOM
// ============================================================
const root = document.createElement('aside');
root.id = 'pcms-audio-player';
root.className = 'audio-player';
root.setAttribute('aria-label', 'Audio speler');
root.innerHTML = `
${SVG.musicNote}
${SVG.prev}
${SVG.play} ${SVG.pause}
${SVG.next}
${SVG.vol} ${SVG.mute}
${SVG.prev}
${SVG.play} ${SVG.pause}
${SVG.next}
`;
document.body.appendChild(root);
// FIX: Move .audio-sheet out of the .audio-player root so its
// position:fixed is anchored to the viewport, not to the mini-bar.
// The mini-bar has backdrop-filter, which makes it a containing
// block for fixed descendants — that broke the sheet's left/right/top/bottom.
const _detachedSheet = root.querySelector('.audio-sheet');
if (_detachedSheet) document.body.appendChild(_detachedSheet);
// ============================================================
// 2. Element references
// ============================================================
const $ = (id) => document.getElementById(id);
const audio = $('audio-element');
const cover = $('audio-cover');
const titleEl = $('audio-title');
const artistEl = $('audio-artist');
const seek = $('audio-seek');
const seekFill = $('audio-seek-fill');
const currentEl = $('audio-current');
const totalEl = $('audio-total');
const playBtn = $('audio-play');
const prevBtn = $('audio-prev');
const nextBtn = $('audio-next');
const muteBtn = $('audio-mute');
const volumeSlider = $('audio-volume');
const expandTrigger = $('audio-expand-trigger');
const sheet = $('audio-sheet');
const sheetBackdrop = $('audio-sheet-backdrop');
const sheetPanel = sheet.querySelector('.audio-sheet-panel');
const sheetClose = $('audio-sheet-close');
const sheetCover = $('audio-sheet-cover');
const sheetTitle = $('audio-sheet-title');
const sheetArtist = $('audio-sheet-artist');
const sheetAlbum = $('audio-sheet-album');
const sheetSeek = $('audio-sheet-seek');
const sheetSeekFill = $('audio-sheet-seek-fill');
const sheetCurrent = $('audio-sheet-current');
const sheetTotal = $('audio-sheet-total');
const sheetPlay = $('audio-sheet-play');
const sheetPrev = $('audio-sheet-prev');
const sheetNext = $('audio-sheet-next');
const sheetQueue = $('audio-sheet-queue');
const sheetQueueList = $('audio-sheet-queue-list');
const dragZone = $('audio-sheet-drag-zone');
// ============================================================
// 3. State
// ============================================================
let queue = [];
let currentIndex = 0;
let isPlaying = false;
let albumName = '';
// BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
// nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
// doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
// -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
// bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
// en spoelen gaat in seconden in plaats van per nummer.
let tapeMode = false;
// Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
// gate; no plain media URL is ever exposed to the page) and feed them to the
// element through one of two engines:
//
// 1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
// ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
// The next track's bytes are APPENDED into the same buffer, so the whole
// queue is one continuous playback session. That is what keeps a
// backgrounded tab/PWA playing across track changes: Chrome's background
// media policy pauses NEW playback sessions started in the background
// (the old per-track src-swap + load() + play()), but never interrupts a
// continuing one. A track change becomes a timeline position, not a swap.
// 2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
// objectURL per track, the previous behaviour.
let currentObjectUrl = null;
// Monotonic load token: a fast prev/next can fire several loads before an
// earlier fetch resolves. Only the latest load may touch the audio pipeline.
let loadSeq = 0;
// Next-track prefetch (blob engine; the MSE engine appends ahead instead).
// Shape: { url, bytes } — bytes is null while the fetch is still in flight.
let preload = null;
// ── MSE chain state ──
const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
let mseFailed = false; // runtime bail → blob engine for the rest of this session
const useMse = () => MSE_SUPPORTED && !mseFailed;
let ms = null; // MediaSource
let sb = null; // SourceBuffer
let chain = []; // appended segments: { qIndex, start, end } (timeline seconds)
let chainFetching = false; // a fetch+append for the NEXT track is in flight
let sbOps = Promise.resolve(); // serializes SourceBuffer operations
// Hide initially
root.classList.add('audio-player-hidden');
// ============================================================
// 4. Track / queue loading
// ============================================================
function setCoverImage(el, url) {
if (url) {
el.style.backgroundImage = `url("${url}")`;
el.classList.add('has-image');
el.innerHTML = '';
} else {
el.style.backgroundImage = '';
el.classList.remove('has-image');
el.innerHTML = SVG.musicNote;
}
}
// Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
// same-origin credentials get us past the stream route's access gate.
// Retries a few times with backoff: a single transient network blip used to
// bump the error counter and SKIP the song (auto-advance past it). Now one
// hiccup just costs a retry, and we only give up after genuinely failing.
async function fetchTrackBytes(url, attempts) {
attempts = attempts || 1;
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
const r = await fetch(url, {
credentials: 'same-origin',
headers: { 'X-Audio-Player': '1' },
});
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.arrayBuffer();
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await new Promise((res) => setTimeout(res, 350 * (i + 1)));
}
}
}
throw lastErr;
}
// Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
function applyBlobBytes(bytes, autoplay, mySeq) {
if (mySeq !== loadSeq) return; // superseded
const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
root.classList.remove('audio-loading');
// Free the previously-playing track's blob — otherwise each track leaks a
// copy. Never the same handle as objUrl (createObjectURL is unique), so this
// can't revoke the source we're about to play.
if (currentObjectUrl && currentObjectUrl !== objUrl) {
try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
}
currentObjectUrl = objUrl;
// Clean transition: pause + load forces a reset of internal state after
// multiple src changes (prevents state corruption of the audio element).
try { audio.pause(); } catch (e) {}
audio.src = objUrl;
try { audio.load(); } catch (e) {}
if (autoplay) play();
}
function onLoadError(err, mySeq) {
if (mySeq !== loadSeq) return; // superseded — ignore stale failure
root.classList.remove('audio-loading');
console.error('[pcms-audio] track load failed after retries', err);
// Genuine failure (after retries): bump the counter and auto-skip, but stop
// after 3 in a row so a fully-broken queue can't loop "next" forever.
consecutiveErrors++;
if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
}
// Discard any held/in-flight preload (plain bytes now — GC handles them).
function dropPreload() { preload = null; }
// Prefetch the *next* track's bytes in the background. Idempotent: re-calling
// while the same track is already cached / in flight is a no-op. Called from
// the `playing` event so the network is otherwise idle. Blob engine only —
// the MSE engine "preloads" by appending ahead (ensureNextAppended).
function preloadNext() {
if (queue.length < 2) return;
if (tapeMode && currentIndex >= queue.length - 1) return; // einde band
const ni = (currentIndex + 1) % queue.length;
const t = queue[ni];
if (!t || !t.url) return;
if (preload && preload.url === t.url) return; // already held or in flight
const marker = { url: t.url, bytes: null };
preload = marker;
fetchTrackBytes(t.url, 2).then((bytes) => {
// Only keep it if this is still the track we want next.
if (preload === marker) marker.bytes = bytes;
}).catch(() => { if (preload === marker) preload = null; });
}
// ============================================================
// 4a. MSE chain engine — one continuous playback session
// ============================================================
// All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
// appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
// byte-stream format generates continuous timestamps — sequence mode).
// Auto-advance = playback simply flowing into the next track's region.
// Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
// appended tracks would glitch the MPEG frame parser.
function stripId3(buf) {
const u8 = new Uint8Array(buf);
let start = 0, end = u8.length;
if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) { // "ID3"
const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0); // +10 when a footer is flagged
if (skip < end) start = skip;
}
if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128; // "TAG"
return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
}
function teardownChain() {
chain = [];
chainFetching = false;
sbOps = Promise.resolve();
sb = null;
ms = null;
}
// Serialize a SourceBuffer operation (append/remove): they throw if issued
// while the buffer is still updating, so everything funnels through a queue.
function sbRun(fn) {
const run = () => new Promise((resolve, reject) => {
if (!sb || !ms || ms.readyState !== 'open') return resolve();
const ok = () => { cleanup(); resolve(); };
const err = (e) => { cleanup(); reject(e); };
function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
sb.addEventListener('updateend', ok);
sb.addEventListener('error', err);
try { fn(); } catch (e) { cleanup(); reject(e); }
});
const p = sbOps.then(run, run);
sbOps = p.catch(() => {});
return p;
}
// Append one track's bytes as the next segment of the chain.
async function appendSegment(qIndex, bytes, mySeq) {
const clean = stripId3(bytes);
try {
await sbRun(() => sb.appendBuffer(clean));
} catch (e) {
if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
// Evict already-played data and retry once.
const seg = currentSegment();
if (seg && seg.start > 1) {
await sbRun(() => sb.remove(0, seg.start - 0.5));
chain = chain.filter((s) => s.end > seg.start - 0.5);
}
await sbRun(() => sb.appendBuffer(clean));
} else {
throw e;
}
}
if (mySeq !== loadSeq || !sb) return;
const buffered = sb.buffered;
const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
chain.push({ qIndex, start, end: chainEnd });
// DE STREAM MOET DICHT ALS ER NIETS MEER KOMT, anders vuurt `ended` nooit.
// Bij een wachtrij van een was dat al zo. Een BANDJE heeft nu hetzelfde
// nodig: sinds hij niet meer rondloopt haakt de keten na het laatste nummer
// niets meer aan, en dan bleef de band aan het eind hangen -- de teller
// stilstaand op de laatste seconde, isPlaying() waar, en de spoelen
// draaiend. Gemeten op dev (22-8): 125,6 van 125,7 en daar bleef hij.
const laatsteVanDeBand = tapeMode && qIndex >= queue.length - 1;
if ((queue.length === 1 || laatsteVanDeBand) && ms && ms.readyState === 'open') {
// Single-track queue: close the stream so `ended` fires (which replays
// it, matching the old engine's behaviour). Bij een bandje stopt `ended`
// hem juist, want next() pauzeert daar aan het eind.
try { ms.endOfStream(); } catch (e) {}
}
}
// Keep exactly one full track appended ahead of the one playing.
function ensureNextAppended() {
if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
if (queue.length < 2 || !chain.length) return;
const seg = currentSegment();
if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return; // already one ahead
// EEN BANDJE LOOPT NIET ROND (Robins eis, 22-8). Deze modulo is precies wat
// een cassette eindeloos maakte: na het laatste nummer hing hij nummer een
// er weer achter, en omdat het een doorlopende keten is merk je dat niet
// eens als een trackwissel -- de band gaat gewoon door.
const volgendeInRij = chain[chain.length - 1].qIndex + 1;
if (tapeMode && volgendeInRij > queue.length - 1) return; // einde band
const nextIdx = volgendeInRij % queue.length;
const t = queue[nextIdx];
if (!t || !t.url) return;
chainFetching = true;
const mySeq = loadSeq;
const bytesP = (preload && preload.url === t.url && preload.bytes)
? Promise.resolve(preload.bytes)
: fetchTrackBytes(t.url, 2);
bytesP.then((bytes) => {
if (mySeq !== loadSeq) return;
if (preload && preload.url === t.url) preload = null;
return appendSegment(nextIdx, bytes, mySeq);
}).catch((e) => {
console.warn('[pcms-audio] next-track append failed', e);
}).finally(() => { chainFetching = false; });
}
// Drop played-out data so the buffer holds ~2 tracks at most.
function pruneBuffer(curSeg) {
if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
const cut = curSeg.start - 0.5;
if (cut <= 1) return;
sbRun(() => sb.remove(0, cut)).catch(() => {});
chain = chain.filter((s) => s.end > cut);
}
function currentSegment() {
const t = audio.currentTime || 0;
for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
return chain[chain.length - 1] || null;
}
// Playback flowed across a track boundary (the gapless auto-advance):
// update chrome/metadata, top the buffer up, evict what's been played.
function maybeCrossBoundary() {
const seg = currentSegment();
if (!seg || seg.qIndex === currentIndex) return;
currentIndex = seg.qIndex;
const t = queue[currentIndex];
if (t) {
console.log('[pcms-audio] gapless auto-advance →', t.title);
updateTrackChrome(t);
}
ensureNextAppended();
pruneBuffer(seg);
updatePositionState();
savePlayerState();
}
// Start a fresh chain at queue[index]. Manual actions only (start/jump/
// prev/next/restore) — those happen in the foreground, where starting a
// new playback session is allowed.
function chainStart(index, autoplay, mySeq, bytes) {
teardownChain();
ms = new MediaSource();
const msUrl = URL.createObjectURL(ms);
if (currentObjectUrl && currentObjectUrl !== msUrl) {
try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
}
currentObjectUrl = msUrl;
try { audio.pause(); } catch (e) {}
audio.src = msUrl;
try { audio.load(); } catch (e) {}
const bailToBlob = (e) => {
console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
mseFailed = true;
teardownChain();
if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
};
ms.addEventListener('sourceopen', () => {
if (mySeq !== loadSeq || !ms) return;
try {
sb = ms.addSourceBuffer('audio/mpeg');
} catch (e) { return bailToBlob(e); }
appendSegment(index, bytes, mySeq).then(() => {
if (mySeq !== loadSeq) return;
// Session-restore: land at the saved in-track position.
if (pendingSeek > 0 && chain.length) {
const seg = chain[0];
try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
pendingSeek = 0;
}
ensureNextAppended();
}).catch(bailToBlob);
}, { once: true });
if (autoplay) play();
}
// Current position/duration in TRACK coordinates (the MSE timeline is the
// whole chain; the UI always shows the single playing track).
// De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
// geeft in bandmodus de teller over de hele band -- en het opslaan van de
// sessie heeft juist de trackpositie nodig: bij het herstellen begint de
// keten opnieuw en start dit nummer weer op nul.
function trackTijd() {
if (useMse() && chain.length) {
const seg = currentSegment();
if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
}
return { cur: audio.currentTime || 0, dur: audio.duration };
}
function displayTimes() {
// Een bandje heeft een teller, geen nummerpositie: hij telt door over de
// kant heen. Dat is letterlijk de ketentijdlijn, dus hier juist NIET
// terugrekenen naar trackcoordinaten.
if (tapeMode && useMse() && chain.length) {
return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
}
return trackTijd();
}
// metaOnly: show the track in the UI but DON'T download its bytes yet.
// Used by the site pre-seed so opening a page doesn't auto-download audio;
// the blob is fetched lazily on the first play().
// Persistently mark the current track (stays highlighted as long as it's active).
function markPlaying(trackId) {
document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
if (!trackId) return;
const el = document.getElementById('track-' + trackId);
if (el) el.classList.add('pat-playing');
}
// After an htmx navigation the post DOM is replaced → reapply the highlight.
document.body.addEventListener('htmx:afterSettle', () => {
const t = queue[currentIndex];
if (t) markPlaying(t.id);
});
// Media Session metadata (lock-screen / notification info + artwork). Set per
// track; the action handlers are wired once below. Keeping a live media session
// is what lets iOS continue a programmatic auto-advance play() instead of
// pausing it immediately.
function updateMediaMetadata(t) {
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
try {
const art = [];
if (t && t.cover) {
let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
art.push({ src: u, sizes: '512x512', type: '' });
}
navigator.mediaSession.metadata = new MediaMetadata({
title: (t && t.title) || 'Untitled',
artist: (t && t.artist) || '',
album: albumName || '',
artwork: art,
});
} catch (e) { /* non-fatal */ }
}
// All the visible per-track chrome: titles, covers, queue highlight, media
// session metadata. Called from loadTrack AND from the gapless boundary-cross.
function updateTrackChrome(t) {
// In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
// staat eronder, zoals een cassette een titel op het label heeft en de
// nummers op het doosje.
const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
const onder = tapeMode ? (t.title || '') : (t.artist || '');
titleEl.textContent = hoofd;
artistEl.textContent = onder;
sheetTitle.textContent = hoofd;
sheetArtist.textContent = onder;
sheetAlbum.textContent = albumName || '';
setCoverImage(cover, t.cover);
setCoverImage(sheetCover, t.cover);
root.classList.remove('audio-player-hidden');
document.body.classList.add('has-audio-player');
renderQueue();
markPlaying(t.id);
updateMediaMetadata(t);
}
function loadTrack(index, autoplay, metaOnly) {
if (!queue[index]) {
console.warn('[pcms-audio] loadTrack: no track at index', index);
return;
}
currentIndex = index;
const t = queue[index];
if (!t.url) {
console.error('[pcms-audio] track has no url', t);
return;
}
console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
// Metadata + chrome update synchronously so the UI reacts instantly while
// the bytes download.
updateTrackChrome(t);
if (metaOnly) return;
const mySeq = ++loadSeq;
// Fast path: the bytes for this exact track were already prefetched while
// the previous track played → no gap, no fetch window.
let bytesP;
if (preload && preload.url === t.url && preload.bytes) {
bytesP = Promise.resolve(preload.bytes);
preload = null;
} else {
// Not preloaded (or still in flight) → drop any stale preload and fetch
// fresh, retrying transient failures before giving up.
dropPreload();
root.classList.add('audio-loading');
bytesP = fetchTrackBytes(t.url, 3);
}
bytesP.then((bytes) => {
if (mySeq !== loadSeq) return;
root.classList.remove('audio-loading');
if (useMse()) chainStart(index, autoplay, mySeq, bytes);
else applyBlobBytes(bytes, autoplay, mySeq);
}).catch((err) => onLoadError(err, mySeq));
}
// True when the viewport is in the mobile sheet-layout — matches the CSS
// breakpoint where .audio-sheet slides up full-width from the bottom
// (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
// panel, so we do NOT auto-open it there.
// Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
// tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
// desktop (≥1200) = mini-player only, NO full player. matchMedia so this
// exactly follows the CSS breakpoints.
function playerTier() {
if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
return 'phone';
}
function hasFullPlayer() { return playerTier() !== 'desktop'; }
function isMobileView() { return playerTier() === 'phone'; }
function setQueue(tracks, startIdx, opts) {
queue = Array.isArray(tracks) ? tracks.slice() : [];
albumName = (opts && opts.albumName) || '';
tapeMode = !!(opts && opts.asTape);
if (!queue.length) return;
loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
// Mobile: a track press from an album/playlist auto-opens the full
// now-playing sheet (Spotify-style) instead of just the thin mini-strip.
// Only here (setQueue = a fresh, user-initiated queue) —
// not on next/prev or the site pre-seed — so a sheet the user
// deliberately closed doesn't reappear by itself.
if (hasFullPlayer()) openSheet();
}
function play() {
if (!audio.src) {
// Nothing fetched yet (pre-seed showed metadata only, or a load is still
// in flight). Kick off the blob load for the current track and autoplay.
if (queue[currentIndex]) loadTrack(currentIndex, true);
return;
}
const p = audio.play();
if (p && typeof p.catch === 'function') {
p.catch((err) => {
console.warn('[pcms-audio] play() rejected:', err.name, err.message);
// Browser autoplay policy blocked it (typically after 3-4
// auto-plays on iOS Safari, or when the tab was temporarily inactive).
// Show a visual hint for the user to tap play.
if (err && err.name === 'NotAllowedError') {
root.classList.add('audio-needs-tap');
isPlaying = false;
root.classList.remove('is-playing');
}
});
}
}
function pause() { audio.pause(); }
function togglePlay() { audio.paused ? play() : pause(); }
function next() {
if (!queue.length) return;
// Aan het eind van een bandje: stoppen. `ended` roept deze functie aan, dus
// zonder deze tak begint de band na het laatste nummer weer vooraan.
if (tapeMode && currentIndex >= queue.length - 1) { pause(); return; }
loadTrack((currentIndex + 1) % queue.length, true);
}
function prev() {
if (!queue.length) return;
// En aan het begin ook niet omlopen. Terugspoelen voorbij het begin levert
// de kop van de band op, niet het laatste nummer.
if (tapeMode && currentIndex === 0) {
try { audio.currentTime = 0; } catch (e) { /* nog niets geladen */ }
return;
}
loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
}
function close() {
pause();
mediaRegistry().release(registrySelf);
root.classList.add('audio-player-hidden');
document.body.classList.remove('has-audio-player');
closeSheet();
queue = [];
albumName = '';
tapeMode = false;
loadSeq++; // cancel any in-flight load
dropPreload();
teardownChain();
if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
currentObjectUrl = null;
try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
}
function renderQueue() {
if (!queue.length) { sheetQueue.hidden = true; return; }
sheetQueueList.innerHTML = '';
queue.forEach((t, i) => {
const li = document.createElement('li');
li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
li.dataset.idx = String(i);
li.innerHTML = `${i + 1}. ${escapeHtml(t.title || 'Untitled')} `
+ (t.artist ? `${escapeHtml(t.artist)} ` : '');
sheetQueueList.appendChild(li);
});
sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
li.addEventListener('click', () => {
const idx = parseInt(li.dataset.idx, 10);
if (!isNaN(idx) && idx !== currentIndex) {
loadTrack(idx, true);
}
});
});
sheetQueue.hidden = queue.length < 2;
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// ============================================================
// 4b. Mutual exclusion — shared media registry (see embed-player.js).
// ============================================================
// All players (this site player + YouTube/SoundCloud/Spotify embeds)
// register themselves in window.pcmsMediaRegistry. Starting one pauses
// the previous. This is the precise replacement for the old focus/blur
// heuristic for embeds with a real JS API. (The blur fallback below stays
// for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
function mediaRegistry() {
if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
const r = {
_active: null,
setActive(player) {
if (this._active && this._active !== player && this._active.pause) {
try { this._active.pause(); } catch (e) {}
}
this._active = player;
},
release(player) { if (this._active === player) this._active = null; },
};
window.pcmsMediaRegistry = r;
return r;
}
const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
// ============================================================
// 5. Audio element events → UI sync
// ============================================================
// Error counter prevents an infinite loop when ALL tracks are broken.
let consecutiveErrors = 0;
audio.addEventListener('play', () => {
isPlaying = true;
root.classList.add('is-playing');
root.classList.remove('audio-needs-tap'); // hide tap hint
mediaRegistry().setActive(registrySelf); // pause any currently playing embeds
if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
});
// Media Session action handlers (wired once): lock-screen / headset / car
// controls, and — crucially — an active session so iOS keeps a programmatic
// auto-advance playing instead of pausing it the instant it starts.
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
wire('play', () => play());
wire('pause', () => pause());
wire('previoustrack', () => prev());
wire('nexttrack', () => next());
wire('seekto', (e) => {
if (!e || e.seekTime == null) return;
// Lock-screen scrubber works in TRACK coordinates (positionState below).
if (useMse() && chain.length) {
const seg = currentSegment();
if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
return;
}
if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
});
}
// Lock-screen / notification scrubber: report per-track position, not the
// whole-chain timeline.
function updatePositionState() {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
try {
const dt = displayTimes();
if (!isFinite(dt.dur) || !dt.dur) return;
navigator.mediaSession.setPositionState({
duration: dt.dur,
playbackRate: audio.playbackRate || 1,
position: Math.min(dt.cur, dt.dur),
});
} catch (e) { /* non-fatal */ }
}
// Reset the error counter only on a REAL playback start (`playing`), not the
// eager `play` event. `play` fires before any network/decode error, so resetting
// there would prevent the 3-strikes stop from ever triggering on a broken
// track → infinite "next" loop. `playing` only fires when audio is actually playing.
audio.addEventListener('playing', () => {
consecutiveErrors = 0;
if (useMse()) ensureNextAppended(); else preloadNext();
updatePositionState();
});
audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
audio.addEventListener('ended', next);
audio.addEventListener('error', (e) => {
const code = audio.error ? audio.error.code : '?';
console.error('[pcms-audio] playback error', code, audio.src, e);
if (useMse() && ms) {
// The MSE pipeline failed (decode/append) → permanently fall back to the
// blob engine for this session and retry the SAME track.
console.warn('[pcms-audio] MSE failed, falling back to blob playback');
mseFailed = true;
teardownChain();
if (queue[currentIndex]) loadTrack(currentIndex, true);
return;
}
consecutiveErrors++;
// On network/decode error: skip to next track instead of stalling.
// Max 3 consecutive errors before giving up (otherwise infinite loop).
if (consecutiveErrors < 3 && queue.length > 1) {
console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
setTimeout(next, 400);
}
});
audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
audio.addEventListener('timeupdate', () => {
// Gapless boundary: in MSE mode a track change is just the timeline
// flowing past a segment edge — detect it here and update the chrome.
if (useMse() && chain.length) maybeCrossBoundary();
const dt = displayTimes();
if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
const pct = (dt.cur / dt.dur) * 100;
seekFill.style.width = pct + '%';
sheetSeekFill.style.width = pct + '%';
currentEl.textContent = formatTime(dt.cur);
totalEl.textContent = formatTime(dt.dur);
sheetCurrent.textContent = formatTime(dt.cur);
sheetTotal.textContent = formatTime(dt.dur);
});
function formatTime(s) {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function attachSeek(seekEl) {
seekEl.addEventListener('click', (e) => {
const rect = seekEl.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (useMse() && chain.length) {
// Seek within the CURRENT track's segment of the chain timeline.
const seg = currentSegment();
if (seg) {
try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
updatePositionState();
}
return;
}
if (!audio.duration) return;
audio.currentTime = ratio * audio.duration;
});
}
attachSeek(seek);
attachSeek(sheetSeek);
// Volume + mute persist across sessions/pages via localStorage.
const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
audio.volume = _initVol;
volumeSlider.value = Math.round(_initVol * 100);
if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
volumeSlider.addEventListener('input', () => {
audio.volume = volumeSlider.value / 100;
if (volumeSlider.value > 0) audio.muted = false;
saveVol();
});
muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
// ============================================================
// 6. Control wiring
// ============================================================
playBtn.addEventListener('click', togglePlay);
prevBtn.addEventListener('click', prev);
nextBtn.addEventListener('click', next);
sheetPlay.addEventListener('click', togglePlay);
sheetPrev.addEventListener('click', prev);
sheetNext.addEventListener('click', next);
// ============================================================
// 7. Sheet expand/close + drag-down-to-close
// ============================================================
// Back button closes the sheet on mobile: on open we push a history entry
// so the phone back button (popstate) closes the sheet first instead of
// leaving the page. We balance it on a UI-initiated close via history.back().
let sheetHistoryPushed = false;
function openSheet() {
if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
if (sheet.classList.contains('is-open')) return;
sheet.classList.add('is-open');
sheet.setAttribute('aria-hidden', 'false');
document.body.classList.add('audio-sheet-locked');
// Phone + tablet: push a history entry so the back button closes the sheet first.
try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
}
function closeSheet(fromPopstate) {
if (!sheet.classList.contains('is-open')) return;
sheet.classList.remove('is-open');
sheet.setAttribute('aria-hidden', 'true');
document.body.classList.remove('audio-sheet-locked');
sheetPanel.style.removeProperty('--pcms-drag-y');
sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
// UI close (X / swipe / backdrop / Esc): pop our own history entry so the
// next back button navigates normally. On a popstate close (back button itself)
// the entry is already popped.
const wasPushed = sheetHistoryPushed;
sheetHistoryPushed = false;
if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
}
window.addEventListener('popstate', () => {
if (sheet.classList.contains('is-open')) closeSheet(true);
});
// Clicking the mini-player track info:
// - DESKTOP (≥1200px): jump to the post the track came from (if known),
// via htmx so audio keeps playing. No post known → fall back to the sheet.
// - MOBILE/TABLET: always open the full now-playing sheet.
function scrollToTrack(trackId) {
if (!trackId) { window.scrollTo(0, 0); return; }
const el = document.getElementById('track-' + trackId);
if (!el) { window.scrollTo(0, 0); return; }
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
el.classList.add('pat-flash');
setTimeout(() => el.classList.remove('pat-flash'), 1600);
}
function goToPost(url, trackId) {
const hash = trackId ? ('#track-' + trackId) : '';
if (window.htmx && url.charAt(0) === '/') {
try {
const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
history.pushState({}, '', url + hash);
// Scroll to the track after the swap (small delay so the global
// afterSwap scroll-to-top runs first); fall back to top if not found.
const go = () => setTimeout(() => scrollToTrack(trackId), 60);
if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
return;
} catch (e) { /* fall back to full navigation */ }
}
location.href = url + hash;
}
expandTrigger.addEventListener('click', () => {
const t = queue[currentIndex];
// Only on true desktop (≥1200, no full-player) do we jump to the post.
// Tablet + phone have a full-player → open it (same as mobile behaviour).
const jumpToPost = !hasFullPlayer();
if (jumpToPost && t) {
// 1) Track played from a post → we already know that URL.
if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
// 2) Site-wide track (no postUrl) → look up the post via the track id.
if (t.id) {
fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
.catch(() => openSheet());
return;
}
}
openSheet();
});
sheetClose.addEventListener('click', () => closeSheet());
sheetBackdrop.addEventListener('click', () => closeSheet());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
});
// Resized to desktop width (≥1200) while the full player is open? Close it —
// the full player doesn't exist on desktop.
window.addEventListener('resize', () => {
if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
});
// Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
// registry already handles this precisely (real play events). But for
// iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
// iframe FALLBACK (when an ad-blocker blocks the player API) there is no
// play event: we catch those via focus. User clicks such an iframe →
// window 'blur' → pause our player. (For API embeds this is at worst a
// harmless double-pause.)
window.addEventListener('blur', () => {
setTimeout(() => {
const el = document.activeElement;
// Only embed iframes (inside .folio-embed) pause the player — not a
// random iframe (captcha/ad/map) that happens to receive focus.
if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
pause();
}
}, 0);
});
// Drag-down-to-close on touch devices.
//
// We set --pcms-drag-y as a CSS custom prop instead of writing
// sheetPanel.style.transform directly. The reason: on desktop the panel
// uses `transform: translate(-50%, 0)` for horizontal centering. Writing
// `style.transform = translateY(...)` would obliterate the -50% and the
// panel would jump rightward. With a custom prop, audio.css composes the
// final transform per breakpoint:
// mobile: transform: translateY(var(--pcms-drag-y, 0))
// desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
let dragStartY = 0, dragLastY = 0, isDragging = false;
function onPointerDown(e) {
if (e.pointerType !== 'touch') return;
if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
dragStartY = dragLastY = e.clientY;
isDragging = true;
sheetPanel.classList.add('is-dragging');
sheetBackdrop.classList.add('is-dragging');
}
function onPointerMove(e) {
if (!isDragging) return;
dragLastY = e.clientY;
const dy = Math.max(0, dragLastY - dragStartY);
sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
}
function onPointerUp() {
if (!isDragging) return;
isDragging = false;
sheetPanel.classList.remove('is-dragging');
sheetBackdrop.classList.remove('is-dragging');
const dy = dragLastY - dragStartY;
if (dy > 100) {
closeSheet();
} else {
sheetPanel.style.removeProperty('--pcms-drag-y');
sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
}
}
if (window.PointerEvent) {
dragZone.addEventListener('pointerdown', onPointerDown);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
// ============================================================
// 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
// ============================================================
// Four entry points fire the same play action:
// - .pat-play → single-track widget in a post
// - .pat-row → track row inside an album/playlist tracklist
// - .post-album-cover-btn → big cover button (plays album from track 0)
// - .post-album-playall → "Speel album" / "Speel playlist" button
//
// For .pat-play the metadata lives on the surrounding .post-audio-track
// wrapper. For the other three the data is on the button itself. The
// handler reads from button-first, falls back to wrapper.
// Event delegation on document.body instead of per-button listeners. This
// survives HTMX history-restores: the mobile back button (popstate) lets HTMX
// restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
// would leave dead buttons (flag baked into the snapshot, listener gone). One
// delegated listener works regardless of how many times the DOM is (re)swapped.
// WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
// regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
// en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
// Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
// gegevens klopten, en er gebeurde niets.
const PLAY_SELECTOR =
'.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
document.body.addEventListener('click', (e) => {
const btn = e.target.closest(PLAY_SELECTOR);
if (!btn) return;
e.preventDefault();
e.stopPropagation();
// The post you're playing FROM = the current page (embeds live in post content).
// Store it on the track(s) so the desktop mini-player can jump back to it —
// survives the sessionStorage resume as well.
const postUrl = location.pathname + location.search;
// Resolve metadata: button-first, then closest .post-audio-track wrapper
// (only inline single-track widgets put the data on the wrapper).
const wrapper = btn.closest('.post-audio-track');
const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
if (albumId) {
const album = document.getElementById(albumId);
if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
try {
const tracks = JSON.parse(album.dataset.pcmsAlbum);
tracks.forEach((t) => { t.postUrl = postUrl; });
// Start at the clicked track if we know its URL, else start at 0
// (cover-btn and playall both want to start from the beginning).
const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
// Een mixtape gaat als EEN object de speler in: de titel van het bandje
// op de speler, de teller over de hele band, en spoelen in seconden.
// De soort staat al op het blok (data-pcms-album-kind), dus hier is het
// een doorgeefje en geen tweede plek die iets afleidt.
setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
albumName: album.dataset.pcmsAlbumTitle || '',
asTape: album.dataset.pcmsAlbumKind === 'mixtape',
});
} catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
} else if (trackData) {
try {
const t = JSON.parse(trackData);
t.postUrl = postUrl;
setQueue([t], 0);
} catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
} else if (trackUrl) {
// Fallback: at minimum we have the signed URL
setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
} else {
console.error('[pcms-audio] no track data or url on button or wrapper', btn);
}
});
// ============================================================
// 8b. Admin playlist delete (event delegation)
// ============================================================
// The post-album embed renders a [data-pcms-playlist-delete] button
// top-right when the viewer is admin (server decides; not client).
// We delegate from document.body so HTMX-swapped content works too.
document.body.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-pcms-playlist-delete]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = btn.dataset.pcmsPlaylistDelete;
const title = btn.dataset.pcmsPlaylistTitle || id;
if (!id) return;
if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
btn.disabled = true;
try {
const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
method: 'POST',
headers: { 'X-CSRF-Token': '' },
credentials: 'same-origin',
});
const j = await r.json().catch(() => ({}));
if (j && j.ok) {
location.reload();
} else {
alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
btn.disabled = false;
}
} catch (err) {
alert('Verwijderen mislukt: ' + err.message);
btn.disabled = false;
}
});
// ============================================================
// 9. Public API
// ============================================================
/**
* SPOELEN, in seconden over de hele band.
*
* Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
* IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
* gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
*
* Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
* niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
* van het huidige nummer en stapt dan naar de buur. Grover, maar het is
* eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
*/
function seekBy(seconden) {
const d = Number(seconden) || 0;
if (!d || !audio) return;
if (useMse() && chain.length) {
const eind = chain[chain.length - 1].end;
const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
updatePositionState();
return;
}
// Blob-motor: binnen het nummer blijven, en anders naar de buur.
const duur = audio.duration || 0;
const nu = audio.currentTime || 0;
if (duur && nu + d >= duur) {
// Vooruit voorbij het eind. In bandmodus stopt next() aan het eind van de
// band; daarbuiten loopt hij door naar het volgende nummer.
next();
return;
}
if (nu + d < 0) {
// TERUGSPOELEN VOORBIJ HET BEGIN moet in het vorige nummer landen aan het
// EIND, niet aan het begin -- dat is wat terugspoelen doet. Zonder dit
// sprong je bij elke druk naar de kop van het vorige nummer en kwam je
// nooit ergens in het midden uit.
if (!(tapeMode && currentIndex === 0)) pendingSeek = Number.MAX_SAFE_INTEGER;
prev();
return;
}
try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
}
/** Waar staat de teller, over het hele bandje. */
function tapeTijden() { return displayTimes(); }
window.pcmsAudioPlayer = {
setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
isTape: () => tapeMode,
isPlaying: () => isPlaying,
currentTrack: () => queue[currentIndex] || null,
};
// ============================================================
// 10. Session persistence — player "survives" across page navigations
// ============================================================
// An element doesn't survive a full page load (and cross-context
// navigation — e.g. to the headerless hub overview — is intentionally a
// full-nav). We save the session to sessionStorage and restore + resume it
// on the next page: the player comes back with the same track at the same
// position. In Chrome (high media engagement) it resumes immediately;
// if the browser blocks autoplay it waits at that position (one tap = play).
const PLAYER_STATE_KEY = 'pcms-player-state';
let pendingSeek = 0;
function savePlayerState() {
try {
if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
queue, currentIndex, albumName, tapeMode,
// In-TRACK position (the MSE timeline spans the whole chain; a restore
// starts a fresh chain where this track begins at 0).
time: trackTijd().cur || 0,
playing: !!audio.src && !audio.paused,
}));
} catch (e) {}
}
window.addEventListener('pagehide', savePlayerState);
window.addEventListener('beforeunload', savePlayerState);
audio.addEventListener('play', savePlayerState);
audio.addEventListener('pause', savePlayerState);
audio.addEventListener('ended', savePlayerState);
setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
// Apply the restored position once track metadata is available.
audio.addEventListener('loadedmetadata', () => {
if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
pendingSeek = 0;
}
});
function restorePlayerState() {
let s = null;
try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
queue = s.queue;
albumName = s.albumName || '';
tapeMode = !!s.tapeMode;
pendingSeek = s.time || 0;
const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
// playing → fetch + (attempt to) resume; paused → meta-only.
loadTrack(idx, !!s.playing, !s.playing);
return true;
}
// ============================================================
// 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
// ============================================================
// An active session (restore) wins over the page seed, so music that is
// already playing continues instead of being replaced by the new page's tracks.
if (!restorePlayerState()) {
if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
queue = window.PCMS_SITE_TRACKS.map(t => ({
id: t.id || null,
url: t.media_url || t.url,
title: t.title || 'Untitled',
artist: t.artist || '',
cover: t.cover_url || t.cover || '',
}));
// Only prime the queue — the player bar appears only on the first audio click
// (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
// which shows the bar). No more pre-seed bar on page load.
currentIndex = 0;
}
}
})();