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:
nvrl committed 2026-10-31 20:49:47 +01:00
1 parent 4a7f6903a3
commit 87af563a95
16 files changed
+731 -112

No files matched your search

+14 -9
View File
@@ -59,15 +59,20 @@ local session token is stored. Logins are rate-limited to 5 per IP and 3 per ema
## Controls ## Controls
| key | action | | phone | desktop | action |
| -------------- | ---------------- | | ---------------- | -------------------- | ------------------------------ |
| ↓ / j, ↑ / k | next / previous | | tap | click · space | play / pause |
| space | play / pause | | double-tap | double-click · l | like |
| ← / → | seek 10 s | | swipe up / down | ↓ j · ↑ k | next / previous |
| l | like | | 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 Lock-screen, notification and headphone buttons control playback too (Media Session API).
you liked, with links to pl8list. "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 ## 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. its artist's catalogue are crawled first.
**Ranking** (`ranker.py`). Each track the user has not seen yet gets a score from popularity, **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 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 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. row. Tune `WEIGHTS` at the top of the file.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 163 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1007 KiB

After

Width:  |  Height:  |  Size: 471 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 505 KiB

+2 -2
View File
@@ -29,7 +29,7 @@ WEIGHTS = {
"exploration": 0.6, "exploration": 0.6,
} }
# Per-event contributions to artist affinity. # 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 REPEAT_ARTIST_PENALTY = 1.5
RECENT_ARTIST_WINDOW = 6 RECENT_ARTIST_WINDOW = 6
@@ -60,7 +60,7 @@ def taste(db, user_id):
rows = db.query(""" rows = db.query("""
SELECT e.track_id, e.kind, e.seconds, t.handle, t.bpm, t.key 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 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 ORDER BY e.ts
""", (user_id,)) """, (user_id,))
artist = defaultdict(float) artist = defaultdict(float)
+8 -5
View File
@@ -37,13 +37,13 @@ from pl8list import Client, LoginError, LoginFlow
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
STATIC = ROOT / "static" 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. # Only HLS playlists and segments are proxied, nothing else on pl8list.
PROXY_RE = re.compile(r"^/api/[^/]+/[^/]+/hls/(playlist\.m3u8|segment/\d+)$") PROXY_RE = re.compile(r"^/api/[^/]+/[^/]+/hls/(playlist\.m3u8|segment/\d+)$")
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[^@\s]+$")
CONTENT_TYPES = { CONTENT_TYPES = {
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".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" SESSION_COOKIE = "pl8inf"
LOGIN_COOKIE = "pl8inf_login" LOGIN_COOKIE = "pl8inf_login"
@@ -353,9 +353,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"tracks": tracks, "stats": stats}) self.send_json({"tracks": tracks, "stats": stats})
def likes(self, user): def likes(self, user):
liked = ranker.taste(self.db, user["id"])["liked_ids"] liked = list(ranker.taste(self.db, user["id"])["liked_ids"])
rows = self.db.query("SELECT * FROM tracks WHERE id IN (%s)" % ",".join("?" * len(liked)), list(liked)) \ rows = self.db.query("""
if liked else [] 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]}) self.send_json({"tracks": [ranker.serialize(r) for r in rows]})
def proxy(self, url): def proxy(self, url):
+385 -52
View File
@@ -4,12 +4,21 @@ const PL8 = "https://pl8list.com";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end 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 SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
const COMPLETE_RATIO = 0.85; 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 feed = document.getElementById("feed");
const audio = document.getElementById("audio"); const audio = document.getElementById("audio");
const tpl = document.getElementById("card-tpl"); const tpl = document.getElementById("card-tpl");
const statsEl = document.getElementById("stats"); const statsEl = document.getElementById("stats");
const filtersForm = document.getElementById("filters"); 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} const cards = []; // {track, el, canvas, liked}
let active = -1; let active = -1;
@@ -19,7 +28,10 @@ let hls = null;
let listened = 0; // seconds actually heard on the active card let listened = 0; // seconds actually heard on the active card
let lastTime = 0; let lastTime = 0;
let reported = false; // final event already sent for the active card 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 ---------------------------------------------------------------- // Server ----------------------------------------------------------------
@@ -49,12 +61,14 @@ async function loadMore() {
const data = await res.json(); const data = await res.json();
showStats(data.stats); showStats(data.stats);
if (!data.tracks.length) { 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); setTimeout(() => { status.remove(); loading = false; }, 8000);
return; return;
} }
status.remove(); status.remove();
data.tracks.forEach(addCard); data.tracks.forEach((t) => appendCard(buildCard(t)));
} catch (err) { } catch (err) {
status.textContent = "failed to load: " + err; status.textContent = "failed to load: " + err;
setTimeout(() => status.remove(), 5000); setTimeout(() => status.remove(), 5000);
@@ -71,24 +85,31 @@ function showStats(s) {
// Uploads are often titled after their file name. // Uploads are often titled after their file name.
const cleanTitle = (t) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, ""); 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 ----------------------------------------------------------------- // Cards -----------------------------------------------------------------
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; function buildCard(track, { liked = false } = {}) {
function addCard(track) {
const el = tpl.content.firstElementChild.cloneNode(true); const el = tpl.content.firstElementChild.cloneNode(true);
const img = track.artist_img ? `url("${PL8}/img/${track.artist_img}")` : ""; const img = track.artist_img ? `url("${PL8}/img/${track.artist_img}")` : "";
el.querySelector(".bg").style.backgroundImage = img; el.querySelector(".bg").style.backgroundImage = img;
const art = el.querySelector(".art"); if (img) el.querySelector(".art").style.backgroundImage = img;
if (img) art.style.backgroundImage = img; else el.querySelector(".initial").textContent = (track.artist_name || "?")[0].toUpperCase();
else art.textContent = (track.artist_name || "?")[0].toUpperCase();
el.querySelector(".title").textContent = cleanTitle(track.title); el.querySelector(".title").textContent = cleanTitle(track.title);
const artist = el.querySelector(".artist"); const artist = el.querySelector(".artist");
artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : ""); artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : "");
artist.href = `${PL8}/${track.handle}`; 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(".desc").textContent = track.description || "";
el.querySelector(".dur").textContent = track.duration ? fmtTime(track.duration) : ""; 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.saves} saves · ${track.downloads} downloads · ${track.comments} comments` +
(track.purchases ? ` · ${track.purchases} sold` : ""); (track.purchases ? ` · ${track.purchases} sold` : "");
const card = { track, el, canvas: el.querySelector(".wave"), liked: false }; const card = { track, el, canvas: el.querySelector(".wave"), liked };
const idx = cards.push(card) - 1; el.querySelector(".like").classList.toggle("on", liked);
el.querySelector(".like").addEventListener("click", () => toggleLike(idx)); el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
el.querySelector(".next").addEventListener("click", () => go(idx + 1)); el.querySelector(".dislike").addEventListener("click", () => dislike(card));
card.canvas.addEventListener("click", (e) => { el.querySelector(".share").addEventListener("click", () => share(card));
if (idx !== active || !audio.duration) return; el.querySelector(".next").addEventListener("click", () => go(cards.indexOf(card) + 1));
const r = card.canvas.getBoundingClientRect(); bindWaveform(card);
audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration; return card;
}); }
feed.append(el); function appendCard(card) {
observer.observe(el); 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); 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 -------------------------------------------------------------- // Playback --------------------------------------------------------------
function streamUrl(t) { function streamUrl(t) {
@@ -153,6 +227,11 @@ function streamUrl(t) {
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`; 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) { function play(track) {
const url = streamUrl(track); const url = streamUrl(track);
if (window.Hls && Hls.isSupported()) { if (window.Hls && Hls.isSupported()) {
@@ -161,7 +240,7 @@ function play(track) {
hls.on(Hls.Events.ERROR, (_, d) => { hls.on(Hls.Events.ERROR, (_, d) => {
if (d.fatal) { if (d.fatal) {
console.warn("hls fatal", d); console.warn("hls fatal", d);
go(active + 1); playbackFailed();
} }
}); });
hls.loadSource(url); hls.loadSource(url);
@@ -170,6 +249,12 @@ function play(track) {
audio.src = url; // Safari plays HLS natively audio.src = url; // Safari plays HLS natively
} }
audio.play().catch(() => {}); audio.play().catch(() => {});
updateMediaSession(track);
}
function togglePlay() {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
} }
function finishActive() { function finishActive() {
@@ -187,6 +272,7 @@ function finishActive() {
function activate(idx) { function activate(idx) {
if (idx === active || !cards[idx]) return; if (idx === active || !cards[idx]) return;
finishActive(); finishActive();
if (cards[active]) cards[active].el.classList.remove("paused", "buffering");
active = idx; active = idx;
listened = 0; listened = 0;
lastTime = 0; lastTime = 0;
@@ -195,17 +281,55 @@ function activate(idx) {
if (idx >= cards.length - PREFETCH_AHEAD) loadMore(); 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) { function go(idx) {
if (cards[idx]) cards[idx].el.scrollIntoView({ behavior: "smooth" }); if (idx < 0) return;
else loadMore(); if (!cards[idx]) {
loadMore();
return;
}
activate(idx);
cards[idx].el.scrollIntoView({ behavior: document.hidden ? "instant" : "smooth" });
} }
function toggleLike(idx) { function setLike(card, liked) {
const card = cards[idx]; if (card.liked === liked) return;
card.liked = !card.liked; card.liked = liked;
card.el.querySelector(".like").classList.toggle("on", card.liked); const btn = card.el.querySelector(".like");
if (stats) { stats.liked += card.liked ? 1 : -1; showStats(); } btn.classList.toggle("on", liked);
sendEvent(card.track, card.liked ? "like" : "unlike"); 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", () => { audio.addEventListener("timeupdate", () => {
@@ -215,9 +339,10 @@ audio.addEventListener("timeupdate", () => {
lastTime = t; lastTime = t;
const card = cards[active]; const card = cards[active];
if (card && audio.duration) { if (card && audio.duration) {
drawWave(card, t / audio.duration); if (!dragging) drawWave(card, t / audio.duration);
card.el.querySelector(".cur").textContent = fmtTime(t); card.el.querySelector(".cur").textContent = fmtTime(t);
} }
updatePosition();
}); });
audio.addEventListener("ended", () => { audio.addEventListener("ended", () => {
@@ -228,67 +353,271 @@ audio.addEventListener("ended", () => {
go(active + 1); 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) => { const observer = new IntersectionObserver((entries) => {
for (const e of entries) { for (const e of entries) {
if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target)); if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target));
} }
}, { root: feed, threshold: 0.6 }); }, { root: feed, threshold: 0.6 });
// UI -------------------------------------------------------------------- // Lock screen, notification and headphone controls ------------------------
document.querySelector("#start button").addEventListener("click", () => { function updateMediaSession(track) {
started = true; if (!("mediaSession" in navigator)) return;
document.getElementById("start").hidden = true; const artwork = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
if (cards[active]) play(cards[active].track); 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 panels = [[filtersForm, filtersBtn], [likesPanel, likesBtn]];
const panel = document.getElementById("likes");
panel.hidden = !panel.hidden; function closePanels() {
if (panel.hidden) return; 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 list = document.getElementById("likes-list");
const { tracks } = await (await fetch("/local/likes")).json(); const { tracks } = await (await fetch("/local/likes")).json();
list.replaceChildren(...tracks.map((t) => { list.replaceChildren(...tracks.map((t) => {
const li = document.createElement("li"); 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"); const a = document.createElement("a");
a.href = `${PL8}/${t.handle}/${t.slug}`; a.href = trackUrl(t);
a.target = "_blank"; a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title); a.textContent = cleanTitle(t.title);
const by = document.createElement("span"); const by = document.createElement("span");
by.textContent = ` · ${t.artist_name}`; by.textContent = t.artist_name;
li.append(a, by); meta.append(a, by);
li.append(btn, meta);
return li; 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) => { // Filters ---------------------------------------------------------------
e.preventDefault();
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); const f = new FormData(filtersForm);
filters = {}; filters = {};
if (f.get("free")) filters.free = "1"; if (f.get("free")) filters.free = "1";
if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min"); if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min");
if (f.get("bpm_max")) filters.bpm_max = f.get("bpm_max"); 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. // 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(); } for (const c of cards.splice(active + 1)) { observer.unobserve(c.el); c.el.remove(); }
loadMore(); 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) => { document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return; if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; 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); } if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
else if (k === "ArrowUp" || k === "k") { 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 === "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 === "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); window.addEventListener("pagehide", finishActive);
// Sign-in ----------------------------------------------------------------- // Sign-in -----------------------------------------------------------------
@@ -311,6 +640,7 @@ async function postJSON(url, data) {
function showLogin() { function showLogin() {
document.getElementById("bar").hidden = true; document.getElementById("bar").hidden = true;
startEl.hidden = true;
loginEl.hidden = false; loginEl.hidden = false;
emailForm.hidden = false; emailForm.hidden = false;
codeForm.hidden = true; codeForm.hidden = true;
@@ -371,6 +701,9 @@ function signedIn(user) {
loginEl.hidden = true; loginEl.hidden = true;
document.getElementById("bar").hidden = false; document.getElementById("bar").hidden = false;
document.getElementById("logout-btn").textContent = `sign out @${user.handle}`; 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(); if (!cards.length) loadMore();
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<radialGradient id="g" cx="30%" cy="20%" r="90%">
<stop offset="0" stop-color="#3a1019"/>
<stop offset="0.6" stop-color="#0b0b10"/>
<stop offset="1" stop-color="#05050a"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#g)"/>
<path d="M256 256c-38-44-62-66-98-66-37 0-66 29-66 66s29 66 66 66c36 0 60-22 98-66zm0 0c38 44 62 66 98 66 37 0 66-29 66-66s-29-66-66-66c-36 0-60 22-98 66z"
fill="none" stroke="#ff4d6d" stroke-width="40" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

+68 -12
View File
@@ -4,17 +4,38 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>pl8list ∞</title> <title>pl8list ∞</title>
<meta name="description" content="Endless scroll through pl8list, tuned to what you like.">
<meta name="theme-color" content="#000000">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="pl8 ∞">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js" <script src="https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js"
integrity="sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn" integrity="sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<!-- Shared icons, referenced with <use href="#i-…">. -->
<svg class="sprite" aria-hidden="true">
<symbol id="i-heart" viewBox="0 0 24 24"><path fill="currentColor" d="M12 21s-7.5-4.6-10-9.3C.4 8.4 2.3 4.5 6 4.5c2.2 0 3.6 1.2 6 3.6 2.4-2.4 3.8-3.6 6-3.6 3.7 0 5.6 3.9 4 7.2C19.5 16.4 12 21 12 21z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M12 3v12M7 8l5-5 5 5M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6"/></symbol>
<symbol id="i-open" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M9 7h8v8"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M12 4v16M6 14l6 6 6-6"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path fill="currentColor" d="M7 4.5h3.5v15H7zM13.5 4.5H17v15h-3.5z"/></symbol>
</svg>
<header id="bar" hidden> <header id="bar" hidden>
<span class="logo">pl8list <b>∞</b></span> <span class="logo">pl8list <b>∞</b></span>
<span id="stats"></span> <span id="stats"></span>
<button id="filters-btn" type="button">filters</button> <button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button">likes</button> <button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
<button id="help-btn" type="button" title="shortcuts (?)" aria-label="help">?</button>
<button id="logout-btn" type="button" title="sign out"></button> <button id="logout-btn" type="button" title="sign out"></button>
</header> </header>
@@ -45,44 +66,79 @@
</div> </div>
</div> </div>
<form id="filters" hidden> <form id="filters" class="panel" hidden>
<label><input type="checkbox" name="free"> free only</label> <label><input type="checkbox" name="free"> free only</label>
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label> <label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label> <label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
<button type="submit">apply</button> <button type="submit">apply</button>
<button type="button" id="filters-clear" class="link">clear</button>
</form> </form>
<aside id="likes" hidden> <aside id="likes" class="panel" hidden>
<h2>likes</h2> <h2>likes</h2>
<ol id="likes-list"></ol> <ol id="likes-list"></ol>
</aside> </aside>
<main id="feed"></main> <main id="feed"></main>
<div id="start"> <div id="start" hidden>
<button type="button">tap to start</button> <button type="button">tap anywhere to start</button>
<p>↑ ↓ / j k scroll · space play/pause · ← → seek · l like</p>
</div> </div>
<div id="help" hidden>
<div class="help-box">
<h2>controls</h2>
<dl class="touch-only">
<dt>tap</dt><dd>play / pause</dd>
<dt>double-tap</dt><dd>like</dd>
<dt>swipe up</dt><dd>next track</dd>
<dt>drag waveform</dt><dd>seek</dd>
</dl>
<dl class="pointer-only">
<dt>click · space</dt><dd>play / pause</dd>
<dt>double-click · l</dt><dd>like</dd>
<dt>↓ j · ↑ k</dt><dd>next / previous</dd>
<dt>← →</dt><dd>seek 10 s</dd>
<dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd>
<dt>m</dt><dd>mute</dd>
<dt>?</dt><dd>this help</dd>
</dl>
<p>Lock-screen and headphone buttons work too.</p>
<button type="button" id="help-close">got it</button>
</div>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<audio id="audio" preload="auto"></audio> <audio id="audio" preload="auto"></audio>
<template id="card-tpl"> <template id="card-tpl">
<section class="card"> <section class="card">
<div class="bg"></div> <div class="bg"></div>
<div class="content"> <div class="content">
<div class="art"></div> <div class="art">
<span class="initial"></span>
<span class="paused-badge"><svg><use href="#i-play"/></svg></span>
<span class="spinner"></span>
</div>
<h1 class="title"></h1> <h1 class="title"></h1>
<a class="artist" target="_blank" rel="noopener"></a> <a class="artist" target="_blank" rel="noopener"></a>
<div class="chips"></div> <div class="chips"></div>
<p class="desc"></p> <p class="desc"></p>
<canvas class="wave"></canvas> <div class="wave-wrap">
<canvas class="wave"></canvas>
<span class="wave-tip"></span>
</div>
<div class="time"><span class="cur">0:00</span><span class="dur"></span></div> <div class="time"><span class="cur">0:00</span><span class="dur"></span></div>
<div class="counts"></div> <div class="counts"></div>
</div> </div>
<nav class="actions"> <nav class="actions">
<button type="button" class="like" title="like (l)">♥</button> <button type="button" class="like" title="like (l)" aria-label="like"><svg><use href="#i-heart"/></svg></button>
<a class="open" target="_blank" rel="noopener" title="open on pl8list"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7M9 7h8v8"/></svg></a> <button type="button" class="dislike" title="not for me (x)" aria-label="not for me"><svg><use href="#i-x"/></svg></button>
<button type="button" class="next" title="next (j)">↓</button> <button type="button" class="share" title="share (s)" aria-label="share"><svg><use href="#i-share"/></svg></button>
<a class="open" target="_blank" rel="noopener" title="open on pl8list" aria-label="open on pl8list"><svg><use href="#i-open"/></svg></a>
<button type="button" class="next" title="next (j)" aria-label="next"><svg><use href="#i-down"/></svg></button>
</nav> </nav>
</section> </section>
</template> </template>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "pl8list ∞",
"short_name": "pl8 ∞",
"description": "Endless scroll through pl8list, tuned to what you like.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/static/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/static/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+225 -32
View File
@@ -2,7 +2,7 @@
--fg: #f2f2f2; --fg: #f2f2f2;
--muted: #9a9a9a; --muted: #9a9a9a;
--accent: #ff4d6d; --accent: #ff4d6d;
--panel: rgba(18, 18, 20, 0.92); --panel: rgba(18, 18, 20, 0.94);
color-scheme: dark; color-scheme: dark;
} }
@@ -15,6 +15,7 @@ html, body {
color: var(--fg); color: var(--fg);
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden; overflow: hidden;
-webkit-tap-highlight-color: transparent;
} }
a { color: inherit; } a { color: inherit; }
@@ -32,6 +33,12 @@ button {
padding: 0.35em 0.9em; padding: 0.35em 0.9em;
} }
[hidden] { display: none !important; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* Header and panels ------------------------------------------------------ */
#bar { #bar {
position: fixed; position: fixed;
inset: 0 0 auto 0; inset: 0 0 auto 0;
@@ -40,26 +47,40 @@ button {
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
padding-top: max(0.6rem, env(safe-area-inset-top));
background: linear-gradient(#000c, #0000); background: linear-gradient(#000c, #0000);
} }
.logo { font-weight: 600; margin-right: auto; } .logo { font-weight: 600; margin-right: auto; }
.logo b { color: var(--accent); } .logo b { color: var(--accent); }
#stats { color: var(--muted); font-size: 0.8rem; } #stats { color: var(--muted); font-size: 0.8rem; }
#help-btn { width: 2.2em; padding: 0.35em 0; }
#logout-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filters-btn.active::after {
content: "";
display: inline-block;
width: 0.45em;
height: 0.45em;
margin-left: 0.4em;
border-radius: 50%;
background: var(--accent);
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#filters, #likes { .panel {
position: fixed; position: fixed;
z-index: 11; z-index: 11;
top: 3rem; top: calc(3rem + env(safe-area-inset-top));
right: 1rem; right: 1rem;
background: var(--panel); background: var(--panel);
border: 1px solid #333; border: 1px solid #333;
border-radius: 12px; border-radius: 12px;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
box-shadow: 0 16px 40px #000a;
} }
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; } #filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters[hidden], #likes[hidden] { display: none; }
#filters input[type=number] { #filters input[type=number] {
width: 4.5em; width: 4.5em;
background: #222; background: #222;
@@ -70,9 +91,30 @@ button {
#likes { width: min(26rem, calc(100vw - 2rem)); max-height: 70vh; overflow: auto; } #likes { width: min(26rem, calc(100vw - 2rem)); max-height: 70vh; overflow: auto; }
#likes h2 { margin: 0 0 0.5rem; font-size: 1rem; } #likes h2 { margin: 0 0 0.5rem; font-size: 1rem; }
#likes ol { margin: 0; padding-left: 1.4rem; } #likes ol { margin: 0; padding: 0; list-style: none; }
#likes li { margin: 0.3rem 0; } #likes li {
#likes li span { color: var(--muted); } display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
border-bottom: 1px solid #ffffff10;
}
#likes li:last-child { border-bottom: none; }
#likes li .meta { min-width: 0; flex: 1; }
#likes li .meta a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
#likes li .meta a:hover { text-decoration: underline; }
#likes li .meta span { color: var(--muted); font-size: 0.8rem; }
#likes li .play-now {
flex: none;
width: 2rem;
height: 2rem;
padding: 0;
display: grid;
place-items: center;
}
#likes li .play-now svg { width: 0.9rem; height: 0.9rem; }
/* Feed ------------------------------------------------------------------- */
#feed { #feed {
height: 100dvh; height: 100dvh;
@@ -91,6 +133,10 @@ button {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
} }
.bg { .bg {
@@ -111,6 +157,7 @@ button {
} }
.art { .art {
position: relative;
width: min(16rem, 42vh, 70vw); width: min(16rem, 42vh, 70vw);
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 14px; border-radius: 14px;
@@ -118,11 +165,45 @@ button {
box-shadow: 0 20px 60px #000a; box-shadow: 0 20px 60px #000a;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 4rem;
font-weight: 700;
color: #fff6;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
transition: transform 0.25s ease, filter 0.25s ease;
} }
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
.paused-badge {
position: absolute;
inset: 0;
margin: auto;
width: 4.5rem;
height: 4.5rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.paused-badge svg { width: 2rem; height: 2rem; margin-left: 0.2rem; }
.card.paused .paused-badge { opacity: 1; }
.spinner {
position: absolute;
inset: 0;
margin: auto;
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 3px solid #ffffff33;
border-top-color: #fff;
opacity: 0;
pointer-events: none;
animation: spin 0.8s linear infinite;
}
.card.buffering .spinner { opacity: 1; }
.card.buffering .paused-badge { opacity: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.title { font-size: 1.25rem; margin: 0; overflow-wrap: anywhere; } .title { font-size: 1.25rem; margin: 0; overflow-wrap: anywhere; }
.artist { color: var(--muted); text-decoration: none; } .artist { color: var(--muted); text-decoration: none; }
@@ -150,7 +231,21 @@ button {
font-size: 0.85rem; font-size: 0.85rem;
} }
.wave { width: 100%; height: 64px; cursor: pointer; margin-top: 0.6rem; } .wave-wrap { position: relative; width: 100%; margin-top: 0.6rem; }
.wave { display: block; width: 100%; height: 64px; cursor: pointer; touch-action: none; }
.wave-tip {
position: absolute;
bottom: calc(100% + 4px);
transform: translateX(-50%);
padding: 0.1em 0.45em;
border-radius: 6px;
background: #000c;
font-size: 0.75rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
}
.wave-wrap.hover .wave-tip { opacity: 1; }
.time { width: 100%; display: flex; justify-content: space-between; color: var(--muted); font-size: 0.75rem; } .time { width: 100%; display: flex; justify-content: space-between; color: var(--muted); font-size: 0.75rem; }
.counts { color: var(--muted); font-size: 0.8rem; } .counts { color: var(--muted); font-size: 0.8rem; }
@@ -170,11 +265,75 @@ button {
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
border: none; border: none;
font-size: 1.3rem;
text-decoration: none; text-decoration: none;
padding: 0; padding: 0;
transition: background 0.15s, transform 0.15s;
} }
.actions > *:hover { background: rgba(255, 255, 255, 0.2); }
.actions > *:active { transform: scale(0.9); }
.actions svg { width: 1.3rem; height: 1.3rem; }
.actions .like.on { color: var(--accent); background: rgba(255, 77, 109, 0.2); } .actions .like.on { color: var(--accent); background: rgba(255, 77, 109, 0.2); }
.actions .like.pop svg { animation: pop 0.35s ease; }
@keyframes pop { 50% { transform: scale(1.4); } }
/* Feedback overlays ------------------------------------------------------ */
.flash {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 5.5rem;
height: 5.5rem;
margin: -2.75rem 0 0 -2.75rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
pointer-events: none;
animation: flash 0.6s ease forwards;
}
.flash svg { width: 2.4rem; height: 2.4rem; }
@keyframes flash {
from { opacity: 1; transform: scale(0.7); }
to { opacity: 0; transform: scale(1.3); }
}
.burst {
position: absolute;
z-index: 3;
width: 6rem;
height: 6rem;
margin: -3rem 0 0 -3rem;
color: var(--accent);
pointer-events: none;
filter: drop-shadow(0 6px 20px #ff4d6d88);
animation: burst 0.8s cubic-bezier(.2, .9, .3, 1.3) forwards;
}
@keyframes burst {
0% { opacity: 0; transform: scale(0.3) rotate(var(--tilt)); }
25% { opacity: 1; transform: scale(1.15) rotate(var(--tilt)); }
60% { opacity: 1; transform: scale(1) rotate(var(--tilt)); }
100% { opacity: 0; transform: translateY(-3rem) scale(0.9) rotate(var(--tilt)); }
}
#toast {
position: fixed;
z-index: 40;
left: 50%;
bottom: max(2rem, env(safe-area-inset-bottom));
transform: translate(-50%, 1rem);
padding: 0.6em 1.1em;
border-radius: 999px;
background: #222e;
border: 1px solid #ffffff22;
font-size: 0.9rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
white-space: nowrap;
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }
.status { .status {
height: 100dvh; height: 100dvh;
@@ -182,10 +341,52 @@ button {
place-items: center; place-items: center;
color: var(--muted); color: var(--muted);
scroll-snap-align: start; scroll-snap-align: start;
padding: 1rem;
text-align: center;
} }
#bar[hidden] { display: none; } #start {
#logout-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #000b;
cursor: pointer;
}
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
#help {
position: fixed;
inset: 0;
z-index: 25;
display: grid;
place-items: center;
padding: 1rem;
background: #000b;
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } }
.help-box {
width: min(22rem, 100%);
background: var(--panel);
border: 1px solid #333;
border-radius: 16px;
padding: 1.2rem 1.4rem;
}
.help-box h2 { margin: 0 0 0.8rem; font-size: 1.1rem; }
.help-box dl { display: grid; grid-template-columns: auto 1fr; gap: 0.45rem 1rem; margin: 0; }
.help-box dt { font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--accent); }
.help-box dd { margin: 0; }
.help-box p { color: var(--muted); font-size: 0.85rem; margin: 1rem 0; }
.help-box button { width: 100%; background: var(--accent); border: none; color: #fff; padding: 0.6em; font-weight: 600; }
@media (hover: hover) and (pointer: fine) { .touch-only { display: none !important; } }
@media not ((hover: hover) and (pointer: fine)) { .pointer-only { display: none !important; } }
/* Sign-in ---------------------------------------------------------------- */
#login { #login {
position: fixed; position: fixed;
@@ -199,7 +400,6 @@ button {
radial-gradient(50rem 40rem at 90% 90%, #4d6dff2e, transparent 60%), radial-gradient(50rem 40rem at 90% 90%, #4d6dff2e, transparent 60%),
#050507; #050507;
} }
#login[hidden] { display: none; }
.login-box { .login-box {
width: min(24rem, 100%); width: min(24rem, 100%);
@@ -211,7 +411,6 @@ button {
.login-box h1 b { color: var(--accent); } .login-box h1 b { color: var(--accent); }
.tagline { margin: 0 0 0.8rem; color: var(--muted); } .tagline { margin: 0 0 0.8rem; color: var(--muted); }
.login-box form { display: flex; flex-direction: column; gap: 0.6rem; } .login-box form { display: flex; flex-direction: column; gap: 0.6rem; }
.login-box form[hidden] { display: none; }
.login-box label { color: var(--muted); font-size: 0.9rem; } .login-box label { color: var(--muted); font-size: 0.9rem; }
.login-box input { .login-box input {
padding: 0.7em 0.9em; padding: 0.7em 0.9em;
@@ -227,26 +426,16 @@ button {
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; } #login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
.fineprint { margin: 0; color: #666; font-size: 0.75rem; } .fineprint { margin: 0; color: #666; font-size: 0.75rem; }
#start { /* Phones ----------------------------------------------------------------- */
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #000c;
color: var(--muted);
}
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
#start[hidden] { display: none; }
@media (max-width: 640px) { @media (max-width: 640px) {
#stats { display: none; } #stats { display: none; }
#bar { gap: 0.4rem; padding: 0.5rem 0.7rem; } #bar { gap: 0.4rem; padding-left: 0.7rem; padding-right: 0.7rem; }
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; } #bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
#help-btn { display: none; }
#logout-btn { max-width: 6.5rem; } #logout-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; } .content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions { .actions {
right: auto; right: auto;
@@ -254,6 +443,10 @@ button {
bottom: max(1.2rem, env(safe-area-inset-bottom)); bottom: max(1.2rem, env(safe-area-inset-bottom));
transform: translateX(-50%); transform: translateX(-50%);
flex-direction: row; flex-direction: row;
gap: 1.4rem; gap: 0.9rem;
} }
} }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}