Add pl8list infinite-scroll app with pl8list sign-in

Endless TikTok-style feed over pl8list's Remix loaders. A guest crawler
builds a shared catalogue; a per-user ranker learns from likes, full
listens and skips. Users sign in with pl8list's email code; only a hash
of the local session token is stored. Ships as a hardened Docker image
with a Traefik override for pl8.narl.io.
This commit is contained in:
nvrl committed 2026-10-31 20:22:27 +01:00
commit 4a7f6903a3
20 files changed
+2192

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
"use strict";
const PL8 = "https://pl8list.com";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
const COMPLETE_RATIO = 0.85;
const feed = document.getElementById("feed");
const audio = document.getElementById("audio");
const tpl = document.getElementById("card-tpl");
const statsEl = document.getElementById("stats");
const filtersForm = document.getElementById("filters");
const cards = []; // {track, el, canvas, liked}
let active = -1;
let loading = false;
let started = false;
let hls = null;
let listened = 0; // seconds actually heard on the active card
let lastTime = 0;
let reported = false; // final event already sent for the active card
let filters = {};
// Server ----------------------------------------------------------------
function sendEvent(track, kind, seconds) {
const body = JSON.stringify({ track_id: track.id, kind, seconds });
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
}
}
async function loadMore() {
if (loading) return;
loading = true;
const status = document.createElement("div");
status.className = "status";
status.textContent = cards.length ? "loading…" : "crawling pl8list, first tracks in a few seconds…";
feed.append(status);
try {
const params = new URLSearchParams({ n: "10", ...filters });
const res = await fetch("/local/feed?" + params);
if (res.status === 401) {
status.remove();
loading = false;
showLogin();
return;
}
const data = await res.json();
showStats(data.stats);
if (!data.tracks.length) {
status.textContent = "nothing new right now, crawler is still digging. scroll again in a bit.";
setTimeout(() => { status.remove(); loading = false; }, 8000);
return;
}
status.remove();
data.tracks.forEach(addCard);
} catch (err) {
status.textContent = "failed to load: " + err;
setTimeout(() => status.remove(), 5000);
}
loading = false;
}
let stats = null;
function showStats(s) {
if (s) stats = s;
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
}
// Uploads are often titled after their file name.
const cleanTitle = (t) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
// Cards -----------------------------------------------------------------
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
function addCard(track) {
const el = tpl.content.firstElementChild.cloneNode(true);
const img = track.artist_img ? `url("${PL8}/img/${track.artist_img}")` : "";
el.querySelector(".bg").style.backgroundImage = img;
const art = el.querySelector(".art");
if (img) art.style.backgroundImage = img;
else art.textContent = (track.artist_name || "?")[0].toUpperCase();
el.querySelector(".title").textContent = cleanTitle(track.title);
const artist = el.querySelector(".artist");
artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : "");
artist.href = `${PL8}/${track.handle}`;
el.querySelector(".open").href = `${PL8}/${track.handle}/${track.slug}`;
el.querySelector(".desc").textContent = track.description || "";
el.querySelector(".dur").textContent = track.duration ? fmtTime(track.duration) : "";
const chips = el.querySelector(".chips");
const chip = (text, cls) => {
const s = document.createElement("span");
s.textContent = text;
if (cls) s.className = cls;
chips.append(s);
};
if (track.bpm) chip(`${Math.round(track.bpm)} bpm`);
if (track.key) chip(track.key);
chip(track.price ? `$${track.price}` : "free", track.price ? "" : "free");
if (track.preview) chip("preview", "preview");
el.querySelector(".counts").textContent =
`${track.saves} saves · ${track.downloads} downloads · ${track.comments} comments` +
(track.purchases ? ` · ${track.purchases} sold` : "");
const card = { track, el, canvas: el.querySelector(".wave"), liked: false };
const idx = cards.push(card) - 1;
el.querySelector(".like").addEventListener("click", () => toggleLike(idx));
el.querySelector(".next").addEventListener("click", () => go(idx + 1));
card.canvas.addEventListener("click", (e) => {
if (idx !== active || !audio.duration) return;
const r = card.canvas.getBoundingClientRect();
audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration;
});
feed.append(el);
observer.observe(el);
drawWave(card, 0);
}
function drawWave(card, progress) {
const { canvas, track } = card;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w) return;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== w * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = track.waveform || [];
const bars = Math.max(1, Math.floor(w / 3));
const max = Math.max(1, ...data);
for (let i = 0; i < bars; i++) {
const v = data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
const bh = Math.max(2, v * h);
ctx.fillStyle = i / bars < progress ? "#ff4d6d" : "rgba(255,255,255,0.35)";
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
}
}
// Playback --------------------------------------------------------------
function streamUrl(t) {
const p = new URLSearchParams();
if (t.preview) p.set("preview", "true");
if (t.upload_id) p.set("v", t.upload_id);
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
}
function play(track) {
const url = streamUrl(track);
if (window.Hls && Hls.isSupported()) {
if (hls) hls.destroy();
hls = new Hls();
hls.on(Hls.Events.ERROR, (_, d) => {
if (d.fatal) {
console.warn("hls fatal", d);
go(active + 1);
}
});
hls.loadSource(url);
hls.attachMedia(audio);
} else {
audio.src = url; // Safari plays HLS natively
}
audio.play().catch(() => {});
}
function finishActive() {
if (active < 0 || reported) return;
reported = true;
// Flicking past a card before any audio played says nothing about the track.
if (listened < 1) return;
const { track } = cards[active];
const dur = audio.duration || track.duration || 0;
if (dur && listened / dur >= COMPLETE_RATIO) sendEvent(track, "complete", listened);
else if (listened < SKIP_SECONDS) sendEvent(track, "skip", listened);
else sendEvent(track, "play", listened);
}
function activate(idx) {
if (idx === active || !cards[idx]) return;
finishActive();
active = idx;
listened = 0;
lastTime = 0;
reported = false;
if (started) play(cards[idx].track);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
}
function go(idx) {
if (cards[idx]) cards[idx].el.scrollIntoView({ behavior: "smooth" });
else loadMore();
}
function toggleLike(idx) {
const card = cards[idx];
card.liked = !card.liked;
card.el.querySelector(".like").classList.toggle("on", card.liked);
if (stats) { stats.liked += card.liked ? 1 : -1; showStats(); }
sendEvent(card.track, card.liked ? "like" : "unlike");
}
audio.addEventListener("timeupdate", () => {
const t = audio.currentTime;
const d = t - lastTime;
if (d > 0 && d < 1.5) listened += d; // ignore seeks
lastTime = t;
const card = cards[active];
if (card && audio.duration) {
drawWave(card, t / audio.duration);
card.el.querySelector(".cur").textContent = fmtTime(t);
}
});
audio.addEventListener("ended", () => {
if (active >= 0 && !reported) {
sendEvent(cards[active].track, "complete", listened);
reported = true;
}
go(active + 1);
});
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target));
}
}, { root: feed, threshold: 0.6 });
// UI --------------------------------------------------------------------
document.querySelector("#start button").addEventListener("click", () => {
started = true;
document.getElementById("start").hidden = true;
if (cards[active]) play(cards[active].track);
});
document.getElementById("filters-btn").addEventListener("click", () => { filtersForm.hidden = !filtersForm.hidden; });
document.getElementById("likes-btn").addEventListener("click", async () => {
const panel = document.getElementById("likes");
panel.hidden = !panel.hidden;
if (panel.hidden) return;
const list = document.getElementById("likes-list");
const { tracks } = await (await fetch("/local/likes")).json();
list.replaceChildren(...tracks.map((t) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = `${PL8}/${t.handle}/${t.slug}`;
a.target = "_blank";
a.textContent = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = ` · ${t.artist_name}`;
li.append(a, by);
return li;
}));
if (!tracks.length) list.textContent = "nothing liked yet";
});
filtersForm.addEventListener("submit", (e) => {
e.preventDefault();
const f = new FormData(filtersForm);
filters = {};
if (f.get("free")) filters.free = "1";
if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min");
if (f.get("bpm_max")) filters.bpm_max = f.get("bpm_max");
filtersForm.hidden = true;
// Drop cards below the current one so the new filter applies right away.
for (const c of cards.splice(active + 1)) { observer.unobserve(c.el); c.el.remove(); }
loadMore();
});
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
else if (k === " ") { e.preventDefault(); audio.paused ? audio.play() : audio.pause(); }
else if (k === "ArrowRight") audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10);
else if (k === "ArrowLeft") audio.currentTime = Math.max(0, audio.currentTime - 10);
else if (k === "l" && active >= 0) toggleLike(active);
});
window.addEventListener("resize", () => cards.forEach((c) => drawWave(c, 0)));
window.addEventListener("pagehide", finishActive);
// Sign-in -----------------------------------------------------------------
const loginEl = document.getElementById("login");
const emailForm = document.getElementById("email-form");
const codeForm = document.getElementById("code-form");
const loginError = document.getElementById("login-error");
async function postJSON(url, data) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data || {}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `request failed (${res.status})`);
return body;
}
function showLogin() {
document.getElementById("bar").hidden = true;
loginEl.hidden = false;
emailForm.hidden = false;
codeForm.hidden = true;
document.getElementById("email").focus();
}
async function withForm(form, fn) {
const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; });
loginError.textContent = "";
try {
await fn();
} catch (err) {
loginError.textContent = err.message;
}
buttons.forEach((b) => { b.disabled = false; });
}
emailForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(emailForm, async () => {
const { email } = await postJSON("/auth/start", { email: emailForm.email.value });
document.getElementById("sent-to").textContent = email;
emailForm.hidden = true;
codeForm.hidden = false;
codeForm.code.value = "";
codeForm.code.focus();
});
});
codeForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(codeForm, async () => {
const { user } = await postJSON("/auth/verify", { code: codeForm.code.value });
signedIn(user);
});
});
codeForm.code.addEventListener("input", () => {
if (/^\d{6}$/.test(codeForm.code.value)) codeForm.requestSubmit();
});
document.getElementById("resend").addEventListener("click", () => {
withForm(codeForm, async () => {
await postJSON("/auth/resend");
loginError.textContent = "new code sent";
});
});
document.getElementById("restart").addEventListener("click", showLogin);
document.getElementById("logout-btn").addEventListener("click", async () => {
await postJSON("/auth/logout");
location.reload();
});
function signedIn(user) {
loginEl.hidden = true;
document.getElementById("bar").hidden = false;
document.getElementById("logout-btn").textContent = `sign out @${user.handle}`;
if (!cards.length) loadMore();
}
(async () => {
const res = await fetch("/local/me");
if (res.ok) signedIn((await res.json()).user);
else showLogin();
})();