mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
At sign-in users can choose to stay connected to pl8list on that device. The pl8list session is then stored in the device's session row, encrypted with XChaCha20-Poly1305 under a key derived from the device's local token (only the browser has it) and a server key kept outside the database. It is only decrypted during that device's own requests, re-encrypted when the token rotates or pl8list sends a new cookie, and deleted when pl8list rejects it. Every sign-in now syncs the personal feed, saved tracks (imported as likes) and saved artists (imported as follows). Connected devices resync on use every six hours and push new likes and follows to the pl8list library with addOnly, so nothing is ever removed there. Without the connection, the resync button runs the email-code sign-in again. Artists can be followed from each card (f); follows feed the ranker. The account menu shows the sync state, resync and disconnect. Privacy policy, export and account deletion cover the new data.
840 lines
29 KiB
JavaScript
840 lines
29 KiB
JavaScript
"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 = `<svg><use href="#i-${playing ? "play" : "pause"}"/></svg>`;
|
|
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 = '<use href="#i-heart"/>';
|
|
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 = '<svg><use href="#i-play"/></svg>';
|
|
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();
|
|
})();
|