"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 DOUBLE_TAP_MS = 300;
const FILTERS_KEY = "pl8inf.filters";
const HINT_KEY = "pl8inf.hintShown";
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 filtersBtn = document.getElementById("filters-btn");
const likesPanel = document.getElementById("likes");
const likesBtn = document.getElementById("likes-btn");
const accountPanel = document.getElementById("account");
const accountBtn = document.getElementById("account-btn");
const startEl = document.getElementById("start");
const helpEl = document.getElementById("help");
const toastEl = document.getElementById("toast");
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 dragging = false; // waveform seek in progress
let filters = loadFilters();
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
// 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 = Object.keys(filters).length
? "nothing new matches your filters right now. loosen them, or check back in a bit."
: "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((t) => appendCard(buildCard(t)));
} 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, "");
const trackUrl = (t) => `${PL8}/${t.handle}/${t.slug}`;
// Images come through our server, so pl8list never sees the visitor's address.
const artistImg = (t) => `/img/${encodeURIComponent(t.artist_img)}`;
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
let toastTimer = 0;
function toast(text, ms = 2200) {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), ms);
}
// Cards -----------------------------------------------------------------
function buildCard(track, { liked = false } = {}) {
const el = tpl.content.firstElementChild.cloneNode(true);
const img = track.artist_img ? `url("${artistImg(track)}")` : "";
el.querySelector(".bg").style.backgroundImage = img;
if (img) el.querySelector(".art").style.backgroundImage = img;
else el.querySelector(".initial").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 = trackUrl(track);
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 };
el.querySelector(".like").classList.toggle("on", liked);
showFollow(card);
el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
el.querySelector(".follow").addEventListener("click", () => setFollow(card, !card.track.followed));
el.querySelector(".dislike").addEventListener("click", () => dislike(card));
el.querySelector(".share").addEventListener("click", () => share(card));
el.querySelector(".next").addEventListener("click", () => go(cards.indexOf(card) + 1));
bindWaveform(card);
return card;
}
function appendCard(card) {
cards.push(card);
feed.append(card.el);
observer.observe(card.el);
drawWave(card, 0);
}
function insertNext(card) {
const ref = cards[active];
cards.splice(active + 1, 0, card);
if (ref) ref.el.after(card.el);
else feed.prepend(card.el);
observer.observe(card.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);
}
}
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
function bindWaveform(card) {
const { canvas } = card;
const wrap = canvas.parentElement;
const tip = wrap.querySelector(".wave-tip");
const duration = () => (card === cards[active] && audio.duration) || card.track.duration || 0;
const fraction = (e) => {
const r = canvas.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
};
const showTip = (f) => {
tip.textContent = fmtTime(f * duration());
tip.style.left = `${f * 100}%`;
};
canvas.addEventListener("pointerdown", (e) => {
if (card !== cards[active] || !audio.duration) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
dragging = true;
wrap.classList.add("hover");
showTip(fraction(e));
drawWave(card, fraction(e));
});
canvas.addEventListener("pointermove", (e) => {
if (dragging) drawWave(card, fraction(e));
if (dragging || e.pointerType === "mouse") {
wrap.classList.add("hover");
showTip(fraction(e));
}
});
const finish = (e) => {
if (!dragging) return;
dragging = false;
if (e.type === "pointerup") audio.currentTime = fraction(e) * audio.duration;
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
};
canvas.addEventListener("pointerup", finish);
canvas.addEventListener("pointercancel", finish);
canvas.addEventListener("pointerleave", () => { if (!dragging) wrap.classList.remove("hover"); });
}
// 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 playbackFailed() {
toast("couldn't play this one, skipping");
go(active + 1);
}
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);
playbackFailed();
}
});
hls.loadSource(url);
hls.attachMedia(audio);
} else {
audio.src = url; // Safari plays HLS natively
}
audio.play().catch(() => {});
updateMediaSession(track);
}
function togglePlay() {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
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();
if (cards[active]) cards[active].el.classList.remove("paused", "buffering");
active = idx;
listened = 0;
lastTime = 0;
reported = false;
if (started) play(cards[idx].track);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
}
// Activate directly as well as scrolling: with the screen locked the
// IntersectionObserver may not fire, but lock-screen "next" must still work.
function go(idx) {
if (idx < 0) return;
if (!cards[idx]) {
loadMore();
return;
}
activate(idx);
cards[idx].el.scrollIntoView({ behavior: document.hidden ? "instant" : "smooth" });
}
function setLike(card, liked) {
if (card.liked === liked) return;
card.liked = liked;
const btn = card.el.querySelector(".like");
btn.classList.toggle("on", liked);
if (liked) {
btn.classList.remove("pop");
void btn.offsetWidth; // restart the animation
btn.classList.add("pop");
}
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
sendEvent(card.track, liked ? "like" : "unlike");
}
function showFollow(card) {
const btn = card.el.querySelector(".follow");
btn.classList.toggle("on", !!card.track.followed);
btn.textContent = card.track.followed ? "following" : "follow";
}
async function setFollow(card, follow) {
const { handle } = card.track;
// Every card by this artist changes together.
const update = (on) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
c.track.followed = on;
showFollow(c);
});
update(follow);
try {
await postJSON("/local/follow", { handle, follow });
toast(follow ? `following ${card.track.artist_name}` : `unfollowed ${card.track.artist_name}`);
} catch (err) {
update(!follow);
toast(err.message, 4000);
}
}
function dislike(card) {
sendEvent(card.track, "dislike");
if (card === cards[active]) reported = true; // a dislike says more than a skip
if (card.liked) setLike(card, false);
toast("got it, less like this");
go(cards.indexOf(card) + 1);
}
async function share(card) {
const { track } = card;
const url = trackUrl(track);
if (navigator.share && isTouch) {
try {
await navigator.share({ title: cleanTitle(track.title), text: `${cleanTitle(track.title)} by ${track.artist_name}`, url });
} catch { /* dismissed */ }
return;
}
try {
await navigator.clipboard.writeText(url);
toast("link copied");
} catch {
toast(url, 4000);
}
}
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) {
if (!dragging) drawWave(card, t / audio.duration);
card.el.querySelector(".cur").textContent = fmtTime(t);
}
updatePosition();
});
audio.addEventListener("ended", () => {
if (active >= 0 && !reported) {
sendEvent(cards[active].track, "complete", listened);
reported = true;
}
go(active + 1);
});
audio.addEventListener("play", () => {
cards[active]?.el.classList.remove("paused");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
});
audio.addEventListener("pause", () => {
if (!audio.ended) cards[active]?.el.classList.add("paused");
cards[active]?.el.classList.remove("buffering");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
});
audio.addEventListener("waiting", () => cards[active]?.el.classList.add("buffering"));
audio.addEventListener("playing", () => cards[active]?.el.classList.remove("buffering"));
audio.addEventListener("error", () => { if (!hls && started && audio.src) playbackFailed(); });
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 });
// Lock screen, notification and headphone controls ------------------------
function updateMediaSession(track) {
if (!("mediaSession" in navigator)) return;
const artwork = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
if (track.artist_img) artwork.unshift({ src: artistImg(track), sizes: "512x512" });
navigator.mediaSession.metadata = new MediaMetadata({
title: cleanTitle(track.title),
artist: track.artist_name,
album: "pl8list ∞",
artwork,
});
}
let lastPositionUpdate = 0;
function updatePosition() {
if (!("mediaSession" in navigator) || !navigator.mediaSession.setPositionState) return;
const now = performance.now();
if (now - lastPositionUpdate < 1000 || !Number.isFinite(audio.duration)) return;
lastPositionUpdate = now;
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
position: Math.min(audio.currentTime, audio.duration),
playbackRate: audio.playbackRate || 1,
});
} catch { /* duration not known yet */ }
}
if ("mediaSession" in navigator) {
const handlers = {
play: () => audio.play().catch(() => {}),
pause: () => audio.pause(),
nexttrack: () => go(active + 1),
previoustrack: () => (audio.currentTime > 5 ? (audio.currentTime = 0) : go(active - 1)),
seekto: (d) => { audio.currentTime = d.seekTime; },
seekforward: (d) => { audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + (d.seekOffset || 10)); },
seekbackward: (d) => { audio.currentTime = Math.max(0, audio.currentTime - (d.seekOffset || 10)); },
};
for (const [action, fn] of Object.entries(handlers)) {
try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ }
}
}
// Tap to play/pause, double-tap to like ------------------------------------
let lastTap = { at: 0, card: null, toggled: false };
let swallowClick = false; // the tap that only closed a panel
function flash(card, playing) {
const f = document.createElement("div");
f.className = "flash";
f.innerHTML = ``;
card.el.append(f);
f.addEventListener("animationend", () => f.remove());
}
function burst(card, e) {
const r = card.el.getBoundingClientRect();
const b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
b.classList.add("burst");
b.innerHTML = '';
b.style.left = `${e.clientX - r.left}px`;
b.style.top = `${e.clientY - r.top}px`;
b.style.setProperty("--tilt", `${Math.round(Math.random() * 30 - 15)}deg`);
card.el.append(b);
b.addEventListener("animationend", () => b.remove());
}
feed.addEventListener("click", (e) => {
if (swallowClick) { swallowClick = false; return; }
if (e.target.closest("button, a, input, .wave-wrap")) return;
const el = e.target.closest(".card");
const card = el && cards.find((c) => c.el === el);
if (!card || !started) return;
const now = performance.now();
if (lastTap.card === card && now - lastTap.at < DOUBLE_TAP_MS) {
// Second tap: undo the play/pause from the first one and like instead.
if (lastTap.toggled) {
togglePlay();
el.querySelectorAll(".flash").forEach((f) => f.remove());
}
burst(card, e);
setLike(card, true);
lastTap = { at: 0, card: null, toggled: false };
return;
}
const toggled = card === cards[active];
if (toggled) {
togglePlay();
flash(card, !audio.paused);
}
lastTap = { at: now, card, toggled };
});
// Panels and overlays -----------------------------------------------------
const panels = [[filtersForm, filtersBtn], [likesPanel, likesBtn], [accountPanel, accountBtn]];
function closePanels() {
for (const [panel, btn] of panels) {
panel.hidden = true;
btn.setAttribute("aria-expanded", "false");
}
}
function togglePanel(panel) {
const open = panel.hidden;
closePanels();
if (open) {
panel.hidden = false;
panels.find(([p]) => p === panel)[1].setAttribute("aria-expanded", "true");
}
return open;
}
document.addEventListener("pointerdown", (e) => {
const open = panels.find(([p]) => !p.hidden);
if (!open || open[0].contains(e.target) || e.target.closest("#bar")) return;
closePanels();
swallowClick = true;
setTimeout(() => { swallowClick = false; }, 500);
}, true);
function showHelp() { helpEl.hidden = false; }
function hideHelp() { helpEl.hidden = true; }
document.getElementById("help-btn").addEventListener("click", showHelp);
document.getElementById("help-close").addEventListener("click", hideHelp);
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
function start() {
if (started) return;
started = true;
startEl.hidden = true;
if (cards[active]) play(cards[active].track);
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, "1");
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
: "click to pause · double-click to like · ? for shortcuts", 5000);
}
}
startEl.addEventListener("click", start);
// Likes -----------------------------------------------------------------
likesBtn.addEventListener("click", async () => {
if (!togglePanel(likesPanel)) 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 btn = document.createElement("button");
btn.type = "button";
btn.className = "play-now";
btn.title = "play now";
btn.innerHTML = '';
btn.addEventListener("click", () => {
closePanels();
if (!started) start();
insertNext(buildCard(t, { liked: true }));
go(active + 1);
});
const meta = document.createElement("div");
meta.className = "meta";
const a = document.createElement("a");
a.href = trackUrl(t);
a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = t.artist_name;
meta.append(a, by);
li.append(btn, meta);
return li;
}));
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
});
// Filters ---------------------------------------------------------------
function loadFilters() {
try {
return JSON.parse(localStorage.getItem(FILTERS_KEY)) || {};
} catch {
return {};
}
}
function showFilters() {
filtersForm.free.checked = filters.free === "1";
filtersForm.bpm_min.value = filters.bpm_min || "";
filtersForm.bpm_max.value = filters.bpm_max || "";
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
}
function applyFilters() {
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");
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
// 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();
}
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
document.getElementById("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
showFilters();
// Keyboard --------------------------------------------------------------
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 === "Escape") {
closePanels();
hideHelp();
return;
}
if (!loginEl.hidden) return;
if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
return;
}
const card = cards[active];
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(); togglePlay(); if (card) flash(card, !audio.paused); }
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" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "m") { audio.muted = !audio.muted; toast(audio.muted ? "muted" : "sound on"); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
});
window.addEventListener("resize", () => cards.forEach((c, i) =>
drawWave(c, i === active && audio.duration ? audio.currentTime / audio.duration : 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");
const loginCancel = document.getElementById("login-cancel");
let signedInUser = null;
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 Object.assign(new Error(body.error || `request failed (${res.status})`), { status: res.status });
return body;
}
// `resync`: already signed in, signing in again only to sync with pl8list.
function showLogin({ resync = false } = {}) {
closePanels();
document.getElementById("bar").hidden = true;
startEl.hidden = true;
loginEl.hidden = false;
emailForm.hidden = false;
codeForm.hidden = true;
loginCancel.hidden = !resync;
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
document.getElementById("email").focus();
}
loginCancel.addEventListener("click", () => signedIn(signedInUser));
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, connected } = await postJSON("/auth/verify", {
code: codeForm.code.value,
connect: codeForm.connect.checked,
});
signedIn(user, { connected, synced_at: Date.now() / 1000 });
toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
});
});
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);
// Account -----------------------------------------------------------------
const deleteForm = document.getElementById("delete-form");
const syncStatus = document.getElementById("sync-status");
const syncBtn = document.getElementById("sync-btn");
const disconnectBtn = document.getElementById("disconnect-btn");
function ago(ts) {
const m = Math.round((Date.now() / 1000 - ts) / 60);
if (m < 1) return "just now";
if (m < 60) return `${m} min ago`;
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
return `${Math.round(m / 1440)} days ago`;
}
function showSync(pl8list) {
const connected = !!pl8list?.connected;
syncStatus.classList.toggle("on", connected);
syncStatus.textContent = connected
? `connected on this device${pl8list.synced_at ? `, last sync ${ago(pl8list.synced_at)}` : ""}. ` +
"Likes and follows are saved to your pl8list library."
: "not connected. Resync signs you in with pl8list once to read your feed and library.";
disconnectBtn.hidden = !connected;
}
syncBtn.addEventListener("click", async () => {
try {
await postJSON("/account/pl8list/sync");
toast("syncing with pl8list…");
showSync({ connected: true, synced_at: Date.now() / 1000 });
} catch (err) {
if (err.status === 409) showLogin({ resync: true });
else toast(err.message, 4000);
}
});
disconnectBtn.addEventListener("click", async () => {
try {
await postJSON("/account/pl8list/disconnect");
showSync({ connected: false });
toast("disconnected from pl8list");
} catch (err) {
toast(err.message, 4000);
}
});
accountBtn.addEventListener("click", async () => {
if (!togglePanel(accountPanel)) return;
deleteForm.hidden = true;
const res = await fetch("/local/me");
if (res.ok) showSync((await res.json()).pl8list);
});
document.getElementById("logout-btn").addEventListener("click", async () => {
await postJSON("/auth/logout");
location.reload();
});
document.getElementById("logout-all-btn").addEventListener("click", async () => {
await postJSON("/account/logout-all");
location.reload();
});
document.getElementById("delete-open").addEventListener("click", () => {
deleteForm.hidden = false;
deleteForm.confirm.value = "";
deleteForm.confirm.focus();
});
deleteForm.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await postJSON("/account/delete", { confirm: deleteForm.confirm.value.trim() });
localStorage.removeItem(FILTERS_KEY);
localStorage.removeItem(HINT_KEY);
location.reload();
} catch (err) {
toast(err.message, 4000);
}
});
function signedIn(user, pl8list) {
signedInUser = user;
if (pl8list) showSync(pl8list);
loginEl.hidden = true;
document.getElementById("bar").hidden = false;
accountBtn.textContent = `@${user.handle}`;
document.getElementById("account-handle").textContent = `@${user.handle}`;
document.getElementById("delete-handle").textContent = user.handle;
// Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start();
else if (!started) startEl.hidden = false;
if (!cards.length) loadMore();
}
(async () => {
const res = await fetch("/local/me");
if (res.ok) {
const me = await res.json();
signedIn(me.user, me.pl8list);
}
else showLogin();
})();