mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 16:47:00 +01:00
Add tap controls, lock-screen playback and other comfort features
- Tap/click a track to play or pause, double-tap/double-click to like with a heart burst; any key or tap starts playback - Media Session API: lock-screen, notification and headphone controls, with next/previous working while the screen is locked - Drag the waveform to seek, hover shows the time - "Not for me" button feeds a dislike signal into the ranker - Share button (native share sheet or copy link) - Likes newest first, each playable right away - Filters remembered per browser, clear button, active indicator - Panels close on outside click or Esc; help overlay on "?" - Buffering spinner, paused badge, toast messages, first-run hint - Installable as an app (manifest and icons) - Refreshed README screenshots and controls
This commit is contained in:
1 parent
4a7f6903a3
commit
87af563a95
16 files changed
+731
-112
No files matched your search
+385
-52
@@ -4,12 +4,21 @@ 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 startEl = document.getElementById("start");
|
||||
const helpEl = document.getElementById("help");
|
||||
const toastEl = document.getElementById("toast");
|
||||
|
||||
const cards = []; // {track, el, canvas, liked}
|
||||
let active = -1;
|
||||
@@ -19,7 +28,10 @@ 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 = {};
|
||||
let dragging = false; // waveform seek in progress
|
||||
let filters = loadFilters();
|
||||
|
||||
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
|
||||
// Server ----------------------------------------------------------------
|
||||
|
||||
@@ -49,12 +61,14 @@ async function loadMore() {
|
||||
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.";
|
||||
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(addCard);
|
||||
data.tracks.forEach((t) => appendCard(buildCard(t)));
|
||||
} catch (err) {
|
||||
status.textContent = "failed to load: " + err;
|
||||
setTimeout(() => status.remove(), 5000);
|
||||
@@ -71,24 +85,31 @@ function showStats(s) {
|
||||
|
||||
// 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}`;
|
||||
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 -----------------------------------------------------------------
|
||||
|
||||
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
function addCard(track) {
|
||||
function buildCard(track, { liked = false } = {}) {
|
||||
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();
|
||||
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 = `${PL8}/${track.handle}/${track.slug}`;
|
||||
el.querySelector(".open").href = trackUrl(track);
|
||||
el.querySelector(".desc").textContent = track.description || "";
|
||||
el.querySelector(".dur").textContent = track.duration ? fmtTime(track.duration) : "";
|
||||
|
||||
@@ -108,19 +129,30 @@ function addCard(track) {
|
||||
`${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;
|
||||
const card = { track, el, canvas: el.querySelector(".wave"), liked };
|
||||
el.querySelector(".like").classList.toggle("on", liked);
|
||||
|
||||
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;
|
||||
});
|
||||
el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
|
||||
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;
|
||||
}
|
||||
|
||||
feed.append(el);
|
||||
observer.observe(el);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -144,6 +176,48 @@ function drawWave(card, progress) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -153,6 +227,11 @@ function streamUrl(t) {
|
||||
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()) {
|
||||
@@ -161,7 +240,7 @@ function play(track) {
|
||||
hls.on(Hls.Events.ERROR, (_, d) => {
|
||||
if (d.fatal) {
|
||||
console.warn("hls fatal", d);
|
||||
go(active + 1);
|
||||
playbackFailed();
|
||||
}
|
||||
});
|
||||
hls.loadSource(url);
|
||||
@@ -170,6 +249,12 @@ function play(track) {
|
||||
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() {
|
||||
@@ -187,6 +272,7 @@ function finishActive() {
|
||||
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;
|
||||
@@ -195,17 +281,55 @@ function activate(idx) {
|
||||
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 (cards[idx]) cards[idx].el.scrollIntoView({ behavior: "smooth" });
|
||||
else loadMore();
|
||||
if (idx < 0) return;
|
||||
if (!cards[idx]) {
|
||||
loadMore();
|
||||
return;
|
||||
}
|
||||
activate(idx);
|
||||
cards[idx].el.scrollIntoView({ behavior: document.hidden ? "instant" : "smooth" });
|
||||
}
|
||||
|
||||
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");
|
||||
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 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", () => {
|
||||
@@ -215,9 +339,10 @@ audio.addEventListener("timeupdate", () => {
|
||||
lastTime = t;
|
||||
const card = cards[active];
|
||||
if (card && audio.duration) {
|
||||
drawWave(card, t / audio.duration);
|
||||
if (!dragging) drawWave(card, t / audio.duration);
|
||||
card.el.querySelector(".cur").textContent = fmtTime(t);
|
||||
}
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
@@ -228,67 +353,271 @@ audio.addEventListener("ended", () => {
|
||||
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 });
|
||||
|
||||
// UI --------------------------------------------------------------------
|
||||
// Lock screen, notification and headphone controls ------------------------
|
||||
|
||||
document.querySelector("#start button").addEventListener("click", () => {
|
||||
started = true;
|
||||
document.getElementById("start").hidden = true;
|
||||
if (cards[active]) play(cards[active].track);
|
||||
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: `${PL8}/img/${track.artist_img}`, 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 };
|
||||
});
|
||||
|
||||
document.getElementById("filters-btn").addEventListener("click", () => { filtersForm.hidden = !filtersForm.hidden; });
|
||||
// Panels and overlays -----------------------------------------------------
|
||||
|
||||
document.getElementById("likes-btn").addEventListener("click", async () => {
|
||||
const panel = document.getElementById("likes");
|
||||
panel.hidden = !panel.hidden;
|
||||
if (panel.hidden) return;
|
||||
const panels = [[filtersForm, filtersBtn], [likesPanel, likesBtn]];
|
||||
|
||||
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 = `${PL8}/${t.handle}/${t.slug}`;
|
||||
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}`;
|
||||
li.append(a, by);
|
||||
by.textContent = t.artist_name;
|
||||
meta.append(a, by);
|
||||
li.append(btn, meta);
|
||||
return li;
|
||||
}));
|
||||
if (!tracks.length) list.textContent = "nothing liked yet";
|
||||
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
|
||||
});
|
||||
|
||||
filtersForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
// 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");
|
||||
filtersForm.hidden = true;
|
||||
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(); audio.paused ? audio.play() : audio.pause(); }
|
||||
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" && active >= 0) toggleLike(active);
|
||||
else if (k === "l" && card) setLike(card, !card.liked);
|
||||
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) => drawWave(c, 0)));
|
||||
window.addEventListener("resize", () => cards.forEach((c, i) =>
|
||||
drawWave(c, i === active && audio.duration ? audio.currentTime / audio.duration : 0)));
|
||||
window.addEventListener("pagehide", finishActive);
|
||||
|
||||
// Sign-in -----------------------------------------------------------------
|
||||
@@ -311,6 +640,7 @@ async function postJSON(url, data) {
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("bar").hidden = true;
|
||||
startEl.hidden = true;
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
codeForm.hidden = true;
|
||||
@@ -371,6 +701,9 @@ function signedIn(user) {
|
||||
loginEl.hidden = true;
|
||||
document.getElementById("bar").hidden = false;
|
||||
document.getElementById("logout-btn").textContent = `sign out @${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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user