window.KoanPlayer = (opts = {}) => {
const LONG = 900; const changed = opts.onChange || (() => {});
const renew = opts.renew || null;
let queue = [];
let cur = -1; let resumeAt = 0; let playing = false;
let gen = 0; let mode = null; let ctx = null; let source = null, startedAt = 0; let queued = null; const buffers = new Map(); const el = new Audio(); el.preload = "auto";
let dest = null; const out = new Audio(); out.setAttribute("playsinline", "");
out.addEventListener("pause", () => {
if (playing && mode === "buffer") { playing = false; ctx.suspend(); notify(); }
});
out.addEventListener("play", () => {
if (!playing && mode === "buffer") { playing = true; ctx.resume(); notify(); }
});
const decodable = (t) => t && t.dur > 0 && t.dur <= LONG;
let elUnlocked = false;
function wake(t) {
if (navigator.audioSession) { try { navigator.audioSession.type = "playback"; } catch {} }
if (!ctx && window.AudioContext) ctx = new AudioContext();
if (ctx && !dest && ctx.createMediaStreamDestination) {
try { dest = ctx.createMediaStreamDestination(); out.srcObject = dest.stream; } catch { dest = null; }
}
if (ctx && ctx.state !== "running") ctx.resume().catch(() => {});
if (dest && t) out.play().catch(() => {});
if (!elUnlocked && t) {
elUnlocked = true;
el.src = t.src;
el.play().then(() => { if (mode !== "stream") el.pause(); }).catch(() => {});
}
}
function buffer(i) {
const t = queue[i];
if (!ctx || !decodable(t)) return Promise.resolve(null);
if (!buffers.has(t.src)) {
buffers.set(t.src, fetch(t.src, { credentials: "same-origin" })
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status)))
.then((b) => ctx.decodeAudioData(b))
.catch(() => null));
}
const keep = new Set([cur - 1, cur, cur + 1, i].map((k) => queue[k] && queue[k].src));
for (const k of buffers.keys()) if (!keep.has(k)) buffers.delete(k);
return buffers.get(t.src);
}
function unqueue() {
if (!queued) return;
queued.source.onended = null;
try { queued.source.stop(); } catch {}
queued = null;
}
function stop() {
gen++;
if (source) { source.onended = null; try { source.stop(); } catch {} }
source = null;
unqueue();
el.onended = el.onerror = null;
el.pause(); el.removeAttribute("src"); el.load();
mode = null;
}
function state() {
return { queue, cur, playing, track: queue[cur] || null };
}
function session() {
if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession;
const t = queue[cur];
ms.metadata = t ? new MediaMetadata({
title: t.title || "", artist: t.artist || "", album: t.album || "",
artwork: t.cover ? [{ src: new URL(t.cover, location.href).href }] : [],
}) : null;
ms.playbackState = t ? (playing ? "playing" : "paused") : "none";
if (t && t.dur > 0 && ms.setPositionState) {
try { ms.setPositionState({ duration: t.dur, position: Math.min(position(), t.dur), playbackRate: 1 }); } catch {}
}
}
function notify() { session(); changed(state()); }
function setPlaying(p) { playing = p; notify(); }
function startBuffer(buf, from, at) {
const s = ctx.createBufferSource();
s.buffer = buf;
s.connect(dest || ctx.destination);
s.start(at, from);
return s;
}
function onBufferEnded(g) {
return () => {
if (g !== gen) return;
if (queued) {
cur++;
source = queued.source; startedAt = queued.at; queued = null;
source.onended = onBufferEnded(g);
notify();
queueNext(g);
} else {
go(cur + 1, 0);
}
};
}
async function queueNext(g) {
const i = cur + 1;
if (mode !== "buffer" || !source || queued || !decodable(queue[i])) return;
const buf = await buffer(i);
if (g !== gen || !buf || !source || queued || cur + 1 !== i) return;
const at = startedAt + source.buffer.duration;
if (at < ctx.currentTime + 0.05) return; queued = { source: startBuffer(buf, 0, at), at };
}
function requeue() {
if (queued && queued.at <= ctx.currentTime) return; unqueue();
queueNext(gen);
}
async function go(i, from) {
stop();
if (i < 0 || i >= queue.length) { cur = -1; resumeAt = 0; setPlaying(false); out.pause(); return; }
const g = gen;
cur = i;
resumeAt = 0;
setPlaying(true);
const buf = ctx && decodable(queue[i]) ? await buffer(i) : null;
if (g !== gen) return;
if (buf) {
mode = "buffer";
if (dest) out.play().catch(() => {});
if (ctx.state !== "running") await ctx.resume();
const at = ctx.currentTime + 0.03;
source = startBuffer(buf, from, at);
startedAt = at - from;
source.onended = onBufferEnded(g);
queueNext(g);
} else {
mode = "stream";
out.pause();
const t = queue[i];
let retried = false;
el.src = t.src;
el.currentTime = from;
el.onended = () => { if (g === gen) go(cur + 1, 0); };
el.onerror = async () => {
if (g !== gen) return;
if (!retried && renew && await renew() && g === gen) {
retried = true;
const at = el.currentTime;
el.src = t.src;
el.currentTime = at;
el.play().catch(() => {});
return;
}
go(cur + 1, 0);
};
el.play().catch(() => { if (g === gen) setPlaying(false); });
}
buffer(i + 1);
session();
}
function position() {
if (cur < 0) return 0;
if (mode === "buffer") return Math.max(0, ctx.currentTime - startedAt);
if (mode === "stream") return el.currentTime;
return resumeAt;
}
async function toggle() {
wake(playing ? null : queue[Math.max(cur, 0)]);
if (!queue.length) return;
if (mode === null) return go(Math.max(cur, 0), resumeAt);
if (playing) {
playing = false;
if (mode === "buffer") { out.pause(); await ctx.suspend(); } else el.pause();
} else {
playing = true;
if (mode === "buffer") { if (dest) out.play().catch(() => {}); await ctx.resume(); } else await el.play().catch(() => {});
}
notify();
}
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
const on = (action, fn) => { try { ms.setActionHandler(action, fn); } catch {} };
on("play", () => { if (!playing) toggle(); });
on("pause", () => { if (playing) toggle(); });
on("previoustrack", () => api.prev());
on("nexttrack", () => api.next());
on("seekto", (d) => api.seek(d.seekTime));
}
const api = {
state,
position,
toggle,
set(tracks, i = -1, from = 0) {
stop();
queue = tracks.slice();
cur = i < queue.length ? i : -1;
resumeAt = cur >= 0 ? from : 0;
setPlaying(false);
},
play(tracks, i = 0) { wake(tracks[i]); queue = tracks.slice(); go(i, 0); },
playNow(tracks) { wake(tracks[0]); queue.splice(cur + 1, 0, ...tracks); go(cur + 1, 0); },
playNext(tracks) {
queue.splice(cur + 1, 0, ...tracks);
if (mode === "buffer") requeue();
notify();
},
append(tracks) {
const wasLast = cur === queue.length - 1;
queue.push(...tracks);
if (wasLast && mode === "buffer") requeue();
notify();
},
playAt(i, from = 0) { wake(queue[i]); go(i, from); },
remove(i) {
if (i < 0 || i >= queue.length) return;
queue.splice(i, 1);
if (i === cur) { if (playing) go(i, 0); else { stop(); cur = i < queue.length ? i : -1; resumeAt = 0; notify(); } return; }
if (i < cur) cur--;
else if (i === cur + 1 && mode === "buffer") requeue();
notify();
},
clear() { stop(); queue = []; cur = -1; resumeAt = 0; setPlaying(false); },
next() { wake(queue[cur + 1]); if (cur + 1 < queue.length) go(cur + 1, 0); },
prev() { wake(queue[Math.max(cur - 1, 0)]); go(position() > 3 || cur <= 0 ? Math.max(cur, 0) : cur - 1, 0); },
seek(s) {
if (cur < 0) return;
wake(queue[cur]);
if (mode === null) { resumeAt = s; notify(); return; }
go(cur, s);
},
};
return api;
};