diff --git a/README.md b/README.md index 64bd442..c45db5d 100644 --- a/README.md +++ b/README.md @@ -59,15 +59,20 @@ local session token is stored. Logins are rate-limited to 5 per IP and 3 per ema ## Controls -| key | action | -| -------------- | ---------------- | -| ↓ / j, ↑ / k | next / previous | -| space | play / pause | -| ← / → | seek 10 s | -| l | like | +| phone | desktop | action | +| ---------------- | -------------------- | ------------------------------ | +| tap | click · space | play / pause | +| double-tap | double-click · l | like | +| swipe up / down | ↓ j · ↑ k | next / previous | +| drag waveform | drag waveform · ← → | seek | +| ✕ button | x | not for me (skip, show less) | +| share button | s | share / copy link | +| | m | mute | +| | ? · Esc | help · close panels | -Click the waveform to seek. "filters" sets free-only and a BPM range. "likes" lists everything -you liked, with links to pl8list. +Lock-screen, notification and headphone buttons control playback too (Media Session API). +"likes" lists your likes newest first; ▶ plays one right away. Filters (free only, BPM range) +are remembered per browser. The site can be installed to the home screen as an app. ## How it works @@ -96,7 +101,7 @@ one request every 20 seconds. When a user likes or finishes a track, its related its artist's catalogue are crawled first. **Ranking** (`ranker.py`). Each track the user has not seen yet gets a score from popularity, -freshness, artist affinity (from their likes, completions and skips), closeness to the BPM and +freshness, artist affinity (from their likes, completions, skips and "not for me"), closeness to the BPM and key of tracks they liked, whether it is related to something they liked, and some random exploration. Artists are then spread out so you don't get five tracks from one person in a row. Tune `WEIGHTS` at the top of the file. diff --git a/docs/feed-desktop.png b/docs/feed-desktop.png index f444b3f..7402ee8 100644 Binary files a/docs/feed-desktop.png and b/docs/feed-desktop.png differ diff --git a/docs/feed-mobile.png b/docs/feed-mobile.png index aa85024..c4407d2 100644 Binary files a/docs/feed-mobile.png and b/docs/feed-mobile.png differ diff --git a/docs/filters.png b/docs/filters.png index c6eb558..eed7e5b 100644 Binary files a/docs/filters.png and b/docs/filters.png differ diff --git a/docs/likes.png b/docs/likes.png index de93d94..f4440d2 100644 Binary files a/docs/likes.png and b/docs/likes.png differ diff --git a/ranker.py b/ranker.py index 448bd06..f76e952 100644 --- a/ranker.py +++ b/ranker.py @@ -29,7 +29,7 @@ WEIGHTS = { "exploration": 0.6, } # Per-event contributions to artist affinity. -EVENT_SIGNAL = {"like": 3.0, "unlike": -3.0, "complete": 1.5, "skip": -1.0} +EVENT_SIGNAL = {"like": 3.0, "unlike": -3.0, "complete": 1.5, "skip": -1.0, "dislike": -3.0} REPEAT_ARTIST_PENALTY = 1.5 RECENT_ARTIST_WINDOW = 6 @@ -60,7 +60,7 @@ def taste(db, user_id): rows = db.query(""" SELECT e.track_id, e.kind, e.seconds, t.handle, t.bpm, t.key FROM events e JOIN tracks t ON t.id = e.track_id - WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip') + WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip', 'dislike') ORDER BY e.ts """, (user_id,)) artist = defaultdict(float) diff --git a/server.py b/server.py index dfd9d08..9e8637c 100644 --- a/server.py +++ b/server.py @@ -37,13 +37,13 @@ from pl8list import Client, LoginError, LoginFlow ROOT = Path(__file__).resolve().parent STATIC = ROOT / "static" -EVENT_KINDS = {"play", "like", "unlike", "skip", "complete"} +EVENT_KINDS = {"play", "like", "unlike", "skip", "complete", "dislike"} # Only HLS playlists and segments are proxied, nothing else on pl8list. PROXY_RE = re.compile(r"^/api/[^/]+/[^/]+/hls/(playlist\.m3u8|segment/\d+)$") EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[^@\s]+$") CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", - ".png": "image/png", ".svg": "image/svg+xml", + ".png": "image/png", ".svg": "image/svg+xml", ".webmanifest": "application/manifest+json", } SESSION_COOKIE = "pl8inf" LOGIN_COOKIE = "pl8inf_login" @@ -353,9 +353,12 @@ class Handler(BaseHTTPRequestHandler): self.send_json({"tracks": tracks, "stats": stats}) def likes(self, user): - liked = ranker.taste(self.db, user["id"])["liked_ids"] - rows = self.db.query("SELECT * FROM tracks WHERE id IN (%s)" % ",".join("?" * len(liked)), list(liked)) \ - if liked else [] + liked = list(ranker.taste(self.db, user["id"])["liked_ids"]) + rows = self.db.query(""" + SELECT t.*, MAX(e.ts) AS liked_at FROM events e JOIN tracks t ON t.id = e.track_id + WHERE e.user_id = ? AND e.kind = 'like' AND t.id IN (%s) + GROUP BY t.id ORDER BY liked_at DESC + """ % ",".join("?" * len(liked)), [user["id"]] + liked) if liked else [] self.send_json({"tracks": [ranker.serialize(r) for r in rows]}) def proxy(self, url): diff --git a/static/app.js b/static/app.js index 9785f72..1dce9ef 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = ``; + 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 }; }); -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 = ''; + 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(); } diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..7d9dbd1 Binary files /dev/null and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..b335c92 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..3eb36ec Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png new file mode 100644 index 0000000..8cc2f55 Binary files /dev/null and b/static/icons/icon-maskable-512.png differ diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..ff39d33 --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/static/index.html b/static/index.html index 493a6b6..5e1f2af 100644 --- a/static/index.html +++ b/static/index.html @@ -4,17 +4,38 @@ pl8list ∞ + + + + + + + + + + + + @@ -45,44 +66,79 @@ - -