mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 00:46:59 +01:00
Add pl8list infinite-scroll app with pl8list sign-in
Endless TikTok-style feed over pl8list's Remix loaders. A guest crawler builds a shared catalogue; a per-user ranker learns from likes, full listens and skips. Users sign in with pl8list's email code; only a hash of the local session token is stored. Ships as a hardened Docker image with a Traefik override for pl8.narl.io.
This commit is contained in:
commit
4a7f6903a3
20 files changed
+2192
No files matched your search
+381
@@ -0,0 +1,381 @@
|
||||
"use strict";
|
||||
|
||||
const PL8 = "https://pl8list.com";
|
||||
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
|
||||
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
|
||||
const COMPLETE_RATIO = 0.85;
|
||||
|
||||
const feed = document.getElementById("feed");
|
||||
const audio = document.getElementById("audio");
|
||||
const tpl = document.getElementById("card-tpl");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const filtersForm = document.getElementById("filters");
|
||||
|
||||
const cards = []; // {track, el, canvas, liked}
|
||||
let active = -1;
|
||||
let loading = false;
|
||||
let started = false;
|
||||
let hls = null;
|
||||
let listened = 0; // seconds actually heard on the active card
|
||||
let lastTime = 0;
|
||||
let reported = false; // final event already sent for the active card
|
||||
let filters = {};
|
||||
|
||||
// Server ----------------------------------------------------------------
|
||||
|
||||
function sendEvent(track, kind, seconds) {
|
||||
const body = JSON.stringify({ track_id: track.id, kind, seconds });
|
||||
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
|
||||
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
const status = document.createElement("div");
|
||||
status.className = "status";
|
||||
status.textContent = cards.length ? "loading…" : "crawling pl8list, first tracks in a few seconds…";
|
||||
feed.append(status);
|
||||
try {
|
||||
const params = new URLSearchParams({ n: "10", ...filters });
|
||||
const res = await fetch("/local/feed?" + params);
|
||||
if (res.status === 401) {
|
||||
status.remove();
|
||||
loading = false;
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
showStats(data.stats);
|
||||
if (!data.tracks.length) {
|
||||
status.textContent = "nothing new right now, crawler is still digging. scroll again in a bit.";
|
||||
setTimeout(() => { status.remove(); loading = false; }, 8000);
|
||||
return;
|
||||
}
|
||||
status.remove();
|
||||
data.tracks.forEach(addCard);
|
||||
} catch (err) {
|
||||
status.textContent = "failed to load: " + err;
|
||||
setTimeout(() => status.remove(), 5000);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
let stats = null;
|
||||
|
||||
function showStats(s) {
|
||||
if (s) stats = s;
|
||||
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
|
||||
}
|
||||
|
||||
// Uploads are often titled after their file name.
|
||||
const cleanTitle = (t) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
|
||||
|
||||
// Cards -----------------------------------------------------------------
|
||||
|
||||
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
function addCard(track) {
|
||||
const el = tpl.content.firstElementChild.cloneNode(true);
|
||||
const img = track.artist_img ? `url("${PL8}/img/${track.artist_img}")` : "";
|
||||
el.querySelector(".bg").style.backgroundImage = img;
|
||||
const art = el.querySelector(".art");
|
||||
if (img) art.style.backgroundImage = img;
|
||||
else art.textContent = (track.artist_name || "?")[0].toUpperCase();
|
||||
|
||||
el.querySelector(".title").textContent = cleanTitle(track.title);
|
||||
const artist = el.querySelector(".artist");
|
||||
artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : "");
|
||||
artist.href = `${PL8}/${track.handle}`;
|
||||
el.querySelector(".open").href = `${PL8}/${track.handle}/${track.slug}`;
|
||||
el.querySelector(".desc").textContent = track.description || "";
|
||||
el.querySelector(".dur").textContent = track.duration ? fmtTime(track.duration) : "";
|
||||
|
||||
const chips = el.querySelector(".chips");
|
||||
const chip = (text, cls) => {
|
||||
const s = document.createElement("span");
|
||||
s.textContent = text;
|
||||
if (cls) s.className = cls;
|
||||
chips.append(s);
|
||||
};
|
||||
if (track.bpm) chip(`${Math.round(track.bpm)} bpm`);
|
||||
if (track.key) chip(track.key);
|
||||
chip(track.price ? `$${track.price}` : "free", track.price ? "" : "free");
|
||||
if (track.preview) chip("preview", "preview");
|
||||
|
||||
el.querySelector(".counts").textContent =
|
||||
`${track.saves} saves · ${track.downloads} downloads · ${track.comments} comments` +
|
||||
(track.purchases ? ` · ${track.purchases} sold` : "");
|
||||
|
||||
const card = { track, el, canvas: el.querySelector(".wave"), liked: false };
|
||||
const idx = cards.push(card) - 1;
|
||||
|
||||
el.querySelector(".like").addEventListener("click", () => toggleLike(idx));
|
||||
el.querySelector(".next").addEventListener("click", () => go(idx + 1));
|
||||
card.canvas.addEventListener("click", (e) => {
|
||||
if (idx !== active || !audio.duration) return;
|
||||
const r = card.canvas.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration;
|
||||
});
|
||||
|
||||
feed.append(el);
|
||||
observer.observe(el);
|
||||
drawWave(card, 0);
|
||||
}
|
||||
|
||||
function drawWave(card, progress) {
|
||||
const { canvas, track } = card;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (!w) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (canvas.width !== w * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const data = track.waveform || [];
|
||||
const bars = Math.max(1, Math.floor(w / 3));
|
||||
const max = Math.max(1, ...data);
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const v = data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
|
||||
const bh = Math.max(2, v * h);
|
||||
ctx.fillStyle = i / bars < progress ? "#ff4d6d" : "rgba(255,255,255,0.35)";
|
||||
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
|
||||
}
|
||||
}
|
||||
|
||||
// Playback --------------------------------------------------------------
|
||||
|
||||
function streamUrl(t) {
|
||||
const p = new URLSearchParams();
|
||||
if (t.preview) p.set("preview", "true");
|
||||
if (t.upload_id) p.set("v", t.upload_id);
|
||||
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
|
||||
}
|
||||
|
||||
function play(track) {
|
||||
const url = streamUrl(track);
|
||||
if (window.Hls && Hls.isSupported()) {
|
||||
if (hls) hls.destroy();
|
||||
hls = new Hls();
|
||||
hls.on(Hls.Events.ERROR, (_, d) => {
|
||||
if (d.fatal) {
|
||||
console.warn("hls fatal", d);
|
||||
go(active + 1);
|
||||
}
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(audio);
|
||||
} else {
|
||||
audio.src = url; // Safari plays HLS natively
|
||||
}
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
function finishActive() {
|
||||
if (active < 0 || reported) return;
|
||||
reported = true;
|
||||
// Flicking past a card before any audio played says nothing about the track.
|
||||
if (listened < 1) return;
|
||||
const { track } = cards[active];
|
||||
const dur = audio.duration || track.duration || 0;
|
||||
if (dur && listened / dur >= COMPLETE_RATIO) sendEvent(track, "complete", listened);
|
||||
else if (listened < SKIP_SECONDS) sendEvent(track, "skip", listened);
|
||||
else sendEvent(track, "play", listened);
|
||||
}
|
||||
|
||||
function activate(idx) {
|
||||
if (idx === active || !cards[idx]) return;
|
||||
finishActive();
|
||||
active = idx;
|
||||
listened = 0;
|
||||
lastTime = 0;
|
||||
reported = false;
|
||||
if (started) play(cards[idx].track);
|
||||
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
|
||||
}
|
||||
|
||||
function go(idx) {
|
||||
if (cards[idx]) cards[idx].el.scrollIntoView({ behavior: "smooth" });
|
||||
else loadMore();
|
||||
}
|
||||
|
||||
function toggleLike(idx) {
|
||||
const card = cards[idx];
|
||||
card.liked = !card.liked;
|
||||
card.el.querySelector(".like").classList.toggle("on", card.liked);
|
||||
if (stats) { stats.liked += card.liked ? 1 : -1; showStats(); }
|
||||
sendEvent(card.track, card.liked ? "like" : "unlike");
|
||||
}
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
const t = audio.currentTime;
|
||||
const d = t - lastTime;
|
||||
if (d > 0 && d < 1.5) listened += d; // ignore seeks
|
||||
lastTime = t;
|
||||
const card = cards[active];
|
||||
if (card && audio.duration) {
|
||||
drawWave(card, t / audio.duration);
|
||||
card.el.querySelector(".cur").textContent = fmtTime(t);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
if (active >= 0 && !reported) {
|
||||
sendEvent(cards[active].track, "complete", listened);
|
||||
reported = true;
|
||||
}
|
||||
go(active + 1);
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target));
|
||||
}
|
||||
}, { root: feed, threshold: 0.6 });
|
||||
|
||||
// UI --------------------------------------------------------------------
|
||||
|
||||
document.querySelector("#start button").addEventListener("click", () => {
|
||||
started = true;
|
||||
document.getElementById("start").hidden = true;
|
||||
if (cards[active]) play(cards[active].track);
|
||||
});
|
||||
|
||||
document.getElementById("filters-btn").addEventListener("click", () => { filtersForm.hidden = !filtersForm.hidden; });
|
||||
|
||||
document.getElementById("likes-btn").addEventListener("click", async () => {
|
||||
const panel = document.getElementById("likes");
|
||||
panel.hidden = !panel.hidden;
|
||||
if (panel.hidden) return;
|
||||
const list = document.getElementById("likes-list");
|
||||
const { tracks } = await (await fetch("/local/likes")).json();
|
||||
list.replaceChildren(...tracks.map((t) => {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `${PL8}/${t.handle}/${t.slug}`;
|
||||
a.target = "_blank";
|
||||
a.textContent = cleanTitle(t.title);
|
||||
const by = document.createElement("span");
|
||||
by.textContent = ` · ${t.artist_name}`;
|
||||
li.append(a, by);
|
||||
return li;
|
||||
}));
|
||||
if (!tracks.length) list.textContent = "nothing liked yet";
|
||||
});
|
||||
|
||||
filtersForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(filtersForm);
|
||||
filters = {};
|
||||
if (f.get("free")) filters.free = "1";
|
||||
if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min");
|
||||
if (f.get("bpm_max")) filters.bpm_max = f.get("bpm_max");
|
||||
filtersForm.hidden = true;
|
||||
// Drop cards below the current one so the new filter applies right away.
|
||||
for (const c of cards.splice(active + 1)) { observer.unobserve(c.el); c.el.remove(); }
|
||||
loadMore();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
|
||||
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
|
||||
else if (k === " ") { e.preventDefault(); audio.paused ? audio.play() : audio.pause(); }
|
||||
else if (k === "ArrowRight") audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10);
|
||||
else if (k === "ArrowLeft") audio.currentTime = Math.max(0, audio.currentTime - 10);
|
||||
else if (k === "l" && active >= 0) toggleLike(active);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => cards.forEach((c) => drawWave(c, 0)));
|
||||
window.addEventListener("pagehide", finishActive);
|
||||
|
||||
// Sign-in -----------------------------------------------------------------
|
||||
|
||||
const loginEl = document.getElementById("login");
|
||||
const emailForm = document.getElementById("email-form");
|
||||
const codeForm = document.getElementById("code-form");
|
||||
const loginError = document.getElementById("login-error");
|
||||
|
||||
async function postJSON(url, data) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `request failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("bar").hidden = true;
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
codeForm.hidden = true;
|
||||
document.getElementById("email").focus();
|
||||
}
|
||||
|
||||
async function withForm(form, fn) {
|
||||
const buttons = form.querySelectorAll("button");
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
loginError.textContent = "";
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
loginError.textContent = err.message;
|
||||
}
|
||||
buttons.forEach((b) => { b.disabled = false; });
|
||||
}
|
||||
|
||||
emailForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
withForm(emailForm, async () => {
|
||||
const { email } = await postJSON("/auth/start", { email: emailForm.email.value });
|
||||
document.getElementById("sent-to").textContent = email;
|
||||
emailForm.hidden = true;
|
||||
codeForm.hidden = false;
|
||||
codeForm.code.value = "";
|
||||
codeForm.code.focus();
|
||||
});
|
||||
});
|
||||
|
||||
codeForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
withForm(codeForm, async () => {
|
||||
const { user } = await postJSON("/auth/verify", { code: codeForm.code.value });
|
||||
signedIn(user);
|
||||
});
|
||||
});
|
||||
|
||||
codeForm.code.addEventListener("input", () => {
|
||||
if (/^\d{6}$/.test(codeForm.code.value)) codeForm.requestSubmit();
|
||||
});
|
||||
|
||||
document.getElementById("resend").addEventListener("click", () => {
|
||||
withForm(codeForm, async () => {
|
||||
await postJSON("/auth/resend");
|
||||
loginError.textContent = "new code sent";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("restart").addEventListener("click", showLogin);
|
||||
|
||||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||
await postJSON("/auth/logout");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
function signedIn(user) {
|
||||
loginEl.hidden = true;
|
||||
document.getElementById("bar").hidden = false;
|
||||
document.getElementById("logout-btn").textContent = `sign out @${user.handle}`;
|
||||
if (!cards.length) loadMore();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const res = await fetch("/local/me");
|
||||
if (res.ok) signedIn((await res.json()).user);
|
||||
else showLogin();
|
||||
})();
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>pl8list ∞</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js"
|
||||
integrity="sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn"
|
||||
crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header id="bar" hidden>
|
||||
<span class="logo">pl8list <b>∞</b></span>
|
||||
<span id="stats"></span>
|
||||
<button id="filters-btn" type="button">filters</button>
|
||||
<button id="likes-btn" type="button">likes</button>
|
||||
<button id="logout-btn" type="button" title="sign out"></button>
|
||||
</header>
|
||||
|
||||
<div id="login" hidden>
|
||||
<div class="login-box">
|
||||
<h1>pl8list <b>∞</b></h1>
|
||||
<p class="tagline">endless scroll through pl8list, tuned to what you like</p>
|
||||
<form id="email-form">
|
||||
<label for="email">sign in with your pl8list email</label>
|
||||
<input id="email" type="email" name="email" autocomplete="email" required placeholder="you@example.com">
|
||||
<button type="submit">send code</button>
|
||||
</form>
|
||||
<form id="code-form" hidden>
|
||||
<label for="code">pl8list sent a 6-digit code to <span id="sent-to"></span></label>
|
||||
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}"
|
||||
maxlength="6" required placeholder="123456">
|
||||
<button type="submit">sign in</button>
|
||||
<div class="login-links">
|
||||
<button type="button" id="resend" class="link">resend code</button>
|
||||
<button type="button" id="restart" class="link">use another email</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="login-error" role="alert"></p>
|
||||
<p class="fineprint">
|
||||
No account? <a href="https://pl8list.com/signup" target="_blank" rel="noopener">sign up on pl8list</a>.
|
||||
This site is not affiliated with pl8list. It never stores your pl8list login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="filters" hidden>
|
||||
<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>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
|
||||
<button type="submit">apply</button>
|
||||
</form>
|
||||
|
||||
<aside id="likes" hidden>
|
||||
<h2>likes</h2>
|
||||
<ol id="likes-list"></ol>
|
||||
</aside>
|
||||
|
||||
<main id="feed"></main>
|
||||
|
||||
<div id="start">
|
||||
<button type="button">tap to start</button>
|
||||
<p>↑ ↓ / j k scroll · space play/pause · ← → seek · l like</p>
|
||||
</div>
|
||||
|
||||
<audio id="audio" preload="auto"></audio>
|
||||
|
||||
<template id="card-tpl">
|
||||
<section class="card">
|
||||
<div class="bg"></div>
|
||||
<div class="content">
|
||||
<div class="art"></div>
|
||||
<h1 class="title"></h1>
|
||||
<a class="artist" target="_blank" rel="noopener"></a>
|
||||
<div class="chips"></div>
|
||||
<p class="desc"></p>
|
||||
<canvas class="wave"></canvas>
|
||||
<div class="time"><span class="cur">0:00</span><span class="dur"></span></div>
|
||||
<div class="counts"></div>
|
||||
</div>
|
||||
<nav class="actions">
|
||||
<button type="button" class="like" title="like (l)">♥</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="next" title="next (j)">↓</button>
|
||||
</nav>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,259 @@
|
||||
:root {
|
||||
--fg: #f2f2f2;
|
||||
--muted: #9a9a9a;
|
||||
--accent: #ff4d6d;
|
||||
--panel: rgba(18, 18, 20, 0.92);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
color: var(--fg);
|
||||
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
padding: 0.35em 0.9em;
|
||||
}
|
||||
|
||||
#bar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: linear-gradient(#000c, #0000);
|
||||
}
|
||||
|
||||
.logo { font-weight: 600; margin-right: auto; }
|
||||
.logo b { color: var(--accent); }
|
||||
#stats { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
#filters, #likes {
|
||||
position: fixed;
|
||||
z-index: 11;
|
||||
top: 3rem;
|
||||
right: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
|
||||
#filters[hidden], #likes[hidden] { display: none; }
|
||||
#filters input[type=number] {
|
||||
width: 4.5em;
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
#likes { width: min(26rem, calc(100vw - 2rem)); max-height: 70vh; overflow: auto; }
|
||||
#likes h2 { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
#likes ol { margin: 0; padding-left: 1.4rem; }
|
||||
#likes li { margin: 0.3rem 0; }
|
||||
#likes li span { color: var(--muted); }
|
||||
|
||||
#feed {
|
||||
height: 100dvh;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#feed::-webkit-scrollbar { display: none; }
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: -40px;
|
||||
background: #111 center / cover no-repeat;
|
||||
filter: blur(40px) brightness(0.45) saturate(1.3);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
width: min(34rem, calc(100vw - 5.5rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.art {
|
||||
width: min(16rem, 42vh, 70vw);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 14px;
|
||||
background: #222 center / cover no-repeat;
|
||||
box-shadow: 0 20px 60px #000a;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: #fff6;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.title { font-size: 1.25rem; margin: 0; overflow-wrap: anywhere; }
|
||||
.artist { color: var(--muted); text-decoration: none; }
|
||||
.artist:hover { text-decoration: underline; }
|
||||
|
||||
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; justify-content: center; }
|
||||
.chips span {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1em 0.6em;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.chips .free { background: #1f7a4d; }
|
||||
.chips .preview { background: #6a4b16; }
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wave { width: 100%; height: 64px; cursor: pointer; margin-top: 0.6rem; }
|
||||
.time { width: 100%; display: flex; justify-content: space-between; color: var(--muted); font-size: 0.75rem; }
|
||||
.counts { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 0.8rem;
|
||||
bottom: 18vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.actions > * {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
}
|
||||
.actions .like.on { color: var(--accent); background: rgba(255, 77, 109, 0.2); }
|
||||
|
||||
.status {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
#bar[hidden] { display: none; }
|
||||
#logout-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
#login {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background:
|
||||
radial-gradient(60rem 40rem at 20% 10%, #ff4d6d33, transparent 60%),
|
||||
radial-gradient(50rem 40rem at 90% 90%, #4d6dff2e, transparent 60%),
|
||||
#050507;
|
||||
}
|
||||
#login[hidden] { display: none; }
|
||||
|
||||
.login-box {
|
||||
width: min(24rem, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.login-box h1 { margin: 0; font-size: 2.4rem; }
|
||||
.login-box h1 b { color: var(--accent); }
|
||||
.tagline { margin: 0 0 0.8rem; color: var(--muted); }
|
||||
.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 input {
|
||||
padding: 0.7em 0.9em;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #333;
|
||||
background: #111;
|
||||
}
|
||||
#code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; }
|
||||
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
|
||||
.login-box button[disabled] { opacity: 0.6; cursor: wait; }
|
||||
.login-links { display: flex; justify-content: space-between; }
|
||||
.link { background: none; border: none; padding: 0; color: var(--muted); text-decoration: underline; font-size: 0.85rem; }
|
||||
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
|
||||
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
|
||||
|
||||
#start {
|
||||
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) {
|
||||
#stats { display: none; }
|
||||
#bar { gap: 0.4rem; padding: 0.5rem 0.7rem; }
|
||||
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
|
||||
#logout-btn { max-width: 6.5rem; }
|
||||
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
|
||||
.actions {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
bottom: max(1.2rem, env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
flex-direction: row;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user