mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Rebuild front end in TypeScript with a pager, crossfades and reactive visuals
The front end moves to web/ (Vite, TypeScript, no framework) and is built into web/dist, which the server embeds. hls.js is bundled from npm and loaded on demand instead of vendored. The feed is a custom pager instead of CSS scroll snapping: drag and flick with a spring that keeps the finger's speed, a rubber band at the ends, one page per wheel or trackpad gesture, and only three cards in the DOM. Playback uses two decks. The next track is buffered shortly after the current one starts and tracks crossfade through a Web Audio graph; on iOS, where that would break lock-screen playback, they switch with a cut. An analyser drives a bass pulse of the artwork and its glow, and each card takes its accent colour from the artist image. Styles start from design tokens and respect reduced motion. Docker gets a Node build stage; legal pages use their own small stylesheet.
This commit is contained in:
1 parent
27225332fb
commit
b4470850bf
33 files changed
+3115
-899
No files matched your search
@@ -9,3 +9,5 @@ README.md
|
||||
compose*.yaml
|
||||
Dockerfile
|
||||
backend/target/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
@@ -3,3 +3,5 @@
|
||||
*.db-*
|
||||
__pycache__/
|
||||
backend/target/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:24-alpine AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web ./
|
||||
RUN npm run build
|
||||
|
||||
FROM rust:1-alpine AS build
|
||||
RUN apk add --no-cache musl-dev cmake make perl clang ca-certificates
|
||||
WORKDIR /src/backend
|
||||
@@ -7,7 +14,7 @@ COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
|
||||
&& cargo build --release --locked \
|
||||
&& rm -rf src
|
||||
COPY static /src/static
|
||||
COPY --from=web /src/web/dist /src/web/dist
|
||||
COPY backend ./
|
||||
RUN touch src/main.rs && cargo build --release --locked \
|
||||
&& mkdir -p /out/data && cp target/release/pl8inf /out/pl8inf
|
||||
|
||||
@@ -23,16 +23,22 @@ Not affiliated with pl8list.
|
||||
docker compose up -d --build # http://127.0.0.1:8790
|
||||
```
|
||||
|
||||
or without Docker (Rust 1.85+):
|
||||
or without Docker (Rust 1.85+, Node 22+):
|
||||
|
||||
```sh
|
||||
cd backend && cargo run --release # serves ../static, embedded at build time
|
||||
cd web && npm ci && npm run build # front end into web/dist
|
||||
cd ../backend && cargo run --release # embeds web/dist at build time
|
||||
cargo test # unit tests + API tests against a fake pl8list
|
||||
```
|
||||
|
||||
The server is the Rust crate in `backend/`. The old Python server (`server.py` and friends)
|
||||
still works on the same database and is kept until the Rust one has run in production for a
|
||||
while; `python3 server.py` starts it.
|
||||
For front-end work run the server, then `npm run dev` in `web/`: Vite serves the page with
|
||||
hot reload on http://127.0.0.1:5173/static/ and passes API calls to the server (`BACKEND=` to point it
|
||||
elsewhere). Debug builds of the server read `web/dist` from disk, so `npm run build` is enough
|
||||
to see changes on :8790 too.
|
||||
|
||||
The server is the Rust crate in `backend/`, the front end is TypeScript in `web/` (Vite, no
|
||||
framework). The old Python server (`server.py` and friends) still works on the same database
|
||||
but not with this front end; to roll back, check out `54a436a`.
|
||||
|
||||
Before going public in Germany, set the Impressum variables below. `/impressum` and
|
||||
`/datenschutz` are filled in from them at startup; the texts are in `backend/templates/`.
|
||||
@@ -102,7 +108,7 @@ The "account" menu shows the pl8list sync state and lets users resync, stop stay
|
||||
download everything stored about them (JSON), sign out on all devices, or delete their account
|
||||
(likes, follows, history and sessions, right away). Expired sessions
|
||||
are purged hourly and "served" events after 180 days. The browser only ever talks to this
|
||||
server: hls.js is self-hosted in `static/vendor/` and audio and images are proxied, so no
|
||||
server: hls.js is bundled into the front end and audio and images are proxied, so no
|
||||
visitor address reaches pl8list or a CDN.
|
||||
|
||||
## Controls
|
||||
@@ -123,6 +129,27 @@ Lock-screen, notification and headphone buttons control playback too (Media Sess
|
||||
"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.
|
||||
|
||||
## Front end
|
||||
|
||||
`web/src/`:
|
||||
|
||||
- `pager.ts`: the feed. One card fills the screen; drag, flick, wheel and keys turn the page,
|
||||
with a spring that follows the finger's speed and a rubber band at the ends. Only the
|
||||
current card and its neighbours are in the DOM. One wheel or trackpad gesture moves one
|
||||
page.
|
||||
- `player.ts`: two audio decks. The next track is buffered a few seconds after the current
|
||||
one starts, so it plays at once; tracks crossfade (0.9 s, equal power) and the next one
|
||||
starts just before the current one ends. Both decks run through a Web Audio graph with an
|
||||
analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen
|
||||
locked, so there tracks switch with a cut and lock-screen playback keeps working.
|
||||
- `visual.ts`: per frame, the waveform progress and a bass level (`--energy`) that pulses the
|
||||
artwork and its glow. Without the analyser the level comes from the track's waveform.
|
||||
- `color.ts`: each card's accent colour (`--card-accent`) from the artist image.
|
||||
- `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else.
|
||||
|
||||
Design tokens are CSS custom properties at the top of `style.css`. `prefers-reduced-motion`
|
||||
turns off the spring, the pulse and the animations.
|
||||
|
||||
## How it works
|
||||
|
||||
pl8list has no public API, but it is a Remix app: every page loader returns JSON when you add
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// The front end is built separately (`cd web && npm ci && npm run build`) and
|
||||
// embedded from web/dist. Create the folder if it is missing so the backend still
|
||||
// compiles (and its tests run) without Node; the pages are then empty.
|
||||
fn main() {
|
||||
let dist = std::path::Path::new("../web/dist");
|
||||
println!("cargo:rerun-if-changed=../web/dist");
|
||||
if !dist.join("index.html").exists() {
|
||||
std::fs::create_dir_all(dist).expect("create ../web/dist");
|
||||
println!("cargo:warning=web/dist has no index.html; run `npm run build` in web/ to include the front end");
|
||||
}
|
||||
}
|
||||
@@ -259,8 +259,10 @@ pub type Peer = ConnectInfo<SocketAddr>;
|
||||
|
||||
// Static files ------------------------------------------------------------
|
||||
|
||||
/// The built front end (`web/`, `npm run build`). Hashed asset names, so they
|
||||
/// can be cached for good; index.html and fixed-name files are revalidated.
|
||||
#[derive(Embed)]
|
||||
#[folder = "../static/"]
|
||||
#[folder = "../web/dist/"]
|
||||
struct Static;
|
||||
|
||||
fn content_type(path: &str) -> &'static str {
|
||||
@@ -277,8 +279,9 @@ fn content_type(path: &str) -> &'static str {
|
||||
}
|
||||
|
||||
fn static_file(path: &str) -> Response {
|
||||
let cache = if path.starts_with("assets/") { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
match Static::get(path) {
|
||||
Some(f) => ([(header::CONTENT_TYPE, content_type(path)), (header::CACHE_CONTROL, "no-cache")], f.data).into_response(),
|
||||
Some(f) => ([(header::CONTENT_TYPE, content_type(path)), (header::CACHE_CONTROL, cache)], f.data).into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Datenschutzerklärung · pl8list ∞</title>
|
||||
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/legal.css">
|
||||
</head>
|
||||
<body class="legal">
|
||||
<main>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Impressum · pl8list ∞</title>
|
||||
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/legal.css">
|
||||
</head>
|
||||
<body class="legal">
|
||||
<main>
|
||||
|
||||
-839
@@ -1,839 +0,0 @@
|
||||
"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 DOUBLE_TAP_MS = 300;
|
||||
const FILTERS_KEY = "pl8inf.filters";
|
||||
const HINT_KEY = "pl8inf.hintShown";
|
||||
|
||||
const feed = document.getElementById("feed");
|
||||
const audio = document.getElementById("audio");
|
||||
const tpl = document.getElementById("card-tpl");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const filtersForm = document.getElementById("filters");
|
||||
const filtersBtn = document.getElementById("filters-btn");
|
||||
const likesPanel = document.getElementById("likes");
|
||||
const likesBtn = document.getElementById("likes-btn");
|
||||
const accountPanel = document.getElementById("account");
|
||||
const accountBtn = document.getElementById("account-btn");
|
||||
const startEl = document.getElementById("start");
|
||||
const helpEl = document.getElementById("help");
|
||||
const toastEl = document.getElementById("toast");
|
||||
|
||||
const cards = []; // {track, el, canvas, liked}
|
||||
let active = -1;
|
||||
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 dragging = false; // waveform seek in progress
|
||||
let filters = loadFilters();
|
||||
|
||||
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
|
||||
// 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 = Object.keys(filters).length
|
||||
? "nothing new matches your filters right now. loosen them, or check back in a bit."
|
||||
: "nothing new right now, crawler is still digging. scroll again in a bit.";
|
||||
setTimeout(() => { status.remove(); loading = false; }, 8000);
|
||||
return;
|
||||
}
|
||||
status.remove();
|
||||
data.tracks.forEach((t) => appendCard(buildCard(t)));
|
||||
} 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, "");
|
||||
const trackUrl = (t) => `${PL8}/${t.handle}/${t.slug}`;
|
||||
// Images come through our server, so pl8list never sees the visitor's address.
|
||||
const artistImg = (t) => `/img/${encodeURIComponent(t.artist_img)}`;
|
||||
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 -----------------------------------------------------------------
|
||||
|
||||
function buildCard(track, { liked = false } = {}) {
|
||||
const el = tpl.content.firstElementChild.cloneNode(true);
|
||||
const img = track.artist_img ? `url("${artistImg(track)}")` : "";
|
||||
el.querySelector(".bg").style.backgroundImage = img;
|
||||
if (img) el.querySelector(".art").style.backgroundImage = img;
|
||||
else el.querySelector(".initial").textContent = (track.artist_name || "?")[0].toUpperCase();
|
||||
|
||||
el.querySelector(".title").textContent = cleanTitle(track.title);
|
||||
const artist = el.querySelector(".artist");
|
||||
artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : "");
|
||||
artist.href = `${PL8}/${track.handle}`;
|
||||
el.querySelector(".open").href = trackUrl(track);
|
||||
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 };
|
||||
el.querySelector(".like").classList.toggle("on", liked);
|
||||
showFollow(card);
|
||||
|
||||
el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
|
||||
el.querySelector(".follow").addEventListener("click", () => setFollow(card, !card.track.followed));
|
||||
el.querySelector(".dislike").addEventListener("click", () => dislike(card));
|
||||
el.querySelector(".share").addEventListener("click", () => share(card));
|
||||
el.querySelector(".next").addEventListener("click", () => go(cards.indexOf(card) + 1));
|
||||
bindWaveform(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function appendCard(card) {
|
||||
cards.push(card);
|
||||
feed.append(card.el);
|
||||
observer.observe(card.el);
|
||||
drawWave(card, 0);
|
||||
}
|
||||
|
||||
function insertNext(card) {
|
||||
const ref = cards[active];
|
||||
cards.splice(active + 1, 0, card);
|
||||
if (ref) ref.el.after(card.el);
|
||||
else feed.prepend(card.el);
|
||||
observer.observe(card.el);
|
||||
drawWave(card, 0);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
|
||||
function bindWaveform(card) {
|
||||
const { canvas } = card;
|
||||
const wrap = canvas.parentElement;
|
||||
const tip = wrap.querySelector(".wave-tip");
|
||||
const duration = () => (card === cards[active] && audio.duration) || card.track.duration || 0;
|
||||
const fraction = (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
||||
};
|
||||
const showTip = (f) => {
|
||||
tip.textContent = fmtTime(f * duration());
|
||||
tip.style.left = `${f * 100}%`;
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
if (card !== cards[active] || !audio.duration) return;
|
||||
e.preventDefault();
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
dragging = true;
|
||||
wrap.classList.add("hover");
|
||||
showTip(fraction(e));
|
||||
drawWave(card, fraction(e));
|
||||
});
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
if (dragging) drawWave(card, fraction(e));
|
||||
if (dragging || e.pointerType === "mouse") {
|
||||
wrap.classList.add("hover");
|
||||
showTip(fraction(e));
|
||||
}
|
||||
});
|
||||
const finish = (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
if (e.type === "pointerup") audio.currentTime = fraction(e) * audio.duration;
|
||||
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
|
||||
};
|
||||
canvas.addEventListener("pointerup", finish);
|
||||
canvas.addEventListener("pointercancel", finish);
|
||||
canvas.addEventListener("pointerleave", () => { if (!dragging) wrap.classList.remove("hover"); });
|
||||
}
|
||||
|
||||
// Playback --------------------------------------------------------------
|
||||
|
||||
function streamUrl(t) {
|
||||
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 playbackFailed() {
|
||||
toast("couldn't play this one, skipping");
|
||||
go(active + 1);
|
||||
}
|
||||
|
||||
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);
|
||||
playbackFailed();
|
||||
}
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(audio);
|
||||
} else {
|
||||
audio.src = url; // Safari plays HLS natively
|
||||
}
|
||||
audio.play().catch(() => {});
|
||||
updateMediaSession(track);
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (audio.paused) audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
}
|
||||
|
||||
function finishActive() {
|
||||
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();
|
||||
if (cards[active]) cards[active].el.classList.remove("paused", "buffering");
|
||||
active = idx;
|
||||
listened = 0;
|
||||
lastTime = 0;
|
||||
reported = false;
|
||||
if (started) play(cards[idx].track);
|
||||
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
|
||||
}
|
||||
|
||||
// Activate directly as well as scrolling: with the screen locked the
|
||||
// IntersectionObserver may not fire, but lock-screen "next" must still work.
|
||||
function go(idx) {
|
||||
if (idx < 0) return;
|
||||
if (!cards[idx]) {
|
||||
loadMore();
|
||||
return;
|
||||
}
|
||||
activate(idx);
|
||||
cards[idx].el.scrollIntoView({ behavior: document.hidden ? "instant" : "smooth" });
|
||||
}
|
||||
|
||||
function setLike(card, liked) {
|
||||
if (card.liked === liked) return;
|
||||
card.liked = liked;
|
||||
const btn = card.el.querySelector(".like");
|
||||
btn.classList.toggle("on", liked);
|
||||
if (liked) {
|
||||
btn.classList.remove("pop");
|
||||
void btn.offsetWidth; // restart the animation
|
||||
btn.classList.add("pop");
|
||||
}
|
||||
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
|
||||
sendEvent(card.track, liked ? "like" : "unlike");
|
||||
}
|
||||
|
||||
function showFollow(card) {
|
||||
const btn = card.el.querySelector(".follow");
|
||||
btn.classList.toggle("on", !!card.track.followed);
|
||||
btn.textContent = card.track.followed ? "following" : "follow";
|
||||
}
|
||||
|
||||
async function setFollow(card, follow) {
|
||||
const { handle } = card.track;
|
||||
// Every card by this artist changes together.
|
||||
const update = (on) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
|
||||
c.track.followed = on;
|
||||
showFollow(c);
|
||||
});
|
||||
update(follow);
|
||||
try {
|
||||
await postJSON("/local/follow", { handle, follow });
|
||||
toast(follow ? `following ${card.track.artist_name}` : `unfollowed ${card.track.artist_name}`);
|
||||
} catch (err) {
|
||||
update(!follow);
|
||||
toast(err.message, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
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", () => {
|
||||
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) {
|
||||
if (!dragging) drawWave(card, t / audio.duration);
|
||||
card.el.querySelector(".cur").textContent = fmtTime(t);
|
||||
}
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
if (active >= 0 && !reported) {
|
||||
sendEvent(cards[active].track, "complete", listened);
|
||||
reported = true;
|
||||
}
|
||||
go(active + 1);
|
||||
});
|
||||
|
||||
audio.addEventListener("play", () => {
|
||||
cards[active]?.el.classList.remove("paused");
|
||||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
|
||||
});
|
||||
audio.addEventListener("pause", () => {
|
||||
if (!audio.ended) cards[active]?.el.classList.add("paused");
|
||||
cards[active]?.el.classList.remove("buffering");
|
||||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
|
||||
});
|
||||
audio.addEventListener("waiting", () => cards[active]?.el.classList.add("buffering"));
|
||||
audio.addEventListener("playing", () => cards[active]?.el.classList.remove("buffering"));
|
||||
audio.addEventListener("error", () => { if (!hls && started && audio.src) playbackFailed(); });
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target));
|
||||
}
|
||||
}, { root: feed, threshold: 0.6 });
|
||||
|
||||
// Lock screen, notification and headphone controls ------------------------
|
||||
|
||||
function updateMediaSession(track) {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const artwork = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
|
||||
if (track.artist_img) artwork.unshift({ src: artistImg(track), 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 };
|
||||
});
|
||||
|
||||
// Panels and overlays -----------------------------------------------------
|
||||
|
||||
const panels = [[filtersForm, filtersBtn], [likesPanel, likesBtn], [accountPanel, accountBtn]];
|
||||
|
||||
function closePanels() {
|
||||
for (const [panel, btn] of panels) {
|
||||
panel.hidden = true;
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(panel) {
|
||||
const open = panel.hidden;
|
||||
closePanels();
|
||||
if (open) {
|
||||
panel.hidden = false;
|
||||
panels.find(([p]) => p === panel)[1].setAttribute("aria-expanded", "true");
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
const open = panels.find(([p]) => !p.hidden);
|
||||
if (!open || open[0].contains(e.target) || e.target.closest("#bar")) return;
|
||||
closePanels();
|
||||
swallowClick = true;
|
||||
setTimeout(() => { swallowClick = false; }, 500);
|
||||
}, true);
|
||||
|
||||
function showHelp() { helpEl.hidden = false; }
|
||||
function hideHelp() { helpEl.hidden = true; }
|
||||
document.getElementById("help-btn").addEventListener("click", showHelp);
|
||||
document.getElementById("help-close").addEventListener("click", hideHelp);
|
||||
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startEl.hidden = true;
|
||||
if (cards[active]) play(cards[active].track);
|
||||
if (!localStorage.getItem(HINT_KEY)) {
|
||||
localStorage.setItem(HINT_KEY, "1");
|
||||
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
|
||||
: "click to pause · double-click to like · ? for shortcuts", 5000);
|
||||
}
|
||||
}
|
||||
startEl.addEventListener("click", start);
|
||||
|
||||
// Likes -----------------------------------------------------------------
|
||||
|
||||
likesBtn.addEventListener("click", async () => {
|
||||
if (!togglePanel(likesPanel)) return;
|
||||
const list = document.getElementById("likes-list");
|
||||
const { tracks } = await (await fetch("/local/likes")).json();
|
||||
list.replaceChildren(...tracks.map((t) => {
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "play-now";
|
||||
btn.title = "play now";
|
||||
btn.innerHTML = '<svg><use href="#i-play"/></svg>';
|
||||
btn.addEventListener("click", () => {
|
||||
closePanels();
|
||||
if (!started) start();
|
||||
insertNext(buildCard(t, { liked: true }));
|
||||
go(active + 1);
|
||||
});
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
const a = document.createElement("a");
|
||||
a.href = trackUrl(t);
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.textContent = cleanTitle(t.title);
|
||||
const by = document.createElement("span");
|
||||
by.textContent = t.artist_name;
|
||||
meta.append(a, by);
|
||||
li.append(btn, meta);
|
||||
return li;
|
||||
}));
|
||||
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
|
||||
});
|
||||
|
||||
// Filters ---------------------------------------------------------------
|
||||
|
||||
function loadFilters() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FILTERS_KEY)) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function showFilters() {
|
||||
filtersForm.free.checked = filters.free === "1";
|
||||
filtersForm.bpm_min.value = filters.bpm_min || "";
|
||||
filtersForm.bpm_max.value = filters.bpm_max || "";
|
||||
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const f = new FormData(filtersForm);
|
||||
filters = {};
|
||||
if (f.get("free")) filters.free = "1";
|
||||
if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min");
|
||||
if (f.get("bpm_max")) filters.bpm_max = f.get("bpm_max");
|
||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||||
showFilters();
|
||||
closePanels();
|
||||
// Drop cards below the current one so the new filter applies right away.
|
||||
for (const c of cards.splice(active + 1)) { observer.unobserve(c.el); c.el.remove(); }
|
||||
loadMore();
|
||||
}
|
||||
|
||||
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
|
||||
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
||||
document.getElementById("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
|
||||
showFilters();
|
||||
|
||||
// Keyboard --------------------------------------------------------------
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (k === "Escape") {
|
||||
closePanels();
|
||||
hideHelp();
|
||||
return;
|
||||
}
|
||||
if (!loginEl.hidden) return;
|
||||
if (!started) {
|
||||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
|
||||
return;
|
||||
}
|
||||
const card = cards[active];
|
||||
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
|
||||
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
|
||||
else if (k === " ") { e.preventDefault(); togglePlay(); if (card) flash(card, !audio.paused); }
|
||||
else if (k === "ArrowRight") audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10);
|
||||
else if (k === "ArrowLeft") audio.currentTime = Math.max(0, audio.currentTime - 10);
|
||||
else if (k === "l" && card) setLike(card, !card.liked);
|
||||
else if (k === "f" && card) setFollow(card, !card.track.followed);
|
||||
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, i) =>
|
||||
drawWave(c, i === active && audio.duration ? audio.currentTime / audio.duration : 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");
|
||||
const loginCancel = document.getElementById("login-cancel");
|
||||
let signedInUser = null;
|
||||
|
||||
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 Object.assign(new Error(body.error || `request failed (${res.status})`), { status: res.status });
|
||||
return body;
|
||||
}
|
||||
|
||||
// `resync`: already signed in, signing in again only to sync with pl8list.
|
||||
function showLogin({ resync = false } = {}) {
|
||||
closePanels();
|
||||
document.getElementById("bar").hidden = true;
|
||||
startEl.hidden = true;
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
codeForm.hidden = true;
|
||||
loginCancel.hidden = !resync;
|
||||
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
|
||||
document.getElementById("email").focus();
|
||||
}
|
||||
|
||||
loginCancel.addEventListener("click", () => signedIn(signedInUser));
|
||||
|
||||
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, connected } = await postJSON("/auth/verify", {
|
||||
code: codeForm.code.value,
|
||||
connect: codeForm.connect.checked,
|
||||
});
|
||||
signedIn(user, { connected, synced_at: Date.now() / 1000 });
|
||||
toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Account -----------------------------------------------------------------
|
||||
|
||||
const deleteForm = document.getElementById("delete-form");
|
||||
const syncStatus = document.getElementById("sync-status");
|
||||
const syncBtn = document.getElementById("sync-btn");
|
||||
const disconnectBtn = document.getElementById("disconnect-btn");
|
||||
|
||||
function ago(ts) {
|
||||
const m = Math.round((Date.now() / 1000 - ts) / 60);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m} min ago`;
|
||||
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
|
||||
return `${Math.round(m / 1440)} days ago`;
|
||||
}
|
||||
|
||||
function showSync(pl8list) {
|
||||
const connected = !!pl8list?.connected;
|
||||
syncStatus.classList.toggle("on", connected);
|
||||
syncStatus.textContent = connected
|
||||
? `connected on this device${pl8list.synced_at ? `, last sync ${ago(pl8list.synced_at)}` : ""}. ` +
|
||||
"Likes and follows are saved to your pl8list library."
|
||||
: "not connected. Resync signs you in with pl8list once to read your feed and library.";
|
||||
disconnectBtn.hidden = !connected;
|
||||
}
|
||||
|
||||
syncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/account/pl8list/sync");
|
||||
toast("syncing with pl8list…");
|
||||
showSync({ connected: true, synced_at: Date.now() / 1000 });
|
||||
} catch (err) {
|
||||
if (err.status === 409) showLogin({ resync: true });
|
||||
else toast(err.message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
disconnectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/account/pl8list/disconnect");
|
||||
showSync({ connected: false });
|
||||
toast("disconnected from pl8list");
|
||||
} catch (err) {
|
||||
toast(err.message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
accountBtn.addEventListener("click", async () => {
|
||||
if (!togglePanel(accountPanel)) return;
|
||||
deleteForm.hidden = true;
|
||||
const res = await fetch("/local/me");
|
||||
if (res.ok) showSync((await res.json()).pl8list);
|
||||
});
|
||||
|
||||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||
await postJSON("/auth/logout");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
document.getElementById("logout-all-btn").addEventListener("click", async () => {
|
||||
await postJSON("/account/logout-all");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
document.getElementById("delete-open").addEventListener("click", () => {
|
||||
deleteForm.hidden = false;
|
||||
deleteForm.confirm.value = "";
|
||||
deleteForm.confirm.focus();
|
||||
});
|
||||
|
||||
deleteForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await postJSON("/account/delete", { confirm: deleteForm.confirm.value.trim() });
|
||||
localStorage.removeItem(FILTERS_KEY);
|
||||
localStorage.removeItem(HINT_KEY);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
toast(err.message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
function signedIn(user, pl8list) {
|
||||
signedInUser = user;
|
||||
if (pl8list) showSync(pl8list);
|
||||
loginEl.hidden = true;
|
||||
document.getElementById("bar").hidden = false;
|
||||
accountBtn.textContent = `@${user.handle}`;
|
||||
document.getElementById("account-handle").textContent = `@${user.handle}`;
|
||||
document.getElementById("delete-handle").textContent = 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();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const res = await fetch("/local/me");
|
||||
if (res.ok) {
|
||||
const me = await res.json();
|
||||
signedIn(me.user, me.pl8list);
|
||||
}
|
||||
else showLogin();
|
||||
})();
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
hls.js 1.6.16 from https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js
|
||||
sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn
|
||||
License: Apache-2.0, see hls.js.LICENSE.txt
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,f(i.key),i)}}function i(e,t,i){return t&&r(e.prototype,t),i&&r(e,i),Object.defineProperty(e,"prototype",{writable:!1}),e}function n(e,t,r){return(t=f(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function a(){return a=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},a.apply(null,arguments)}function s(e){return s=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},s(e)}function o(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,h(e,t)}function l(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(l=function(){return!!e})()}function u(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function d(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?u(Object(r),!0).forEach((function(t){n(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):u(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function h(e,t){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},h(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function c(e){var t="function"==typeof Map?new Map:void 0;return c=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(l())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&h(n,r.prototype),n}(e,arguments,s(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,e)},c(e)}function g(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var v,m,p={exports:{}},y=(v||(v=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;return s},o.prototype.listenerCount=function(e){var t=r?r+e:e,i=this._events[t];return i?i.fn?1:i.length:0},o.prototype.emit=function(e,t,i,n,a,s){var o=r?r+e:e;if(!this._events[o])return!1;var l,u,d=this._events[o],h=arguments.length;if(d.fn){switch(d.once&&this.removeListener(e,d.fn,void 0,!0),h){case 1:return d.fn.call(d.context),!0;case 2:return d.fn.call(d.context,t),!0;case 3:return d.fn.call(d.context,t,i),!0;case 4:return d.fn.call(d.context,t,i,n),!0;case 5:return d.fn.call(d.context,t,i,n,a),!0;case 6:return d.fn.call(d.context,t,i,n,a,s),!0}for(u=1,l=new Array(h-1);u<h;u++)l[u-1]=arguments[u];d.fn.apply(d.context,l)}else{var f,c=d.length;for(u=0;u<c;u++)switch(d[u].once&&this.removeListener(e,d[u].fn,void 0,!0),h){case 1:d[u].fn.call(d[u].context);break;case 2:d[u].fn.call(d[u].context,t);break;case 3:d[u].fn.call(d[u].context,t,i);break;case 4:d[u].fn.call(d[u].context,t,i,n);break;default:if(!l)for(f=1,l=new Array(h-1);f<h;f++)l[f-1]=arguments[f];d[u].fn.apply(d[u].context,l)}}return!0},o.prototype.on=function(e,t,r){return a(this,e,t,r,!1)},o.prototype.once=function(e,t,r){return a(this,e,t,r,!0)},o.protLine truncated
|
||||
//# sourceMappingURL=hls.min.js.map
|
||||
@@ -10,13 +10,12 @@
|
||||
<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">
|
||||
<!-- Served by us, not a CDN, so no visitor address goes to a third party. -->
|
||||
<script src="/static/vendor/hls.min.js"
|
||||
integrity="sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn"></script>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<!-- Everything, hls.js included, is bundled and served by us: no visitor address
|
||||
goes to a CDN or to pl8list. -->
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Shared icons, referenced with <use href="#i-…">. -->
|
||||
@@ -117,6 +116,7 @@
|
||||
</aside>
|
||||
|
||||
<main id="feed"></main>
|
||||
<p id="feed-status" role="status" hidden></p>
|
||||
|
||||
<div id="start" hidden>
|
||||
<button type="button">tap anywhere to start</button>
|
||||
@@ -128,13 +128,13 @@
|
||||
<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>swipe up / down</dt><dd>next / previous</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>↓ j · ↑ k · wheel</dt><dd>next / previous</dd>
|
||||
<dt>← →</dt><dd>seek 10 s</dd>
|
||||
<dt>f</dt><dd>follow artist</dd>
|
||||
<dt>x</dt><dd>not for me</dd>
|
||||
@@ -149,11 +149,10 @@
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<audio id="audio" preload="auto"></audio>
|
||||
|
||||
<template id="card-tpl">
|
||||
<section class="card">
|
||||
<div class="bg"></div>
|
||||
<div class="glow"></div>
|
||||
<div class="content">
|
||||
<div class="art">
|
||||
<span class="initial"></span>
|
||||
@@ -184,6 +183,5 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1211
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "pl8inf-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.5"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,15 @@
|
||||
/* Impressum and privacy pages. Plain CSS outside the app bundle, so the server's
|
||||
templates can link it by a fixed name. */
|
||||
:root { color-scheme: dark; --fg: #f2f2f2; --muted: #9a9a9a; --accent: #ff4d6d; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #050507;
|
||||
color: var(--fg);
|
||||
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main { max-width: 42rem; margin: 0 auto; padding: 2rem 1.2rem 4rem; }
|
||||
h1 { font-size: 1.8rem; }
|
||||
h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||
a { color: var(--accent); }
|
||||
code { font-size: 0.9em; }
|
||||
File renamed without changes.
+105
@@ -0,0 +1,105 @@
|
||||
// Calls to our own server. The browser never talks to pl8list directly.
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
handle: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
artist_name: string;
|
||||
artist_img: string | null;
|
||||
artist_verified: boolean;
|
||||
bpm: number | null;
|
||||
key: string | null;
|
||||
duration: number | null;
|
||||
price: number;
|
||||
created_at: string | null;
|
||||
saves: number;
|
||||
downloads: number;
|
||||
purchases: number;
|
||||
comments: number;
|
||||
preview: boolean;
|
||||
upload_id: string | null;
|
||||
waveform: number[] | null;
|
||||
followed?: boolean;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
tracks: number;
|
||||
artists: number;
|
||||
artists_crawled: number;
|
||||
served: number;
|
||||
liked: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
handle: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface Pl8Status {
|
||||
connected: boolean;
|
||||
synced_at: number | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: User;
|
||||
pl8list: Pl8Status;
|
||||
}
|
||||
|
||||
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
|
||||
|
||||
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HttpError(body.error || `request failed (${res.status})`, res.status);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function getJSON<T>(url: string): Promise<T> {
|
||||
return parse<T>(await fetch(url));
|
||||
}
|
||||
|
||||
export async function postJSON<T = { ok: boolean }>(url: string, data?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data ?? {}),
|
||||
});
|
||||
return parse<T>(res);
|
||||
}
|
||||
|
||||
/// Fire and forget; survives the page closing.
|
||||
export function sendEvent(track: Track, kind: EventKind, seconds?: number): void {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export const feed = (filters: Filters) =>
|
||||
getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + new URLSearchParams({ n: "10", ...filters }));
|
||||
|
||||
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
|
||||
|
||||
export const me = () => getJSON<Me>("/local/me");
|
||||
|
||||
export const PL8 = "https://pl8list.com";
|
||||
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
|
||||
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
|
||||
// Images come through our server, so pl8list never sees the visitor's address.
|
||||
export const artistImg = (t: Track) => (t.artist_img ? `/img/${encodeURIComponent(t.artist_img)}` : null);
|
||||
|
||||
export function streamUrl(t: Track): string {
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// One card per track. Elements are built when a card first comes near the screen
|
||||
// and dropped again once it is far behind, so a long session stays light.
|
||||
|
||||
import { type Track, artistImg, artistUrl, trackUrl } from "./api";
|
||||
import { accentFor } from "./color";
|
||||
|
||||
export interface CardActions {
|
||||
like(card: Card): void;
|
||||
dislike(card: Card): void;
|
||||
share(card: Card): void;
|
||||
next(card: Card): void;
|
||||
follow(card: Card): void;
|
||||
/// Seeking only works on the playing card.
|
||||
canSeek(card: Card): boolean;
|
||||
duration(card: Card): number;
|
||||
seek(card: Card, seconds: number): void;
|
||||
}
|
||||
|
||||
// Uploads are often titled after their file name.
|
||||
export const cleanTitle = (t: string) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
|
||||
export const fmtTime = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
||||
|
||||
const tpl = document.getElementById("card-tpl") as HTMLTemplateElement;
|
||||
|
||||
export class Card {
|
||||
el: HTMLElement | null = null;
|
||||
liked: boolean;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private drawn = "";
|
||||
private dragging = false;
|
||||
private accent: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly track: Track,
|
||||
private actions: CardActions,
|
||||
{ liked = false } = {},
|
||||
) {
|
||||
this.liked = liked;
|
||||
}
|
||||
|
||||
element(): HTMLElement {
|
||||
return this.el ?? this.build();
|
||||
}
|
||||
|
||||
/// Forget the element; it is built again if the card comes back.
|
||||
release(): void {
|
||||
this.el?.remove();
|
||||
this.el = this.canvas = null;
|
||||
this.drawn = "";
|
||||
}
|
||||
|
||||
private build(): HTMLElement {
|
||||
const t = this.track;
|
||||
const el = tpl.content.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||
const $ = <T extends HTMLElement>(sel: string) => el.querySelector(sel) as T;
|
||||
const img = artistImg(t);
|
||||
if (img) {
|
||||
$(".bg").style.backgroundImage = `url("${img}")`;
|
||||
$(".art").style.backgroundImage = `url("${img}")`;
|
||||
if (this.accent) el.style.setProperty("--card-accent", this.accent);
|
||||
else accentFor(img).then((c) => {
|
||||
this.accent = c;
|
||||
if (c && this.el) {
|
||||
this.el.style.setProperty("--card-accent", c);
|
||||
this.drawWave(this.lastProgress, true);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
|
||||
}
|
||||
|
||||
$(".title").textContent = cleanTitle(t.title);
|
||||
const artist = $<HTMLAnchorElement>(".artist");
|
||||
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
|
||||
artist.href = artistUrl(t);
|
||||
$<HTMLAnchorElement>(".open").href = trackUrl(t);
|
||||
$(".desc").textContent = t.description || "";
|
||||
$(".dur").textContent = t.duration ? fmtTime(t.duration) : "";
|
||||
|
||||
const chips = $(".chips");
|
||||
const chip = (text: string, cls = "") => {
|
||||
const s = document.createElement("span");
|
||||
s.textContent = text;
|
||||
if (cls) s.className = cls;
|
||||
chips.append(s);
|
||||
};
|
||||
if (t.bpm) chip(`${Math.round(t.bpm)} bpm`);
|
||||
if (t.key) chip(t.key);
|
||||
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
|
||||
if (t.preview) chip("preview", "preview");
|
||||
|
||||
$(".counts").textContent =
|
||||
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
|
||||
(t.purchases ? ` · ${t.purchases} sold` : "");
|
||||
|
||||
$(".like").addEventListener("click", () => this.actions.like(this));
|
||||
$(".dislike").addEventListener("click", () => this.actions.dislike(this));
|
||||
$(".share").addEventListener("click", () => this.actions.share(this));
|
||||
$(".next").addEventListener("click", () => this.actions.next(this));
|
||||
$(".follow").addEventListener("click", () => this.actions.follow(this));
|
||||
|
||||
this.el = el;
|
||||
this.canvas = $<HTMLCanvasElement>(".wave");
|
||||
this.bindWaveform();
|
||||
this.showLike();
|
||||
this.showFollow();
|
||||
requestAnimationFrame(() => this.drawWave(this.lastProgress, true));
|
||||
return el;
|
||||
}
|
||||
|
||||
showLike(pop = false): void {
|
||||
const btn = this.el?.querySelector(".like");
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("on", this.liked);
|
||||
if (pop && this.liked) {
|
||||
btn.classList.remove("pop");
|
||||
void (btn as HTMLElement).offsetWidth; // restart the animation
|
||||
btn.classList.add("pop");
|
||||
}
|
||||
}
|
||||
|
||||
showFollow(): void {
|
||||
const btn = this.el?.querySelector(".follow");
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("on", !!this.track.followed);
|
||||
btn.textContent = this.track.followed ? "following" : "follow";
|
||||
}
|
||||
|
||||
setTime(seconds: number): void {
|
||||
const cur = this.el?.querySelector(".cur");
|
||||
if (cur) cur.textContent = fmtTime(seconds);
|
||||
}
|
||||
|
||||
private lastProgress = 0;
|
||||
|
||||
/// Draw the waveform with `progress` (0..1) played. Cheap to call every frame:
|
||||
/// it only redraws when a bar changes colour or the size changed.
|
||||
drawWave(progress: number, force = false): void {
|
||||
if (this.dragging && !force) return;
|
||||
this.lastProgress = progress;
|
||||
const canvas = this.canvas;
|
||||
if (!canvas) return;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (!w) return;
|
||||
const bars = Math.max(1, Math.floor(w / 3));
|
||||
const played = Math.round(progress * bars);
|
||||
const key = `${w}x${h}:${played}`;
|
||||
if (!force && key === this.drawn) return;
|
||||
this.drawn = key;
|
||||
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 = this.track.waveform || [];
|
||||
const max = Math.max(1, ...data);
|
||||
const accent = getComputedStyle(canvas).getPropertyValue("--card-accent").trim() || "#ff4d6d";
|
||||
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 < played ? accent : "rgba(255,255,255,0.35)";
|
||||
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser
|
||||
/// where there is no Web Audio graph.
|
||||
loudnessAt(progress: number): number {
|
||||
const data = this.track.waveform;
|
||||
if (!data?.length) return 0;
|
||||
this.peak ??= Math.max(1, ...data);
|
||||
return data[Math.min(data.length - 1, Math.floor(progress * data.length))] / this.peak;
|
||||
}
|
||||
|
||||
private peak: number | undefined;
|
||||
|
||||
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
|
||||
private bindWaveform(): void {
|
||||
const canvas = this.canvas!;
|
||||
const wrap = canvas.parentElement!;
|
||||
const tip = wrap.querySelector(".wave-tip") as HTMLElement;
|
||||
const fraction = (e: PointerEvent) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
||||
};
|
||||
const showTip = (f: number) => {
|
||||
tip.textContent = fmtTime(f * this.actions.duration(this));
|
||||
tip.style.left = `${f * 100}%`;
|
||||
};
|
||||
canvas.addEventListener("pointerdown", (e) => {
|
||||
if (!this.actions.canSeek(this)) return;
|
||||
e.preventDefault();
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
this.dragging = true;
|
||||
wrap.classList.add("hover");
|
||||
showTip(fraction(e));
|
||||
this.drawWave(fraction(e), true);
|
||||
});
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
if (this.dragging) this.drawWave(fraction(e), true);
|
||||
if (this.dragging || e.pointerType === "mouse") {
|
||||
wrap.classList.add("hover");
|
||||
showTip(fraction(e));
|
||||
}
|
||||
});
|
||||
const finish = (e: PointerEvent) => {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
if (e.type === "pointerup") this.actions.seek(this, fraction(e) * this.actions.duration(this));
|
||||
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
|
||||
};
|
||||
canvas.addEventListener("pointerup", finish);
|
||||
canvas.addEventListener("pointercancel", finish);
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
if (!this.dragging) wrap.classList.remove("hover");
|
||||
});
|
||||
}
|
||||
|
||||
/// Play/pause icon that pops up and fades.
|
||||
flash(playing: boolean): void {
|
||||
if (!this.el) return;
|
||||
const f = document.createElement("div");
|
||||
f.className = "flash";
|
||||
f.innerHTML = `<svg><use href="#i-${playing ? "play" : "pause"}"/></svg>`;
|
||||
this.el.append(f);
|
||||
f.addEventListener("animationend", () => f.remove());
|
||||
}
|
||||
|
||||
clearFlash(): void {
|
||||
this.el?.querySelectorAll(".flash").forEach((f) => f.remove());
|
||||
}
|
||||
|
||||
/// Heart burst where a double-tap landed.
|
||||
burst(x: number, y: number): void {
|
||||
if (!this.el) return;
|
||||
const r = this.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 = `${x - r.left}px`;
|
||||
b.style.top = `${y - r.top}px`;
|
||||
b.style.setProperty("--tilt", `${Math.round(Math.random() * 30 - 15)}deg`);
|
||||
this.el.append(b);
|
||||
b.addEventListener("animationend", () => b.remove());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Accent colour from an artist image: the most vivid hue, at a lightness that reads
|
||||
// well on the dark background. Images are same-origin (proxied), so the canvas can
|
||||
// read them.
|
||||
|
||||
const cache = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function accentFor(src: string): Promise<string | null> {
|
||||
let p = cache.get(src);
|
||||
if (!p) {
|
||||
p = compute(src).catch(() => null);
|
||||
cache.set(src, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async function compute(src: string): Promise<string | null> {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
await img.decode();
|
||||
const size = 24;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
const px = ctx.getImageData(0, 0, size, size).data;
|
||||
// Average hue as a vector, weighted by how vivid each pixel is.
|
||||
let x = 0, y = 0, weight = 0, sat = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const [h, s, l] = hsl(px[i], px[i + 1], px[i + 2]);
|
||||
const w = s * (1 - Math.abs(l - 0.5) * 2);
|
||||
x += Math.cos(h * 2 * Math.PI) * w;
|
||||
y += Math.sin(h * 2 * Math.PI) * w;
|
||||
sat += s * w;
|
||||
weight += w;
|
||||
}
|
||||
// Black-and-white artwork: keep the default accent.
|
||||
if (weight < size * size * 0.04) return null;
|
||||
const hue = ((Math.atan2(y, x) / (2 * Math.PI) + 1) % 1) * 360;
|
||||
const s = Math.min(90, Math.max(60, (sat / weight) * 100));
|
||||
return `hsl(${hue.toFixed(0)} ${s.toFixed(0)}% 62%)`;
|
||||
}
|
||||
|
||||
function hsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l];
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
return [h / 6, s, l];
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
// hls.js ships types for its full build only; the light build has the same API
|
||||
// minus subtitles, alternate audio and DRM, none of which we use.
|
||||
declare module "hls.js/light" {
|
||||
export * from "hls.js";
|
||||
export { default } from "hls.js";
|
||||
}
|
||||
+686
@@ -0,0 +1,686 @@
|
||||
import "./style.css";
|
||||
|
||||
import * as api from "./api";
|
||||
import type { Filters, Pl8Status, Stats, Track, User } from "./api";
|
||||
import { Card, type CardActions, cleanTitle } from "./cards";
|
||||
import { Pager } from "./pager";
|
||||
import { Player } from "./player";
|
||||
import { Visualizer } from "./visual";
|
||||
|
||||
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
|
||||
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
|
||||
const COMPLETE_RATIO = 0.85;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const PRELOAD_AFTER_MS = 2500; // start buffering the next track once this one has played a bit
|
||||
const KEEP_BUILT = 3; // cards further than this from the current one drop their elements
|
||||
const FILTERS_KEY = "pl8inf.filters";
|
||||
const HINT_KEY = "pl8inf.hintShown";
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
const feedEl = $("feed");
|
||||
const feedStatus = $("feed-status");
|
||||
const statsEl = $("stats");
|
||||
const filtersForm = $<HTMLFormElement>("filters");
|
||||
const filtersBtn = $("filters-btn");
|
||||
const likesPanel = $("likes");
|
||||
const likesBtn = $("likes-btn");
|
||||
const accountPanel = $("account");
|
||||
const accountBtn = $("account-btn");
|
||||
const startEl = $("start");
|
||||
const helpEl = $("help");
|
||||
const toastEl = $("toast");
|
||||
const loginEl = $("login");
|
||||
|
||||
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
|
||||
const player = new Player();
|
||||
const visual = new Visualizer(player);
|
||||
const cards: Card[] = [];
|
||||
let active = -1;
|
||||
let loading = false;
|
||||
let started = false;
|
||||
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 advanced = false; // already mixed into the next track
|
||||
let preloadTimer = 0;
|
||||
let filters: Filters = loadFilters();
|
||||
let stats: Stats | null = null;
|
||||
|
||||
const current = () => cards[active] as Card | undefined;
|
||||
|
||||
// Feed ------------------------------------------------------------------
|
||||
|
||||
const pager = new Pager(feedEl, {
|
||||
count: () => cards.length,
|
||||
element: (i) => cards[i].element(),
|
||||
onChange: (i) => activate(i),
|
||||
onNeedMore: () => loadMore(),
|
||||
});
|
||||
|
||||
function setStatus(text: string): void {
|
||||
feedStatus.textContent = text;
|
||||
feedStatus.hidden = !text;
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
|
||||
try {
|
||||
const data = await api.feed(filters);
|
||||
showStats(data.stats);
|
||||
if (!data.tracks.length) {
|
||||
setStatus(Object.keys(filters).length
|
||||
? "nothing new matches your filters right now. loosen them, or check back in a bit."
|
||||
: "nothing new right now, the crawler is still digging. try again in a bit.");
|
||||
setTimeout(() => { loading = false; }, 8000);
|
||||
return;
|
||||
}
|
||||
setStatus("");
|
||||
cards.push(...data.tracks.map((t) => new Card(t, actions)));
|
||||
pager.refresh();
|
||||
if (active < 0) activate(0);
|
||||
} catch (err) {
|
||||
if (err instanceof api.HttpError && err.status === 401) {
|
||||
loading = false;
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
setStatus(`failed to load: ${(err as Error).message}`);
|
||||
setTimeout(() => setStatus(""), 5000);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function showStats(s?: Stats): void {
|
||||
if (s) stats = s;
|
||||
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
|
||||
}
|
||||
|
||||
let toastTimer = 0;
|
||||
function toast(text: string, ms = 2200): void {
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => toastEl.classList.remove("show"), ms);
|
||||
}
|
||||
|
||||
function finishActive(): void {
|
||||
const card = current();
|
||||
if (!card || reported) return;
|
||||
reported = true;
|
||||
// Flicking past a card before any audio played says nothing about the track.
|
||||
if (listened < 1) return;
|
||||
const dur = player.audio.duration || card.track.duration || 0;
|
||||
if (dur && listened / dur >= COMPLETE_RATIO) api.sendEvent(card.track, "complete", listened);
|
||||
else if (listened < SKIP_SECONDS) api.sendEvent(card.track, "skip", listened);
|
||||
else api.sendEvent(card.track, "play", listened);
|
||||
}
|
||||
|
||||
function activate(idx: number): void {
|
||||
if (idx === active || !cards[idx]) return;
|
||||
finishActive();
|
||||
current()?.el?.classList.remove("paused", "buffering", "current");
|
||||
active = idx;
|
||||
listened = 0;
|
||||
lastTime = 0;
|
||||
reported = false;
|
||||
advanced = false;
|
||||
const card = cards[idx];
|
||||
if (pager.index !== idx) pager.go(idx, { instant: true });
|
||||
card.element().classList.add("current");
|
||||
visual.attach(card);
|
||||
if (started) play(card.track);
|
||||
clearTimeout(preloadTimer);
|
||||
preloadTimer = window.setTimeout(() => {
|
||||
const next = cards[active + 1];
|
||||
if (next && started) player.preload(next.track);
|
||||
}, PRELOAD_AFTER_MS);
|
||||
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
|
||||
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
|
||||
}
|
||||
|
||||
function go(idx: number): void {
|
||||
if (!pager.go(idx, { instant: document.hidden }) && idx >= cards.length) loadMore();
|
||||
}
|
||||
|
||||
function play(track: Track): void {
|
||||
player.play(track);
|
||||
updateMediaSession(track);
|
||||
}
|
||||
|
||||
function playbackFailed(): void {
|
||||
toast("couldn't play this one, skipping");
|
||||
go(active + 1);
|
||||
}
|
||||
|
||||
// Card actions ------------------------------------------------------------
|
||||
|
||||
const actions: CardActions = {
|
||||
like: (c) => setLike(c, !c.liked),
|
||||
dislike: (c) => dislike(c),
|
||||
share: (c) => share(c),
|
||||
next: (c) => go(cards.indexOf(c) + 1),
|
||||
follow: (c) => setFollow(c, !c.track.followed),
|
||||
canSeek: (c) => c === current() && !!player.audio.duration,
|
||||
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
||||
seek: (_, s) => player.seek(s),
|
||||
};
|
||||
|
||||
function setLike(card: Card, liked: boolean): void {
|
||||
if (card.liked === liked) return;
|
||||
card.liked = liked;
|
||||
card.showLike(true);
|
||||
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
|
||||
api.sendEvent(card.track, liked ? "like" : "unlike");
|
||||
}
|
||||
|
||||
function dislike(card: Card): void {
|
||||
api.sendEvent(card.track, "dislike");
|
||||
if (card === current()) 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 setFollow(card: Card, follow: boolean): Promise<void> {
|
||||
const { handle, artist_name } = card.track;
|
||||
// Every card by this artist changes together.
|
||||
const update = (on: boolean) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
|
||||
c.track.followed = on;
|
||||
c.showFollow();
|
||||
});
|
||||
update(follow);
|
||||
try {
|
||||
await api.postJSON("/local/follow", { handle, follow });
|
||||
toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`);
|
||||
} catch (err) {
|
||||
update(!follow);
|
||||
toast((err as Error).message, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
async function share(card: Card): Promise<void> {
|
||||
const { track } = card;
|
||||
const url = api.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);
|
||||
}
|
||||
}
|
||||
|
||||
// Player events -----------------------------------------------------------
|
||||
|
||||
player.on("timeupdate", () => {
|
||||
const a = player.audio;
|
||||
const t = a.currentTime;
|
||||
const d = t - lastTime;
|
||||
if (d > 0 && d < 1.5) listened += d; // ignore seeks
|
||||
lastTime = t;
|
||||
current()?.setTime(t);
|
||||
updatePosition();
|
||||
// Where tracks can be mixed, start the next one just before this one ends.
|
||||
if (player.mixing && !advanced && !document.hidden && a.duration && a.duration - t < 1 && cards[active + 1]) {
|
||||
advanced = true;
|
||||
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
|
||||
go(active + 1);
|
||||
}
|
||||
});
|
||||
|
||||
player.on("ended", () => {
|
||||
const card = current();
|
||||
if (card && !reported) {
|
||||
api.sendEvent(card.track, "complete", listened);
|
||||
reported = true;
|
||||
}
|
||||
go(active + 1);
|
||||
});
|
||||
|
||||
player.on("play", () => {
|
||||
current()?.el?.classList.remove("paused");
|
||||
visual.start();
|
||||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
|
||||
});
|
||||
player.on("pause", () => {
|
||||
if (!player.audio.ended) current()?.el?.classList.add("paused");
|
||||
current()?.el?.classList.remove("buffering");
|
||||
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
|
||||
});
|
||||
player.on("waiting", () => current()?.el?.classList.add("buffering"));
|
||||
player.on("playing", () => current()?.el?.classList.remove("buffering"));
|
||||
player.on("failed", () => { if (started) playbackFailed(); });
|
||||
|
||||
// Lock screen, notification and headphone controls ------------------------
|
||||
|
||||
function updateMediaSession(track: Track): void {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const artwork: MediaImage[] = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
|
||||
const img = api.artistImg(track);
|
||||
if (img) artwork.unshift({ src: img, sizes: "512x512" });
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: cleanTitle(track.title),
|
||||
artist: track.artist_name,
|
||||
album: "pl8list ∞",
|
||||
artwork,
|
||||
});
|
||||
}
|
||||
|
||||
let lastPositionUpdate = 0;
|
||||
function updatePosition(): void {
|
||||
if (!("mediaSession" in navigator) || !navigator.mediaSession.setPositionState) return;
|
||||
const a = player.audio;
|
||||
const now = performance.now();
|
||||
if (now - lastPositionUpdate < 1000 || !Number.isFinite(a.duration)) return;
|
||||
lastPositionUpdate = now;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: a.duration,
|
||||
position: Math.min(a.currentTime, a.duration),
|
||||
playbackRate: a.playbackRate || 1,
|
||||
});
|
||||
} catch { /* duration not known yet */ }
|
||||
}
|
||||
|
||||
if ("mediaSession" in navigator) {
|
||||
const a = () => player.audio;
|
||||
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
||||
["play", () => player.resume()],
|
||||
["pause", () => player.pause()],
|
||||
["nexttrack", () => go(active + 1)],
|
||||
["previoustrack", () => (a().currentTime > 5 ? player.seek(0) : go(active - 1))],
|
||||
["seekto", (d) => player.seek(d.seekTime ?? 0)],
|
||||
["seekforward", (d) => player.seek(a().currentTime + (d.seekOffset || 10))],
|
||||
["seekbackward", (d) => player.seek(a().currentTime - (d.seekOffset || 10))],
|
||||
];
|
||||
for (const [action, fn] of handlers) {
|
||||
try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Tap to play/pause, double-tap to like ------------------------------------
|
||||
|
||||
let lastTap: { at: number; card: Card | null; toggled: boolean } = { at: 0, card: null, toggled: false };
|
||||
let swallowClick = false; // the tap that only closed a panel
|
||||
|
||||
feedEl.addEventListener("click", (e) => {
|
||||
if (swallowClick || pager.suppressClick) { swallowClick = false; return; }
|
||||
const target = e.target as Element;
|
||||
if (target.closest("button, a, input, .wave-wrap")) return;
|
||||
const el = 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) {
|
||||
player.toggle();
|
||||
card.clearFlash();
|
||||
}
|
||||
card.burst(e.clientX, e.clientY);
|
||||
setLike(card, true);
|
||||
lastTap = { at: 0, card: null, toggled: false };
|
||||
return;
|
||||
}
|
||||
|
||||
const toggled = card === current();
|
||||
if (toggled) {
|
||||
player.toggle();
|
||||
card.flash(!player.audio.paused);
|
||||
}
|
||||
lastTap = { at: now, card, toggled };
|
||||
});
|
||||
|
||||
// Any gesture may set up the audio graph; browsers only allow it from one.
|
||||
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, () => { if (started) player.unlock(); }, true);
|
||||
|
||||
// Panels and overlays -----------------------------------------------------
|
||||
|
||||
const panels: [HTMLElement, HTMLElement][] = [[filtersForm, filtersBtn], [likesPanel, likesBtn], [accountPanel, accountBtn]];
|
||||
|
||||
function closePanels(): void {
|
||||
for (const [panel, btn] of panels) {
|
||||
panel.hidden = true;
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(panel: HTMLElement): boolean {
|
||||
const open = panel.hidden !== false;
|
||||
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);
|
||||
const target = e.target as Element;
|
||||
if (!open || open[0].contains(target) || target.closest("#bar")) return;
|
||||
closePanels();
|
||||
swallowClick = true;
|
||||
setTimeout(() => { swallowClick = false; }, 500);
|
||||
}, true);
|
||||
|
||||
const showHelp = () => { helpEl.hidden = false; };
|
||||
const hideHelp = () => { helpEl.hidden = true; };
|
||||
$("help-btn").addEventListener("click", showHelp);
|
||||
$("help-close").addEventListener("click", hideHelp);
|
||||
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
|
||||
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startEl.hidden = true;
|
||||
player.unlock();
|
||||
const card = current();
|
||||
if (card) activateAgain(card);
|
||||
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);
|
||||
|
||||
/// Start playing the current card after `start()`; activate() did not, as nothing played yet.
|
||||
function activateAgain(card: Card): void {
|
||||
play(card.track);
|
||||
clearTimeout(preloadTimer);
|
||||
preloadTimer = window.setTimeout(() => {
|
||||
const next = cards[active + 1];
|
||||
if (next) player.preload(next.track);
|
||||
}, PRELOAD_AFTER_MS);
|
||||
}
|
||||
|
||||
// Likes -----------------------------------------------------------------
|
||||
|
||||
likesBtn.addEventListener("click", async () => {
|
||||
if (!togglePanel(likesPanel)) return;
|
||||
const list = $("likes-list");
|
||||
const { tracks } = await api.likes();
|
||||
list.replaceChildren(...tracks.map((t) => {
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "play-now";
|
||||
btn.title = "play now";
|
||||
btn.innerHTML = '<svg><use href="#i-play"/></svg>';
|
||||
btn.addEventListener("click", () => {
|
||||
closePanels();
|
||||
if (!started) start();
|
||||
cards.splice(active + 1, 0, new Card(t, actions, { liked: true }));
|
||||
pager.refresh();
|
||||
go(active + 1);
|
||||
});
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
const a = document.createElement("a");
|
||||
a.href = api.trackUrl(t);
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.textContent = cleanTitle(t.title);
|
||||
const by = document.createElement("span");
|
||||
by.textContent = t.artist_name + (t.followed ? " · following" : "");
|
||||
meta.append(a, by);
|
||||
li.append(btn, meta);
|
||||
return li;
|
||||
}));
|
||||
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
|
||||
});
|
||||
|
||||
// Filters ---------------------------------------------------------------
|
||||
|
||||
function loadFilters(): Filters {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function showFilters(): void {
|
||||
const f = filtersForm.elements as unknown as Record<string, HTMLInputElement>;
|
||||
f.free.checked = filters.free === "1";
|
||||
f.bpm_min.value = filters.bpm_min || "";
|
||||
f.bpm_max.value = filters.bpm_max || "";
|
||||
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
|
||||
}
|
||||
|
||||
function applyFilters(): void {
|
||||
const f = new FormData(filtersForm);
|
||||
filters = {};
|
||||
if (f.get("free")) filters.free = "1";
|
||||
if (f.get("bpm_min")) filters.bpm_min = String(f.get("bpm_min"));
|
||||
if (f.get("bpm_max")) filters.bpm_max = String(f.get("bpm_max"));
|
||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||||
showFilters();
|
||||
closePanels();
|
||||
// Drop cards after the current one so the new filter applies right away.
|
||||
for (const c of cards.splice(active + 1)) c.release();
|
||||
pager.refresh();
|
||||
loadMore();
|
||||
}
|
||||
|
||||
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
|
||||
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
||||
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
|
||||
showFilters();
|
||||
|
||||
// Keyboard --------------------------------------------------------------
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
if (k === "Escape") {
|
||||
closePanels();
|
||||
hideHelp();
|
||||
return;
|
||||
}
|
||||
if (!loginEl.hidden) return;
|
||||
if (!started) {
|
||||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
|
||||
return;
|
||||
}
|
||||
const card = current();
|
||||
const a = player.audio;
|
||||
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(); player.toggle(); card?.flash(!a.paused); }
|
||||
else if (k === "ArrowRight") player.seek(a.currentTime + 10);
|
||||
else if (k === "ArrowLeft") player.seek(a.currentTime - 10);
|
||||
else if (k === "l" && card) setLike(card, !card.liked);
|
||||
else if (k === "f" && card) setFollow(card, !card.track.followed);
|
||||
else if (k === "x" && card) dislike(card);
|
||||
else if (k === "s" && card) share(card);
|
||||
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
|
||||
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
|
||||
});
|
||||
|
||||
addEventListener("resize", () => current()?.drawWave(player.audio.duration ? player.audio.currentTime / player.audio.duration : 0, true));
|
||||
addEventListener("pagehide", finishActive);
|
||||
|
||||
// Sign-in -----------------------------------------------------------------
|
||||
|
||||
const emailForm = $<HTMLFormElement>("email-form");
|
||||
const codeForm = $<HTMLFormElement>("code-form");
|
||||
const codeInput = $<HTMLInputElement>("code");
|
||||
const connectInput = $<HTMLInputElement>("connect");
|
||||
const loginError = $("login-error");
|
||||
const loginCancel = $("login-cancel");
|
||||
let signedInUser: User | null = null;
|
||||
|
||||
// `resync`: already signed in, signing in again only to sync with pl8list.
|
||||
function showLogin({ resync = false } = {}): void {
|
||||
closePanels();
|
||||
$("bar").hidden = true;
|
||||
startEl.hidden = true;
|
||||
loginEl.hidden = false;
|
||||
emailForm.hidden = false;
|
||||
codeForm.hidden = true;
|
||||
loginCancel.hidden = !resync;
|
||||
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
|
||||
$("email").focus();
|
||||
}
|
||||
|
||||
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); });
|
||||
|
||||
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
|
||||
const buttons = form.querySelectorAll("button");
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
loginError.textContent = "";
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
loginError.textContent = (err as Error).message;
|
||||
}
|
||||
buttons.forEach((b) => { b.disabled = false; });
|
||||
}
|
||||
|
||||
emailForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
withForm(emailForm, async () => {
|
||||
const { email } = await api.postJSON<{ email: string }>("/auth/start", { email: $<HTMLInputElement>("email").value });
|
||||
$("sent-to").textContent = email;
|
||||
emailForm.hidden = true;
|
||||
codeForm.hidden = false;
|
||||
codeInput.value = "";
|
||||
codeInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
codeForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
withForm(codeForm, async () => {
|
||||
const { user, connected } = await api.postJSON<{ user: User; connected: boolean }>("/auth/verify", {
|
||||
code: codeInput.value,
|
||||
connect: connectInput.checked,
|
||||
});
|
||||
signedIn(user, { connected, synced_at: Date.now() / 1000 });
|
||||
toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
|
||||
});
|
||||
});
|
||||
|
||||
codeInput.addEventListener("input", () => {
|
||||
if (/^\d{6}$/.test(codeInput.value)) codeForm.requestSubmit();
|
||||
});
|
||||
|
||||
$("resend").addEventListener("click", () => {
|
||||
withForm(codeForm, async () => {
|
||||
await api.postJSON("/auth/resend");
|
||||
loginError.textContent = "new code sent";
|
||||
});
|
||||
});
|
||||
|
||||
$("restart").addEventListener("click", () => showLogin());
|
||||
|
||||
// Account -----------------------------------------------------------------
|
||||
|
||||
const deleteForm = $<HTMLFormElement>("delete-form");
|
||||
const deleteConfirm = $<HTMLInputElement>("delete-confirm");
|
||||
const syncStatus = $("sync-status");
|
||||
const disconnectBtn = $("disconnect-btn");
|
||||
|
||||
function ago(ts: number): string {
|
||||
const m = Math.round((Date.now() / 1000 - ts) / 60);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m} min ago`;
|
||||
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
|
||||
return `${Math.round(m / 1440)} days ago`;
|
||||
}
|
||||
|
||||
function showSync(s: Pl8Status | undefined): void {
|
||||
const connected = !!s?.connected;
|
||||
syncStatus.classList.toggle("on", connected);
|
||||
syncStatus.textContent = connected
|
||||
? `connected on this device${s?.synced_at ? `, last sync ${ago(s.synced_at)}` : ""}. ` +
|
||||
"Likes and follows are saved to your pl8list library."
|
||||
: "not connected. Resync signs you in with pl8list once to read your feed and library.";
|
||||
disconnectBtn.hidden = !connected;
|
||||
}
|
||||
|
||||
accountBtn.addEventListener("click", async () => {
|
||||
if (!togglePanel(accountPanel)) return;
|
||||
deleteForm.hidden = true;
|
||||
try { showSync((await api.me()).pl8list); } catch { /* keep what is shown */ }
|
||||
});
|
||||
|
||||
$("sync-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await api.postJSON("/account/pl8list/sync");
|
||||
toast("syncing with pl8list…");
|
||||
showSync({ connected: true, synced_at: Date.now() / 1000 });
|
||||
} catch (err) {
|
||||
if (err instanceof api.HttpError && err.status === 409) showLogin({ resync: true });
|
||||
else toast((err as Error).message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
disconnectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await api.postJSON("/account/pl8list/disconnect");
|
||||
showSync({ connected: false, synced_at: null });
|
||||
toast("disconnected from pl8list");
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
$("logout-btn").addEventListener("click", async () => {
|
||||
await api.postJSON("/auth/logout");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
$("logout-all-btn").addEventListener("click", async () => {
|
||||
await api.postJSON("/account/logout-all");
|
||||
location.reload();
|
||||
});
|
||||
|
||||
$("delete-open").addEventListener("click", () => {
|
||||
deleteForm.hidden = false;
|
||||
deleteConfirm.value = "";
|
||||
deleteConfirm.focus();
|
||||
});
|
||||
|
||||
deleteForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
|
||||
localStorage.removeItem(FILTERS_KEY);
|
||||
localStorage.removeItem(HINT_KEY);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
function signedIn(user: User, pl8list?: Pl8Status): void {
|
||||
signedInUser = user;
|
||||
if (pl8list) showSync(pl8list);
|
||||
loginEl.hidden = true;
|
||||
$("bar").hidden = false;
|
||||
accountBtn.textContent = `@${user.handle}`;
|
||||
$("account-handle").textContent = `@${user.handle}`;
|
||||
$("delete-handle").textContent = 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();
|
||||
}
|
||||
|
||||
api.me().then(
|
||||
(me) => signedIn(me.user, me.pl8list),
|
||||
() => showLogin(),
|
||||
);
|
||||
@@ -0,0 +1,232 @@
|
||||
// Full-screen vertical pager. One card fills the screen; drag, fling, wheel and keys
|
||||
// move between cards. Only the current card and its two neighbours are in the DOM,
|
||||
// placed with transforms and settled by a spring, so long sessions stay light and
|
||||
// the motion follows the finger exactly.
|
||||
|
||||
export interface PagerSource {
|
||||
/// How many cards exist right now.
|
||||
count(): number;
|
||||
/// The element for card `i`; the owner builds and caches it.
|
||||
element(i: number): HTMLElement;
|
||||
/// Card `i` became the current one. Called as soon as a move is decided, before
|
||||
/// the animation ends, so audio can start right away.
|
||||
onChange(i: number): void;
|
||||
/// The user is at (or pulling past) the last card.
|
||||
onNeedMore(): void;
|
||||
}
|
||||
|
||||
const STIFFNESS = 380; // 1/s², spring towards the resting position
|
||||
const DAMPING = 34; // 1/s, a little under critical (39): settles with a hint of bounce
|
||||
const FLING = 0.45; // px/ms; a flick faster than this turns the page
|
||||
const DISTANCE = 0.22; // or dragging further than this share of the height
|
||||
const SLOP = 8; // px before a touch counts as a drag
|
||||
const WHEEL_STEP = 40; // px of wheel delta per page
|
||||
const WHEEL_IDLE = 180; // ms of quiet that ends one wheel gesture (trackpad inertia)
|
||||
|
||||
interface Drag {
|
||||
id: number;
|
||||
x0: number;
|
||||
y0: number;
|
||||
base: number;
|
||||
active: boolean;
|
||||
samples: [number, number][];
|
||||
}
|
||||
|
||||
export class Pager {
|
||||
index = 0;
|
||||
/// True right after a drag, so the click that follows it is not a tap.
|
||||
suppressClick = false;
|
||||
private offset = 0; // px; positive while pulled down (towards the previous card)
|
||||
private velocity = 0; // px/s
|
||||
private height = 1;
|
||||
private mounted = new Map<number, HTMLElement>();
|
||||
private raf = 0;
|
||||
private last = 0;
|
||||
private drag: Drag | null = null;
|
||||
private wheelAcc = 0;
|
||||
private wheelLocked = false;
|
||||
private wheelTimer = 0;
|
||||
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
constructor(
|
||||
private root: HTMLElement,
|
||||
private src: PagerSource,
|
||||
) {
|
||||
this.height = root.clientHeight || innerHeight;
|
||||
root.addEventListener("pointerdown", (e) => this.down(e));
|
||||
root.addEventListener("pointermove", (e) => this.move(e));
|
||||
root.addEventListener("pointerup", (e) => this.up(e));
|
||||
root.addEventListener("pointercancel", (e) => this.up(e, true));
|
||||
root.addEventListener("wheel", (e) => this.wheel(e), { passive: false });
|
||||
new ResizeObserver(() => {
|
||||
this.height = root.clientHeight || innerHeight;
|
||||
this.render();
|
||||
}).observe(root);
|
||||
}
|
||||
|
||||
/// Move to card `i`. Returns false when there is no such card (yet).
|
||||
go(i: number, { instant = false } = {}): boolean {
|
||||
if (i < 0) return false;
|
||||
if (i >= this.src.count()) {
|
||||
this.src.onNeedMore();
|
||||
return false;
|
||||
}
|
||||
if (i === this.index) return true;
|
||||
this.offset += (i - this.index) * this.height;
|
||||
this.index = i;
|
||||
this.refresh();
|
||||
this.src.onChange(i);
|
||||
if (instant || this.reduced.matches || document.hidden) this.settleNow();
|
||||
else this.animate();
|
||||
if (i >= this.src.count() - 1) this.src.onNeedMore();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Re-read the cards around the current one (after inserting or removing cards).
|
||||
refresh(): void {
|
||||
const want = new Map<number, HTMLElement>();
|
||||
for (let i = this.index - 1; i <= this.index + 1; i++) {
|
||||
if (i >= 0 && i < this.src.count()) want.set(i, this.src.element(i));
|
||||
}
|
||||
for (const [i, el] of this.mounted) {
|
||||
if (want.get(i) !== el && ![...want.values()].includes(el)) el.remove();
|
||||
}
|
||||
for (const el of want.values()) {
|
||||
if (el.parentElement !== this.root) this.root.append(el);
|
||||
}
|
||||
this.mounted = want;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const h = this.height;
|
||||
for (const [i, el] of this.mounted) {
|
||||
const p = i - this.index + this.offset / h;
|
||||
el.style.transform = `translate3d(0, ${(p * h).toFixed(2)}px, 0)`;
|
||||
// 0 when resting in place, 1 one full page away; cards use it for depth effects.
|
||||
el.style.setProperty("--away", Math.min(1, Math.abs(p)).toFixed(3));
|
||||
}
|
||||
}
|
||||
|
||||
private settleNow(): void {
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.offset = 0;
|
||||
this.velocity = 0;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private animate(): void {
|
||||
if (this.raf) return;
|
||||
this.last = performance.now();
|
||||
const step = (now: number) => {
|
||||
// Small fixed steps keep the spring stable on slow frames.
|
||||
let dt = Math.min(0.05, (now - this.last) / 1000);
|
||||
this.last = now;
|
||||
while (dt > 0) {
|
||||
const h = Math.min(dt, 1 / 240);
|
||||
this.velocity += (-STIFFNESS * this.offset - DAMPING * this.velocity) * h;
|
||||
this.offset += this.velocity * h;
|
||||
dt -= h;
|
||||
}
|
||||
if (Math.abs(this.offset) < 0.5 && Math.abs(this.velocity) < 20) {
|
||||
this.raf = 0;
|
||||
this.settleNow();
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
this.raf = requestAnimationFrame(step);
|
||||
};
|
||||
this.raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Pointer ---------------------------------------------------------------
|
||||
|
||||
private down(e: PointerEvent): void {
|
||||
if (this.drag || (e.pointerType === "mouse" && e.button !== 0)) return;
|
||||
if ((e.target as Element).closest("button, a, input, textarea, select, .wave-wrap")) return;
|
||||
this.drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: this.offset, active: false, samples: [] };
|
||||
}
|
||||
|
||||
private move(e: PointerEvent): void {
|
||||
const d = this.drag;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
const dx = e.clientX - d.x0;
|
||||
const dy = e.clientY - d.y0;
|
||||
if (!d.active) {
|
||||
if (Math.abs(dy) < SLOP || Math.abs(dy) < Math.abs(dx)) return;
|
||||
d.active = true;
|
||||
this.root.setPointerCapture(e.pointerId);
|
||||
// Catch a card that is still settling where it is.
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
d.base = this.offset;
|
||||
d.y0 = e.clientY;
|
||||
}
|
||||
let off = d.base + (e.clientY - d.y0);
|
||||
const atStart = this.index === 0 && off > 0;
|
||||
const atEnd = this.index >= this.src.count() - 1 && off < 0;
|
||||
if (atStart || atEnd) off = Math.sign(off) * this.rubber(Math.abs(off));
|
||||
if (atEnd) this.src.onNeedMore();
|
||||
this.offset = off;
|
||||
const now = performance.now();
|
||||
d.samples.push([now, e.clientY]);
|
||||
while (d.samples.length > 2 && now - d.samples[0][0] > 100) d.samples.shift();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/// Resistance past the first and last card.
|
||||
private rubber(x: number): number {
|
||||
const h = this.height;
|
||||
return (1 - 1 / ((x * 0.55) / h + 1)) * h;
|
||||
}
|
||||
|
||||
private up(e: PointerEvent, cancelled = false): void {
|
||||
const d = this.drag;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
this.drag = null;
|
||||
if (!d.active) return;
|
||||
this.suppressClick = true;
|
||||
setTimeout(() => (this.suppressClick = false), 350);
|
||||
let v = 0; // px/ms
|
||||
if (!cancelled && d.samples.length > 1) {
|
||||
const [t0, y0] = d.samples[0];
|
||||
const [t1, y1] = d.samples[d.samples.length - 1];
|
||||
if (t1 > t0) v = (y1 - y0) / (t1 - t0);
|
||||
}
|
||||
const h = this.height;
|
||||
let target = this.index;
|
||||
if ((this.offset < -h * DISTANCE || v < -FLING) && this.index + 1 < this.src.count()) target++;
|
||||
else if ((this.offset > h * DISTANCE || v > FLING) && this.index > 0) target--;
|
||||
this.velocity = v * 1000;
|
||||
if (target !== this.index) {
|
||||
this.offset += (target - this.index) * h;
|
||||
this.index = target;
|
||||
this.refresh();
|
||||
this.src.onChange(target);
|
||||
if (target >= this.src.count() - 1) this.src.onNeedMore();
|
||||
}
|
||||
if (this.reduced.matches) this.settleNow();
|
||||
else this.animate();
|
||||
}
|
||||
|
||||
// Wheel and trackpad -------------------------------------------------------
|
||||
|
||||
private wheel(e: WheelEvent): void {
|
||||
if (e.ctrlKey) return; // pinch zoom
|
||||
e.preventDefault();
|
||||
clearTimeout(this.wheelTimer);
|
||||
this.wheelTimer = window.setTimeout(() => {
|
||||
this.wheelLocked = false;
|
||||
this.wheelAcc = 0;
|
||||
}, WHEEL_IDLE);
|
||||
if (this.wheelLocked) return;
|
||||
this.wheelAcc += e.deltaY * (e.deltaMode === 1 ? 30 : e.deltaMode === 2 ? this.height : 1);
|
||||
if (Math.abs(this.wheelAcc) >= WHEEL_STEP) {
|
||||
// One page per gesture: the rest of a trackpad swipe's inertia is ignored.
|
||||
this.wheelLocked = true;
|
||||
this.go(this.index + Math.sign(this.wheelAcc));
|
||||
this.wheelAcc = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Audio playback with two decks, so the next track can be preloaded and tracks
|
||||
// crossfade into each other.
|
||||
//
|
||||
// Where the browser allows it, both <audio> elements run through a Web Audio graph:
|
||||
// a gain per deck (for the crossfade) into one analyser (for the visuals). iOS is
|
||||
// left out: its element volume is read-only and Web Audio stops when the screen
|
||||
// locks, which would break lock-screen playback. There tracks switch with a cut.
|
||||
|
||||
import type HlsType from "hls.js/light";
|
||||
import { type Track, streamUrl } from "./api";
|
||||
|
||||
const CROSSFADE = 0.9; // seconds
|
||||
const PRELOAD_BUFFER = 6; // seconds buffered of the track after the current one
|
||||
const PLAY_BUFFER = 30;
|
||||
|
||||
type Hls = HlsType;
|
||||
type HlsClass = typeof HlsType;
|
||||
|
||||
let hlsClass: Promise<HlsClass | null> | null = null;
|
||||
/// Set once the module has loaded, so later loads can attach synchronously and
|
||||
/// play() still runs inside the user's tap (iOS insists on that).
|
||||
let hlsLoaded: HlsClass | null | undefined;
|
||||
|
||||
/// hls.js where it works (MSE), otherwise null and the browser plays HLS itself (Safari).
|
||||
function loadHls(): Promise<HlsClass | null> {
|
||||
hlsClass ??= import("hls.js/light")
|
||||
.then((m) => (m.default.isSupported() ? m.default : null))
|
||||
.catch(() => null)
|
||||
.then((h) => (hlsLoaded = h));
|
||||
return hlsClass;
|
||||
}
|
||||
|
||||
interface Deck {
|
||||
el: HTMLAudioElement;
|
||||
hls: Hls | null;
|
||||
gain: GainNode | null;
|
||||
track: Track | null;
|
||||
/// Should be playing as soon as its source is attached.
|
||||
wantPlay: boolean;
|
||||
/// Bumped on every load/unload, so late callbacks of an old load do nothing.
|
||||
gen: number;
|
||||
}
|
||||
|
||||
export type PlayerEvent = "timeupdate" | "ended" | "play" | "pause" | "waiting" | "playing" | "failed";
|
||||
|
||||
const FORWARDED: PlayerEvent[] = ["timeupdate", "ended", "play", "pause", "waiting", "playing"];
|
||||
|
||||
/// Equal-power fade of `param` from its current value to `to`. A fade can start
|
||||
/// while the previous one still runs (fast flicking), so it starts where that one is.
|
||||
function fade(param: AudioParam, to: number, at: number): void {
|
||||
const from = param.value;
|
||||
param.cancelScheduledValues(0);
|
||||
const up = to > from;
|
||||
const curve = Float32Array.from({ length: 32 }, (_, i) => {
|
||||
const x = (i / 31) * (Math.PI / 2);
|
||||
return up ? from + (to - from) * Math.sin(x) : to + (from - to) * Math.cos(x);
|
||||
});
|
||||
try {
|
||||
param.setValueCurveAtTime(curve, at, CROSSFADE);
|
||||
} catch {
|
||||
param.value = to;
|
||||
}
|
||||
}
|
||||
|
||||
export class Player {
|
||||
private decks: [Deck, Deck];
|
||||
private cur = 0;
|
||||
private ctx: AudioContext | null = null;
|
||||
private unlocking: Promise<void> | null = null;
|
||||
private fadeEnd = 0;
|
||||
private listeners = new Map<PlayerEvent, Set<() => void>>();
|
||||
analyser: AnalyserNode | null = null;
|
||||
/// False on iOS, see the top of this file.
|
||||
readonly canMix: boolean;
|
||||
|
||||
constructor() {
|
||||
const probe = document.createElement("audio");
|
||||
probe.volume = 0.5;
|
||||
this.canMix = probe.volume === 0.5 && "AudioContext" in window;
|
||||
const deck = (): Deck => {
|
||||
const el = document.createElement("audio");
|
||||
el.preload = "auto";
|
||||
document.body.append(el);
|
||||
return { el, hls: null, gain: null, track: null, wantPlay: false, gen: 0 };
|
||||
};
|
||||
this.decks = [deck(), deck()];
|
||||
for (const d of this.decks) {
|
||||
for (const ev of FORWARDED) {
|
||||
d.el.addEventListener(ev, () => {
|
||||
if (d === this.decks[this.cur]) this.emit(ev);
|
||||
});
|
||||
}
|
||||
// hls.js reports its own errors; this is for native HLS.
|
||||
d.el.addEventListener("error", () => {
|
||||
if (d === this.decks[this.cur] && !d.hls && d.track) this.emit("failed");
|
||||
});
|
||||
}
|
||||
loadHls(); // start fetching the module early
|
||||
}
|
||||
|
||||
on(ev: PlayerEvent, fn: () => void): void {
|
||||
if (!this.listeners.has(ev)) this.listeners.set(ev, new Set());
|
||||
this.listeners.get(ev)!.add(fn);
|
||||
}
|
||||
|
||||
private emit(ev: PlayerEvent): void {
|
||||
this.listeners.get(ev)?.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/// The element of the current deck, for time, duration and pause state.
|
||||
get audio(): HTMLAudioElement {
|
||||
return this.decks[this.cur].el;
|
||||
}
|
||||
|
||||
get track(): Track | null {
|
||||
return this.decks[this.cur].track;
|
||||
}
|
||||
|
||||
/// True while two tracks are mixed and crossfades work.
|
||||
get mixing(): boolean {
|
||||
return this.ctx?.state === "running";
|
||||
}
|
||||
|
||||
/// Call from a user gesture. Sets up the Web Audio graph where the browser can mix.
|
||||
unlock(): Promise<void> {
|
||||
if (this.ctx) {
|
||||
if (this.ctx.state === "suspended") this.ctx.resume().catch(() => {});
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!this.canMix) return Promise.resolve();
|
||||
this.unlocking ??= this.setUpGraph();
|
||||
return this.unlocking;
|
||||
}
|
||||
|
||||
private async setUpGraph(): Promise<void> {
|
||||
let ctx: AudioContext;
|
||||
try {
|
||||
ctx = new AudioContext();
|
||||
await ctx.resume();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Routing an element through the graph can't be undone, and a suspended graph
|
||||
// is silent, so only route once it actually runs.
|
||||
if (ctx.state !== "running") {
|
||||
ctx.close();
|
||||
this.unlocking = null;
|
||||
return;
|
||||
}
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.7;
|
||||
analyser.connect(ctx.destination);
|
||||
for (const d of this.decks) {
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = d === this.decks[this.cur] ? 1 : 0;
|
||||
ctx.createMediaElementSource(d.el).connect(gain).connect(analyser);
|
||||
d.gain = gain;
|
||||
}
|
||||
this.ctx = ctx;
|
||||
this.analyser = analyser;
|
||||
}
|
||||
|
||||
/// Play a track, crossfading from the current one when possible.
|
||||
async play(track: Track): Promise<void> {
|
||||
const from = this.decks[this.cur];
|
||||
if (from.track?.id === track.id) {
|
||||
from.wantPlay = true;
|
||||
from.el.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const toIdx = 1 - this.cur;
|
||||
const to = this.decks[toIdx];
|
||||
if (to.track?.id === track.id && performance.now() >= this.fadeEnd) {
|
||||
if (to.hls) to.hls.config.maxBufferLength = PLAY_BUFFER;
|
||||
} else {
|
||||
this.load(to, track, PLAY_BUFFER);
|
||||
}
|
||||
const crossfade = this.mixing && !document.hidden && !from.el.paused && from.track !== null;
|
||||
this.cur = toIdx;
|
||||
to.wantPlay = true;
|
||||
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
|
||||
if (crossfade && to.gain && from.gain) {
|
||||
const t = this.ctx!.currentTime;
|
||||
fade(to.gain.gain, 1, t);
|
||||
fade(from.gain.gain, 0, t);
|
||||
this.fadeEnd = performance.now() + CROSSFADE * 1000;
|
||||
const gen = from.gen;
|
||||
setTimeout(() => {
|
||||
if (from.gen === gen && from !== this.decks[this.cur]) this.unload(from);
|
||||
}, CROSSFADE * 1000 + 50);
|
||||
} else {
|
||||
if (to.gain) {
|
||||
to.gain.gain.cancelScheduledValues(0);
|
||||
to.gain.gain.value = 1;
|
||||
}
|
||||
this.unload(from);
|
||||
}
|
||||
if (to.hls || to.el.hasAttribute("src")) to.el.play().catch(() => {});
|
||||
}
|
||||
|
||||
/// Buffer the start of the track that is likely to come next.
|
||||
preload(track: Track): void {
|
||||
const wait = this.fadeEnd - performance.now();
|
||||
if (wait > 0) {
|
||||
setTimeout(() => this.preload(track), wait + 100);
|
||||
return;
|
||||
}
|
||||
const idle = this.decks[1 - this.cur];
|
||||
if (idle.track?.id !== track.id) this.load(idle, track, PRELOAD_BUFFER);
|
||||
}
|
||||
|
||||
private load(d: Deck, track: Track, buffer: number): void {
|
||||
this.unload(d);
|
||||
const gen = d.gen;
|
||||
d.track = track;
|
||||
const url = streamUrl(track);
|
||||
const attach = (Hls: HlsClass | null) => {
|
||||
if (d.gen !== gen) return;
|
||||
if (Hls) {
|
||||
const hls = new Hls({ maxBufferLength: buffer, maxMaxBufferLength: 60 });
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (!data.fatal || d.gen !== gen) return;
|
||||
console.warn("hls fatal", data.details);
|
||||
if (d === this.decks[this.cur]) this.emit("failed");
|
||||
else this.unload(d);
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(d.el);
|
||||
d.hls = hls;
|
||||
} else {
|
||||
d.el.src = url;
|
||||
}
|
||||
if (d.wantPlay) d.el.play().catch(() => {});
|
||||
};
|
||||
if (hlsLoaded !== undefined) attach(hlsLoaded);
|
||||
else loadHls().then(attach);
|
||||
}
|
||||
|
||||
private unload(d: Deck): void {
|
||||
d.gen++;
|
||||
d.wantPlay = false;
|
||||
d.hls?.destroy();
|
||||
d.hls = null;
|
||||
d.track = null;
|
||||
d.el.pause();
|
||||
if (d.el.hasAttribute("src")) {
|
||||
d.el.removeAttribute("src");
|
||||
d.el.load();
|
||||
}
|
||||
if (d.gain) {
|
||||
d.gain.gain.cancelScheduledValues(0);
|
||||
d.gain.gain.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.audio.paused) this.resume();
|
||||
else this.pause();
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
|
||||
this.decks[this.cur].wantPlay = true;
|
||||
this.audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.decks[this.cur].wantPlay = false;
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
const a = this.audio;
|
||||
a.currentTime = Math.max(0, Math.min(a.duration || 0, seconds));
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this.decks[0].el.muted;
|
||||
}
|
||||
|
||||
set muted(m: boolean) {
|
||||
for (const d of this.decks) d.el.muted = m;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
/* Design tokens ---------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #000;
|
||||
--fg: #f2f2f2;
|
||||
--muted: #9a9a9a;
|
||||
--faint: #666;
|
||||
--accent: #ff4d6d;
|
||||
--panel: rgba(18, 18, 20, 0.94);
|
||||
color-scheme: dark;
|
||||
--line: #ffffff1f;
|
||||
--glass: rgba(255, 255, 255, 0.1);
|
||||
--glass-strong: rgba(255, 255, 255, 0.2);
|
||||
--radius-s: 8px;
|
||||
--radius-m: 14px;
|
||||
--radius-l: 18px;
|
||||
--shadow: 0 16px 40px #000a;
|
||||
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-display: var(--font);
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--fast: 0.15s;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -11,9 +26,9 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font: 15px/1.4 var(--font);
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -131,19 +146,25 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
|
||||
/* Feed ------------------------------------------------------------------- */
|
||||
|
||||
/* The pager (pager.ts) places cards with transforms and sets --away (0 in place,
|
||||
1 a full page away); the visualizer sets --energy (0..1, bass level) on the
|
||||
playing card; --card-accent comes from the artist image. */
|
||||
#feed {
|
||||
height: 100dvh;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
scrollbar-width: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
#feed::-webkit-scrollbar { display: none; }
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
--card-accent: var(--accent);
|
||||
--energy: 0;
|
||||
--away: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -161,8 +182,19 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
filter: blur(40px) brightness(0.45) saturate(1.3);
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%);
|
||||
opacity: calc(0.18 + var(--energy) * 0.55);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
/* Cards shrink and dim a little as they leave, for depth while swiping. */
|
||||
transform: scale(calc(1 - var(--away) * 0.08));
|
||||
opacity: calc(1 - var(--away) * 0.55);
|
||||
width: min(34rem, calc(100vw - 5.5rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -177,11 +209,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 14px;
|
||||
background: #222 center / cover no-repeat;
|
||||
box-shadow: 0 20px 60px #000a;
|
||||
box-shadow:
|
||||
0 20px 60px #000a,
|
||||
0 0 calc(10px + var(--energy) * 70px) color-mix(in oklab, var(--card-accent) calc(15% + var(--energy) * 45%), transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.6rem;
|
||||
transition: transform 0.25s ease, filter 0.25s ease;
|
||||
/* Pulse on the beat through `scale`; `transform` is left to the pause animation. */
|
||||
scale: calc(1 + var(--energy) * 0.045);
|
||||
transition: transform 0.25s var(--ease-out), filter 0.25s ease;
|
||||
}
|
||||
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
|
||||
|
||||
@@ -220,7 +256,13 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
.card.buffering .paused-badge { opacity: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.title { font-size: 1.25rem; margin: 0; overflow-wrap: anywhere; }
|
||||
.title {
|
||||
font: 800 clamp(1.3rem, 4.2vw, 1.9rem)/1.1 var(--font-display);
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.byline { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.artist { color: var(--muted); text-decoration: none; }
|
||||
.artist:hover { text-decoration: underline; }
|
||||
@@ -241,6 +283,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.chips .free { background: #1f7a4d; }
|
||||
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
|
||||
.chips .preview { background: #6a4b16; }
|
||||
|
||||
.desc {
|
||||
@@ -296,7 +339,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
.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(--card-accent); background: color-mix(in oklab, var(--card-accent) 22%, transparent); }
|
||||
.actions .like.pop svg { animation: pop 0.35s ease; }
|
||||
@keyframes pop { 50% { transform: scale(1.4); } }
|
||||
|
||||
@@ -329,9 +372,9 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: -3rem 0 0 -3rem;
|
||||
color: var(--accent);
|
||||
color: var(--card-accent);
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 6px 20px #ff4d6d88);
|
||||
filter: drop-shadow(0 6px 20px color-mix(in oklab, var(--card-accent) 55%, transparent));
|
||||
animation: burst 0.8s cubic-bezier(.2, .9, .3, 1.3) forwards;
|
||||
}
|
||||
@keyframes burst {
|
||||
@@ -359,15 +402,19 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
}
|
||||
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
.status {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
#feed-status {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
left: 50%;
|
||||
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
|
||||
transform: translateX(-50%);
|
||||
width: min(26rem, calc(100vw - 2rem));
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
scroll-snap-align: start;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#feed:empty + #feed-status { bottom: 50%; }
|
||||
|
||||
#start {
|
||||
position: fixed;
|
||||
@@ -454,14 +501,6 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
||||
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
|
||||
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
|
||||
|
||||
/* Impressum and privacy pages -------------------------------------------- */
|
||||
|
||||
html:has(body.legal), body.legal { height: auto; overflow: auto; }
|
||||
body.legal main { max-width: 42rem; margin: 0 auto; padding: 2rem 1.2rem 4rem; line-height: 1.6; }
|
||||
body.legal h1 { font-size: 1.8rem; }
|
||||
body.legal h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||
body.legal a { color: var(--accent); }
|
||||
|
||||
/* Phones ----------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -0,0 +1,69 @@
|
||||
// Per-frame visuals of the playing card: waveform progress, and a bass level that
|
||||
// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1).
|
||||
// The level comes from the analyser where audio runs through Web Audio, otherwise
|
||||
// from the track's waveform at the current position.
|
||||
|
||||
import type { Card } from "./cards";
|
||||
import type { Player } from "./player";
|
||||
|
||||
export class Visualizer {
|
||||
private raf = 0;
|
||||
private bins: Uint8Array<ArrayBuffer> | null = null;
|
||||
private level = 0;
|
||||
private peak = 0.3;
|
||||
private card: Card | null = null;
|
||||
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
constructor(private player: Player) {
|
||||
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
|
||||
}
|
||||
|
||||
/// Follow `card` from now on.
|
||||
attach(card: Card | null): void {
|
||||
if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0");
|
||||
this.card = card;
|
||||
this.start();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
}
|
||||
|
||||
private frame = () => {
|
||||
this.raf = 0;
|
||||
const card = this.card;
|
||||
const a = this.player.audio;
|
||||
if (!card) return;
|
||||
const progress = a.duration ? a.currentTime / a.duration : 0;
|
||||
card.drawWave(progress);
|
||||
if (!this.reduced.matches) {
|
||||
const raw = a.paused ? 0 : this.bass(card, progress);
|
||||
// Fast attack, slow release, against a slowly falling peak: kicks stand out
|
||||
// the same whether a track is mastered loud or quiet.
|
||||
this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12);
|
||||
this.peak = Math.max(this.peak * 0.997, this.level, 0.2);
|
||||
const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65);
|
||||
card.el?.style.setProperty("--energy", energy.toFixed(3));
|
||||
}
|
||||
// Keep running while playing; one last frame after a pause lets the glow fade.
|
||||
if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame);
|
||||
};
|
||||
|
||||
private bass(card: Card, progress: number): number {
|
||||
const an = this.player.analyser;
|
||||
if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8;
|
||||
if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount);
|
||||
an.getByteFrequencyData(this.bins);
|
||||
// Bins up to ~150 Hz: kick and sub.
|
||||
const hz = an.context.sampleRate / an.fftSize;
|
||||
const top = Math.max(2, Math.round(150 / hz));
|
||||
let sum = 0;
|
||||
for (let i = 1; i <= top; i++) sum += this.bins[i];
|
||||
return sum / top / 255;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
// The Rust server embeds dist/: index.html is served at "/", everything else
|
||||
// under "/static/". In development Vite proxies the API to a running server.
|
||||
const backend = process.env.BACKEND ?? "http://127.0.0.1:8790";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/static/",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
// Keep everything as files: the CSP allows no inline scripts or styles.
|
||||
assetsInlineLimit: 0,
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
proxy: Object.fromEntries(
|
||||
["/local", "/auth", "/account", "/api", "/img", "/impressum", "/datenschutz"].map((p) => [p, backend]),
|
||||
),
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user