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
No files matched your search
@@ -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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 1007 KiB After Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 505 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||