mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
The first visit shows a welcome screen where one tap starts a guest session; sign-in moves below it. Guests see a "sign in" pill, are asked once to sign in after five likes or a first follow, and need two taps to forget themselves. Drops are found in the waveform and marked on it; "drop" and the d key jump a bar before the next one. Long and dj-mix fades start on a bar once the beat is locked. Radio sheets can be shared as /?r=handle. The audio features now follow the bar, gaps before drops and the wobble rate, and a new flare pass draws loops round the drop on each kick, with a setting under visuals.
1309 lines
47 KiB
TypeScript
1309 lines
47 KiB
TypeScript
import "./style.css";
|
||
|
||
import * as api from "./api";
|
||
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
|
||
import { Card, type CardActions, cleanTitle, fmtTime } from "./cards";
|
||
import { accentForName } from "./color";
|
||
import * as heard from "./history";
|
||
import { Pager } from "./pager";
|
||
import { Player } from "./player";
|
||
import { Sheet } from "./sheet";
|
||
import { Station } from "./station";
|
||
import type { FlareSetting } from "./blob/blob";
|
||
import { type VisualsSetting, 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 HISTORY_AFTER = 1; // s heard before a track goes into the history
|
||
const SLEEP_FADE = 10; // s the sleep timer fades out over
|
||
const BPM_RANGE = [60, 200]; // the BPM slider's ends, which mean "no limit"
|
||
const FILTERS_KEY = "pl8inf.filters";
|
||
const MODE_KEY = "pl8inf.mode";
|
||
const HINT_KEY = "pl8inf.hintShown";
|
||
const NUDGE_KEY = "pl8inf.signInNudged";
|
||
const NUDGE_LIKES = 5; // a guest is asked to sign in after this many likes, or a first follow
|
||
|
||
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 accountBtn = $("account-btn");
|
||
const startEl = $("start");
|
||
const toastEl = $("toast");
|
||
const loginEl = $("login");
|
||
const upNext = $<HTMLButtonElement>("up-next");
|
||
const radioEnd = $("radio-end");
|
||
|
||
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||
|
||
const player = new Player();
|
||
const visual = new Visualizer(player, isTouch);
|
||
|
||
const sheets = {
|
||
filters: new Sheet(filtersForm, filtersBtn),
|
||
likes: new Sheet($("likes"), $("likes-btn")),
|
||
history: new Sheet($("history"), $("history-btn")),
|
||
account: new Sheet($("account"), accountBtn),
|
||
help: new Sheet($("help")),
|
||
};
|
||
|
||
// Installable as an app; see public/sw.js. The dev server has no /sw.js.
|
||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => console.warn("no service worker:", err));
|
||
}
|
||
const cards: Card[] = [];
|
||
let active = -1;
|
||
let loading = false;
|
||
let feedGen = 0; // bumped when the feed changes, so answers for the old one are dropped
|
||
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 remembered = false; // the active card is in the history
|
||
let preloadTimer = 0;
|
||
let barTimer = 0; // a long crossfade waiting for the next bar
|
||
let filters: Filters = loadFilters();
|
||
let mode: Mode = loadMode();
|
||
let stats: Stats | null = null;
|
||
let jumpOnLoad = false; // a radio just started: go to its first track as soon as it is here
|
||
let radioDry = false; // the artist radio found nothing more; say so on its last card
|
||
/// Playing the likes in order (or shuffled) instead of the feed. Null for the feed.
|
||
let queue: { tracks: Track[]; next: number } | 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;
|
||
if (queue) {
|
||
fromQueue();
|
||
return;
|
||
}
|
||
loading = true;
|
||
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
|
||
const gen = feedGen;
|
||
try {
|
||
const data = await api.feed(filters, mode);
|
||
if (gen !== feedGen) return;
|
||
showStats(data.stats);
|
||
if (!data.tracks.length) {
|
||
jumpOnLoad = false;
|
||
if (mode.mode === "artist") {
|
||
radioDry = true;
|
||
updateRadioEnd();
|
||
} else setStatus(mode.mode !== "classic" || Object.keys(filters).length
|
||
? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit."
|
||
: "the plate's empty for now, the crawler is still digging. try again in a bit.");
|
||
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
|
||
return;
|
||
}
|
||
setStatus("");
|
||
radioDry = false;
|
||
updateRadioEnd();
|
||
const station = mode.mode === "artist" ? mode.name : undefined;
|
||
addCards(data.tracks.map((t) => new Card(t, actions, { station })));
|
||
} catch (err) {
|
||
if (gen !== feedGen) return;
|
||
if (err instanceof api.HttpError && err.status === 401) {
|
||
loading = false;
|
||
signedInUser = null;
|
||
showWelcome();
|
||
return;
|
||
}
|
||
setStatus(`failed to load: ${(err as Error).message}`);
|
||
setTimeout(() => setStatus(""), 5000);
|
||
}
|
||
loading = false;
|
||
}
|
||
|
||
function addCards(more: Card[]): void {
|
||
cards.push(...more);
|
||
pager.refresh();
|
||
if (active < 0) activate(0);
|
||
else if (jumpOnLoad) go(active + 1);
|
||
jumpOnLoad = false;
|
||
showUpNext();
|
||
}
|
||
|
||
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;
|
||
/// A short note at the bottom; `action` adds a button to it, like "undo".
|
||
function toast(text: string, ms = 2200, action?: { label: string; run: () => void }): void {
|
||
toastEl.replaceChildren(text);
|
||
if (action) {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.textContent = action.label;
|
||
b.addEventListener("click", () => {
|
||
toastEl.classList.remove("show");
|
||
action.run();
|
||
}, { once: true });
|
||
toastEl.append(b);
|
||
}
|
||
toastEl.classList.toggle("has-action", !!action);
|
||
toastEl.classList.add("show");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = window.setTimeout(() => toastEl.classList.remove("show"), action ? Math.max(ms, 5000) : 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;
|
||
remembered = false;
|
||
clearTimeout(barTimer);
|
||
barTimer = 0;
|
||
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);
|
||
else showStart(card);
|
||
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();
|
||
updateRadioEnd();
|
||
showUpNext();
|
||
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);
|
||
}
|
||
|
||
/// Put `track` right after the current card and go there.
|
||
function playNow(track: Track, liked = false): void {
|
||
Sheet.closeAll();
|
||
if (!started) start();
|
||
cards.splice(active + 1, 0, new Card(track, actions, { liked }));
|
||
pager.refresh();
|
||
go(active + 1);
|
||
}
|
||
|
||
// Up next -----------------------------------------------------------------
|
||
|
||
function showUpNext(): void {
|
||
const next = cards[active + 1];
|
||
upNext.hidden = !next || !started;
|
||
if (!next) return;
|
||
const t = next.track;
|
||
upNext.querySelector(".up-next-title")!.textContent = `${cleanTitle(t.title)} · ${t.artist_name}`;
|
||
const av = upNext.querySelector<HTMLElement>(".up-next-avatar")!;
|
||
const img = api.artistImg(t);
|
||
av.style.backgroundImage = img ? `url("${img}")` : "";
|
||
av.style.backgroundColor = img ? "" : accentForName(t.artist_name || "?");
|
||
av.textContent = img ? "" : (t.artist_name || "?")[0].toUpperCase();
|
||
}
|
||
upNext.addEventListener("click", () => 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),
|
||
radio: (c) => station.open(c.track.handle, c.track.artist_name, api.artistImg(c.track)),
|
||
canSeek: (c) => c === current() && !!player.audio.duration,
|
||
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
||
seek: (_, s) => player.seek(s),
|
||
toDrop: (c) => toDrop(c),
|
||
};
|
||
|
||
/// Seek to the next drop of the playing card, a bar early so it lands; past the
|
||
/// last one, back to the first.
|
||
function toDrop(card: Card): void {
|
||
const a = player.audio;
|
||
if (card !== current() || !a.duration || !card.drops.length) return;
|
||
const lead = 4 * beatSeconds();
|
||
const times = card.drops.map((d) => Math.max(0, d * a.duration - lead));
|
||
const next = times.find((t) => t > a.currentTime + 1) ?? times[0];
|
||
player.seek(next);
|
||
toast(`drop ${times.indexOf(next) + 1} of ${times.length}`);
|
||
}
|
||
|
||
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");
|
||
if (liked && (stats?.liked ?? 0) >= NUDGE_LIKES) nudgeSignIn();
|
||
}
|
||
|
||
/// Once per device, a guest who has started collecting is asked to sign in.
|
||
function nudgeSignIn(): void {
|
||
if (!signedInUser?.guest || localStorage.getItem(NUDGE_KEY)) return;
|
||
localStorage.setItem(NUDGE_KEY, "1");
|
||
toast("sign in with pl8list to keep these, and bring your library along", 8000, { label: "sign in", run: () => showLogin() });
|
||
}
|
||
|
||
function dislike(card: Card): void {
|
||
api.sendEvent(card.track, "dislike");
|
||
if (card === current()) reported = true; // a dislike says more than a skip
|
||
const wasLiked = card.liked;
|
||
if (wasLiked) setLike(card, false);
|
||
go(cards.indexOf(card) + 1);
|
||
toast("skipped, less of this", 5000, {
|
||
label: "undo",
|
||
run: () => {
|
||
api.sendEvent(card.track, "undislike");
|
||
if (wasLiked) setLike(card, true);
|
||
go(cards.indexOf(card));
|
||
},
|
||
});
|
||
}
|
||
|
||
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 });
|
||
if (follow && signedInUser?.guest && !localStorage.getItem(NUDGE_KEY)) nudgeSignIn();
|
||
else toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`);
|
||
} catch (err) {
|
||
update(!follow);
|
||
toast((err as Error).message, 4000);
|
||
}
|
||
}
|
||
|
||
/// Shares a link that opens this app on the track, with pl8list's own link in the text.
|
||
function share(card: Card): Promise<void> {
|
||
const { track } = card;
|
||
const title = cleanTitle(track.title);
|
||
return shareLink(api.appTrackUrl(track), title, `${title} by ${track.artist_name} · ${api.trackUrl(track)}`);
|
||
}
|
||
|
||
/// The share sheet on phones, the clipboard elsewhere.
|
||
async function shareLink(url: string, title: string, text = title): Promise<void> {
|
||
if (navigator.share && isTouch) {
|
||
try {
|
||
await navigator.share({ title, text, 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;
|
||
const card = current();
|
||
card?.setTime(t);
|
||
updatePosition();
|
||
if (card && !remembered && listened >= HISTORY_AFTER) {
|
||
remembered = true;
|
||
heard.add(card.track);
|
||
}
|
||
// Where tracks can be mixed, start the next one so they overlap by the crossfade.
|
||
const lead = Math.max(1, player.crossfade);
|
||
const left = a.duration - t;
|
||
if (player.mixing && !advanced && !document.hidden && a.duration && cards[active + 1] && !sleepAtEnd) {
|
||
// Long fades start on a bar, like a DJ would bring the next one in; up to a
|
||
// bar early, never late.
|
||
const toBar = player.crossfade >= BAR_FADE && !barTimer ? visual.secondsToBar() : null;
|
||
if (toBar !== null && left - toBar >= lead * 0.75 && left - toBar <= lead + 2 * 4 * beatSeconds()) {
|
||
const card = current();
|
||
barTimer = window.setTimeout(() => {
|
||
barTimer = 0;
|
||
if (current() === card) mixNext();
|
||
}, toBar * 1000);
|
||
} else if (left < lead) mixNext();
|
||
}
|
||
// Seeked back from a bar-timed fade: it waits for the end again.
|
||
if (barTimer && left > lead + 8) {
|
||
clearTimeout(barTimer);
|
||
barTimer = 0;
|
||
}
|
||
});
|
||
|
||
/// Seconds per beat of the playing card.
|
||
function beatSeconds(): number {
|
||
const bpm = current()?.track.bpm || 140;
|
||
return 60 / (bpm < 100 ? bpm * 2 : bpm);
|
||
}
|
||
|
||
/// Long crossfades at least this long start on a bar.
|
||
const BAR_FADE = 4;
|
||
|
||
function mixNext(): void {
|
||
clearTimeout(barTimer);
|
||
barTimer = 0;
|
||
if (advanced) return;
|
||
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;
|
||
}
|
||
if (sleepAtEnd) {
|
||
sleepAtEnd = false;
|
||
showSleep();
|
||
toast("good night");
|
||
return;
|
||
}
|
||
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 rewind",
|
||
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 };
|
||
|
||
// A long press opens the context menu on some phones.
|
||
feedEl.addEventListener("contextmenu", (e) => { if ((e.target as Element).closest(".art")) e.preventDefault(); });
|
||
|
||
feedEl.addEventListener("click", (e) => {
|
||
if (pager.suppressClick) 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();
|
||
visual.cancelFlash();
|
||
}
|
||
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);
|
||
|
||
// Volume ------------------------------------------------------------------
|
||
|
||
const volumeEl = $("volume");
|
||
const muteBtn = $("mute-btn");
|
||
const volumeSlider = $<HTMLInputElement>("volume-slider");
|
||
|
||
function showVolume(): void {
|
||
const silent = player.muted || player.volume === 0;
|
||
muteBtn.querySelector("use")!.setAttribute("href", silent ? "#i-mute" : "#i-vol");
|
||
muteBtn.setAttribute("aria-pressed", String(player.muted));
|
||
volumeSlider.value = String(player.muted ? 0 : player.volume);
|
||
// How far the slider's track is filled (style.css).
|
||
volumeSlider.style.setProperty("--v", `${Number(volumeSlider.value) * 100}%`);
|
||
}
|
||
|
||
function setMuted(m: boolean): void {
|
||
player.muted = m;
|
||
// Unmuting at zero would stay silent.
|
||
if (!m && player.volume === 0) player.volume = 0.5;
|
||
showVolume();
|
||
}
|
||
|
||
function setVolume(v: number): void {
|
||
player.volume = v;
|
||
player.muted = false;
|
||
showVolume();
|
||
}
|
||
|
||
// iOS ignores the volume, see player.ts.
|
||
volumeEl.hidden = !player.canMix;
|
||
showVolume();
|
||
muteBtn.addEventListener("click", () => setMuted(!player.muted));
|
||
volumeSlider.addEventListener("input", () => setVolume(Number(volumeSlider.value)));
|
||
// Hand the keys back to the feed, or space and the arrows would move the slider.
|
||
volumeSlider.addEventListener("change", () => volumeSlider.blur());
|
||
volumeSlider.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
setVolume(player.volume - Math.sign(e.deltaY) * 0.05);
|
||
}, { passive: false });
|
||
|
||
// Start -------------------------------------------------------------------
|
||
|
||
$("help-close").addEventListener("click", () => sheets.help.close());
|
||
for (const id of ["help-open", "help-open-settings"]) $(id).addEventListener("click", () => sheets.help.open());
|
||
|
||
/// The overlay before the first tap names what will play.
|
||
function showStart(card: Card): void {
|
||
const t = card.track;
|
||
startEl.querySelector(".start-track")!.textContent = `${cleanTitle(t.title)} · ${t.artist_name}`;
|
||
}
|
||
|
||
/// A tap on the overlay. Someone new becomes a guest with it: one tap, and it plays.
|
||
async function tapToStart(): Promise<void> {
|
||
if (signedInUser) {
|
||
start();
|
||
return;
|
||
}
|
||
if (startEl.classList.contains("busy")) return;
|
||
startEl.classList.add("busy");
|
||
try {
|
||
const { user } = await api.postJSON<{ user: User }>("/auth/guest");
|
||
signedIn(user);
|
||
start();
|
||
} catch (err) {
|
||
toast((err as Error).message, 4000);
|
||
}
|
||
startEl.classList.remove("busy");
|
||
}
|
||
|
||
function start(): void {
|
||
if (started || !signedInUser) return;
|
||
started = true;
|
||
startEl.hidden = true;
|
||
player.unlock();
|
||
const card = current();
|
||
if (card) activateAgain(card);
|
||
showUpNext();
|
||
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", (e) => {
|
||
// The sign-in link and the legal links are not a tap to start.
|
||
if ((e.target as Element).closest(".start-welcome")) return;
|
||
tapToStart();
|
||
});
|
||
$("start-signin").addEventListener("click", () => showLogin());
|
||
|
||
/// 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 -----------------------------------------------------------------
|
||
|
||
const likesList = $("likes-list");
|
||
const likesSearch = $<HTMLInputElement>("likes-search");
|
||
const likesShuffle = $<HTMLInputElement>("likes-shuffle");
|
||
let likedTracks: Track[] = [];
|
||
|
||
/// A row in the likes or history sheet: artwork with a play button, title, artist.
|
||
function trackRow(t: Track, sub: string, extra?: HTMLElement): HTMLLIElement {
|
||
const li = document.createElement("li");
|
||
li.className = "track-row";
|
||
const play = document.createElement("button");
|
||
play.type = "button";
|
||
play.className = "row-play";
|
||
play.title = "play now";
|
||
play.setAttribute("aria-label", `play ${cleanTitle(t.title)}`);
|
||
const img = api.artistImg(t);
|
||
if (img) play.style.backgroundImage = `url("${img}")`;
|
||
else play.style.backgroundColor = accentForName(t.artist_name || "?");
|
||
play.innerHTML = '<svg><use href="#i-play"/></svg>';
|
||
play.addEventListener("click", () => playNow(t, likedTracks.some((l) => l.id === t.id)));
|
||
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 = sub;
|
||
meta.append(a, by);
|
||
li.append(play, meta);
|
||
if (extra) li.append(extra);
|
||
return li;
|
||
}
|
||
|
||
const matches = (t: Track, q: string) => !q || `${t.title} ${t.artist_name} ${(t.tags ?? []).join(" ")}`.toLowerCase().includes(q);
|
||
|
||
function setCardsLiked(id: number, liked: boolean): void {
|
||
for (const c of cards) {
|
||
if (c.track.id !== id) continue;
|
||
c.liked = liked;
|
||
c.showLike();
|
||
}
|
||
}
|
||
|
||
function showLikes(): void {
|
||
const q = likesSearch.value.trim().toLowerCase();
|
||
const shown = likedTracks.filter((t) => matches(t, q));
|
||
likesList.replaceChildren(...shown.map((t) => {
|
||
const unlike = document.createElement("button");
|
||
unlike.type = "button";
|
||
unlike.className = "icon-btn row-unlike";
|
||
unlike.title = "unlike";
|
||
unlike.setAttribute("aria-label", `unlike ${cleanTitle(t.title)}`);
|
||
unlike.innerHTML = '<svg><use href="#i-heart"/></svg>';
|
||
unlike.addEventListener("click", () => {
|
||
api.sendEvent(t, "unlike");
|
||
if (stats) { stats.liked--; showStats(); }
|
||
setCardsLiked(t.id, false);
|
||
const at = likedTracks.indexOf(t);
|
||
likedTracks.splice(at, 1);
|
||
showLikes();
|
||
toast("unliked", 4000, {
|
||
label: "undo",
|
||
run: () => {
|
||
api.sendEvent(t, "like");
|
||
if (stats) { stats.liked++; showStats(); }
|
||
setCardsLiked(t.id, true);
|
||
likedTracks.splice(at, 0, t);
|
||
showLikes();
|
||
},
|
||
});
|
||
});
|
||
const sub = t.artist_name + (t.followed ? " · following" : "") + (t.bpm ? ` · ${Math.round(t.bpm)} bpm` : "");
|
||
return trackRow(t, sub, unlike);
|
||
}));
|
||
if (!likedTracks.length) likesList.textContent = "nothing liked yet. double-tap a track you like.";
|
||
else if (!shown.length) likesList.textContent = "no likes match that.";
|
||
$<HTMLButtonElement>("likes-play").disabled = !shown.length;
|
||
}
|
||
|
||
sheets.likes.onOpen = async () => {
|
||
likesList.textContent = "loading…";
|
||
try {
|
||
({ tracks: likedTracks } = await api.likes());
|
||
} catch (err) {
|
||
likesList.textContent = `couldn't load your likes: ${(err as Error).message}`;
|
||
return;
|
||
}
|
||
showLikes();
|
||
};
|
||
likesSearch.addEventListener("input", showLikes);
|
||
|
||
/// Play the likes shown, in order or shuffled, instead of the feed.
|
||
$("likes-play").addEventListener("click", () => {
|
||
const list = likedTracks.filter((t) => matches(t, likesSearch.value.trim().toLowerCase()));
|
||
if (!list.length) return;
|
||
if (likesShuffle.checked) {
|
||
for (let i = list.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[list[i], list[j]] = [list[j], list[i]];
|
||
}
|
||
}
|
||
Sheet.closeAll();
|
||
if (!started) start();
|
||
queue = { tracks: list, next: 0 };
|
||
showMode();
|
||
for (const c of cards.splice(active + 1)) c.release();
|
||
feedGen++;
|
||
loading = false;
|
||
jumpOnLoad = true;
|
||
fromQueue();
|
||
toast(`playing ${list.length} like${list.length === 1 ? "" : "s"}${likesShuffle.checked ? ", shuffled" : ""}`);
|
||
});
|
||
|
||
/// The next few tracks of the likes queue as cards.
|
||
function fromQueue(): void {
|
||
if (!queue) return;
|
||
const more = queue.tracks.slice(queue.next, queue.next + 5);
|
||
queue.next += more.length;
|
||
if (!more.length) {
|
||
setStatus("that was all of them. tap the chip up top for your feed.");
|
||
return;
|
||
}
|
||
addCards(more.map((t) => new Card(t, actions, { liked: true })));
|
||
}
|
||
|
||
// History ---------------------------------------------------------------
|
||
|
||
const historyList = $("history-list");
|
||
|
||
/// `ts` in seconds since the epoch.
|
||
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`;
|
||
}
|
||
|
||
sheets.history.onOpen = () => {
|
||
const list = heard.load();
|
||
historyList.replaceChildren(...list.map((h) => trackRow(h.track, `${h.track.artist_name} · ${ago(h.at / 1000)}`)));
|
||
if (!list.length) historyList.textContent = "nothing played on this device yet.";
|
||
};
|
||
$("history-clear").addEventListener("click", () => {
|
||
heard.clear();
|
||
historyList.textContent = "cleared.";
|
||
});
|
||
|
||
// Filters and settings --------------------------------------------------
|
||
|
||
const bpmMin = filtersForm.elements.namedItem("bpm_min") as HTMLInputElement;
|
||
const bpmMax = filtersForm.elements.namedItem("bpm_max") as HTMLInputElement;
|
||
const bpmOut = $("bpm-out");
|
||
|
||
function loadFilters(): Filters {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") || {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function showBpm(): void {
|
||
// The two thumbs never cross.
|
||
if (Number(bpmMin.value) > Number(bpmMax.value)) [bpmMin.value, bpmMax.value] = [bpmMax.value, bpmMin.value];
|
||
const [lo, hi] = [Number(bpmMin.value), Number(bpmMax.value)];
|
||
const anyLo = lo <= BPM_RANGE[0], anyHi = hi >= BPM_RANGE[1];
|
||
bpmOut.textContent = anyLo && anyHi ? "any" : anyLo ? `up to ${hi}` : anyHi ? `${lo} and up` : lo === hi ? `${lo}` : `${lo}–${hi}`;
|
||
const span = BPM_RANGE[1] - BPM_RANGE[0];
|
||
filtersForm.style.setProperty("--lo", `${((lo - BPM_RANGE[0]) / span) * 100}%`);
|
||
filtersForm.style.setProperty("--hi", `${((hi - BPM_RANGE[0]) / span) * 100}%`);
|
||
for (const b of filtersForm.querySelectorAll<HTMLButtonElement>("[data-bpm]")) {
|
||
b.setAttribute("aria-pressed", String(b.dataset.bpm === `${lo}-${hi}`));
|
||
}
|
||
}
|
||
|
||
function showFilters(): void {
|
||
(filtersForm.elements.namedItem("free") as HTMLInputElement).checked = filters.free === "1";
|
||
bpmMin.value = filters.bpm_min || String(BPM_RANGE[0]);
|
||
bpmMax.value = filters.bpm_max || String(BPM_RANGE[1]);
|
||
showBpm();
|
||
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
|
||
}
|
||
|
||
function applyFilters(): void {
|
||
filters = {};
|
||
if ((filtersForm.elements.namedItem("free") as HTMLInputElement).checked) filters.free = "1";
|
||
if (Number(bpmMin.value) > BPM_RANGE[0]) filters.bpm_min = bpmMin.value;
|
||
if (Number(bpmMax.value) < BPM_RANGE[1]) filters.bpm_max = bpmMax.value;
|
||
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||
showFilters();
|
||
Sheet.closeAll();
|
||
refill();
|
||
}
|
||
|
||
/// Drop cards after the current one so a new filter or mode applies right away.
|
||
function refill(): void {
|
||
queue = null;
|
||
for (const c of cards.splice(active + 1)) c.release();
|
||
pager.refresh();
|
||
feedGen++;
|
||
loading = false;
|
||
loadMore();
|
||
}
|
||
|
||
for (const input of [bpmMin, bpmMax]) input.addEventListener("input", showBpm);
|
||
for (const b of filtersForm.querySelectorAll<HTMLButtonElement>("[data-bpm]")) {
|
||
b.addEventListener("click", () => {
|
||
[bpmMin.value, bpmMax.value] = b.dataset.bpm!.split("-");
|
||
showBpm();
|
||
});
|
||
}
|
||
|
||
function showSetting(name: string, value: string): void {
|
||
for (const input of filtersForm.querySelectorAll<HTMLInputElement>(`input[name=${name}]`)) input.checked = input.value === value;
|
||
}
|
||
|
||
function setVisuals(v: VisualsSetting): void {
|
||
visual.visuals = v;
|
||
showSetting("visuals", v);
|
||
$("flares-setting").hidden = v !== "liquid";
|
||
}
|
||
|
||
filtersForm.addEventListener("change", (e) => {
|
||
const input = e.target as HTMLInputElement;
|
||
if (input.name === "visuals") setVisuals(input.value as VisualsSetting);
|
||
else if (input.name === "flares") visual.flares = input.value as FlareSetting;
|
||
else if (input.name === "crossfade") player.crossfade = Number(input.value);
|
||
else if (input.name === "bass") player.bassBoost = input.checked;
|
||
else if (input.name === "sleep") setSleep(input.value);
|
||
});
|
||
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
||
$("filters-clear").addEventListener("click", () => {
|
||
(filtersForm.elements.namedItem("free") as HTMLInputElement).checked = false;
|
||
[bpmMin.value, bpmMax.value] = BPM_RANGE.map(String);
|
||
applyFilters();
|
||
});
|
||
|
||
function showSettings(): void {
|
||
showSetting("visuals", visual.visuals);
|
||
showSetting("flares", visual.flares);
|
||
$("flares-setting").hidden = visual.visuals !== "liquid";
|
||
// A saved crossfade that is not one of the choices shows as the nearest one.
|
||
const fades = [...filtersForm.querySelectorAll<HTMLInputElement>("input[name=crossfade]")].map((i) => Number(i.value));
|
||
const fade = fades.reduce((a, b) => (Math.abs(b - player.crossfade) < Math.abs(a - player.crossfade) ? b : a));
|
||
showSetting("crossfade", String(fade));
|
||
const bass = filtersForm.elements.namedItem("bass") as HTMLInputElement;
|
||
bass.checked = player.bassBoost;
|
||
bass.closest("label")!.hidden = !player.canMix;
|
||
showSleep();
|
||
}
|
||
sheets.filters.onOpen = showSettings;
|
||
|
||
// Sleep timer -----------------------------------------------------------
|
||
|
||
let sleepTimer = 0;
|
||
let sleepUntil = 0;
|
||
let sleepAtEnd = false;
|
||
let sleepFade = 0;
|
||
|
||
function setSleep(value: string): void {
|
||
clearTimeout(sleepTimer);
|
||
cancelAnimationFrame(sleepFade);
|
||
player.dimmed = 1;
|
||
sleepUntil = 0;
|
||
sleepAtEnd = value === "end";
|
||
if (value !== "off" && value !== "end") {
|
||
const ms = Number(value) * 60_000;
|
||
sleepUntil = Date.now() + ms;
|
||
sleepTimer = window.setTimeout(sleepNow, Math.max(0, ms - SLEEP_FADE * 1000));
|
||
}
|
||
showSleep();
|
||
if (value !== "off") toast(value === "end" ? "stopping after this track" : `stopping in ${value} min`);
|
||
}
|
||
|
||
/// Fade out over SLEEP_FADE seconds, then pause.
|
||
function sleepNow(): void {
|
||
const stop = () => {
|
||
player.pause();
|
||
player.dimmed = 1;
|
||
sleepUntil = 0;
|
||
showSleep();
|
||
};
|
||
// Hidden tabs get no animation frames: there it stops at once.
|
||
if (document.hidden) {
|
||
stop();
|
||
return;
|
||
}
|
||
const t0 = performance.now();
|
||
const step = () => {
|
||
const k = (performance.now() - t0) / 1000 / SLEEP_FADE;
|
||
player.dimmed = 1 - k;
|
||
if (k < 1) sleepFade = requestAnimationFrame(step);
|
||
else stop();
|
||
};
|
||
sleepFade = requestAnimationFrame(step);
|
||
}
|
||
|
||
function showSleep(): void {
|
||
if (!sleepUntil) showSetting("sleep", sleepAtEnd ? "end" : "off");
|
||
$("sleep-left").textContent = sleepUntil
|
||
? `${fmtTime(Math.max(0, sleepUntil - Date.now()) / 1000)} left`
|
||
: sleepAtEnd ? "after this track" : "";
|
||
}
|
||
|
||
// Feed modes --------------------------------------------------------------
|
||
|
||
const modeChip = $("mode-chip");
|
||
const modeTabs = $("mode-tabs");
|
||
const MODE_NAMES = { classic: "for you", riddim: "riddim", fresh: "fresh", deep: "deep cuts" };
|
||
|
||
function loadMode(): Mode {
|
||
try {
|
||
const m = JSON.parse(localStorage.getItem(MODE_KEY) || "null");
|
||
if (m?.mode === "artist" ? typeof m.artist === "string" && typeof m.name === "string" : m?.mode in MODE_NAMES) return m;
|
||
} catch { /* fall through */ }
|
||
return { mode: "classic" };
|
||
}
|
||
|
||
const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
|
||
|
||
function showMode(): void {
|
||
// A radio or the likes queue shows as a chip that leads back; the tabs make way.
|
||
const chip = mode.mode === "artist" || queue !== null;
|
||
modeChip.hidden = !chip;
|
||
modeTabs.hidden = chip;
|
||
for (const b of modeTabs.querySelectorAll<HTMLButtonElement>("button")) {
|
||
b.setAttribute("aria-pressed", String(b.dataset.mode === mode.mode));
|
||
}
|
||
const avatar = modeChip.querySelector<HTMLElement>(".chip-avatar")!;
|
||
const label = modeChip.querySelector(".chip-label")!;
|
||
if (queue) {
|
||
label.textContent = "your likes";
|
||
avatar.style.backgroundImage = "";
|
||
avatar.innerHTML = '<svg><use href="#i-heart"/></svg>';
|
||
return;
|
||
}
|
||
label.textContent = modeName(mode);
|
||
const img = mode.mode === "artist" ? mode.img : null;
|
||
avatar.style.backgroundImage = img ? `url("${img}")` : "";
|
||
avatar.textContent = img || mode.mode !== "artist" ? "" : mode.name[0]?.toUpperCase() ?? "";
|
||
}
|
||
|
||
function setMode(m: Mode): void {
|
||
radioDry = false;
|
||
updateRadioEnd();
|
||
if (JSON.stringify(m) === JSON.stringify(mode) && !queue) return;
|
||
mode = m;
|
||
queue = null;
|
||
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
|
||
showMode();
|
||
Sheet.closeAll();
|
||
toast(m.mode === "classic" ? "back to your feed" : `now playing: ${modeName(m)}`);
|
||
refill();
|
||
}
|
||
|
||
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
|
||
for (const b of modeTabs.querySelectorAll<HTMLButtonElement>("button")) {
|
||
b.addEventListener("click", () => setMode({ mode: b.dataset.mode as "classic" | "riddim" | "fresh" | "deep" }));
|
||
}
|
||
|
||
const station = new Station((m) => {
|
||
if (!started) start();
|
||
jumpOnLoad = true;
|
||
setMode(m);
|
||
}, (url, title) => shareLink(url, title));
|
||
|
||
/// On the last card of a radio that has played everything known around its artist.
|
||
function updateRadioEnd(): void {
|
||
const m = mode;
|
||
radioEnd.hidden = !(radioDry && m.mode === "artist" && active >= cards.length - 1);
|
||
if (radioEnd.hidden || m.mode !== "artist") return;
|
||
const text = radioEnd.querySelector("p")!;
|
||
const next = $("radio-end-next");
|
||
text.textContent = `that's all around ${m.name} for now. the crawler keeps looking.`;
|
||
// Hand over to the artist playing now, if the radio wandered away from its seed.
|
||
const card = current();
|
||
const other = card && card.track.handle !== m.artist ? card.track : null;
|
||
next.hidden = !other;
|
||
if (other) {
|
||
next.textContent = `${other.artist_name} radio`;
|
||
next.onclick = () => station.open(other.handle, other.artist_name, api.artistImg(other));
|
||
}
|
||
}
|
||
$("radio-end-feed").addEventListener("click", () => setMode({ mode: "classic" }));
|
||
|
||
setVisuals(visual.visuals);
|
||
showMode();
|
||
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") {
|
||
setZen(false);
|
||
station.close();
|
||
return;
|
||
}
|
||
if (k === "?") { sheets.help.toggle(); return; }
|
||
if (station.isOpen || Sheet.current || !loginEl.hidden) return;
|
||
if (!started) {
|
||
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); tapToStart(); }
|
||
return;
|
||
}
|
||
const card = current();
|
||
const a = player.audio;
|
||
if (k === "ArrowDown" || k === "j" || k === "n") { 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 === ".") player.seek(a.currentTime + 5);
|
||
else if (k === ",") player.seek(a.currentTime - 5);
|
||
else if (/^[0-9]$/.test(k) && a.duration) player.seek((Number(k) / 10) * a.duration);
|
||
else if (k === "d" && card) toDrop(card);
|
||
else if (k === "l" && card) setLike(card, !card.liked);
|
||
else if (k === "f" && card) setFollow(card, !card.track.followed);
|
||
else if (k === "r" && card) actions.radio(card);
|
||
else if (k === "x" && card) dislike(card);
|
||
else if (k === "s" && card) share(card);
|
||
else if (k === "h") sheets.history.open();
|
||
else if (k === "c") sheets.filters.open();
|
||
else if (k === "v") { setVisuals(visual.visuals === "liquid" ? "simple" : "liquid"); toast(`${visual.visuals} visuals`); }
|
||
else if (k === "m") { setMuted(!player.muted); toast(player.muted ? "muted" : "sound on"); }
|
||
else if (k === "+" || k === "=") { setVolume(player.volume + 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
|
||
else if (k === "-") { setVolume(player.volume - 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
|
||
else if (k === "z") setZen(!document.body.classList.contains("zen"));
|
||
});
|
||
|
||
/// Zen: nothing but the drop and the music.
|
||
function setZen(on: boolean): void {
|
||
if (document.body.classList.contains("zen") === on) return;
|
||
document.body.classList.toggle("zen", on);
|
||
if (on) {
|
||
Sheet.closeAll();
|
||
toast("zen · z or esc to leave");
|
||
}
|
||
}
|
||
|
||
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 {
|
||
Sheet.closeAll();
|
||
$("bar").hidden = true;
|
||
startEl.hidden = true;
|
||
upNext.hidden = true;
|
||
loginEl.hidden = false;
|
||
emailForm.hidden = false;
|
||
codeForm.hidden = true;
|
||
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
|
||
$("email").focus();
|
||
}
|
||
|
||
/// Nobody signed in on this device: the overlay welcomes them, and one tap plays.
|
||
function showWelcome(): void {
|
||
Sheet.closeAll();
|
||
loginEl.hidden = true;
|
||
$("bar").hidden = true;
|
||
upNext.hidden = true;
|
||
started = false;
|
||
player.pause();
|
||
startEl.classList.add("welcome");
|
||
startEl.querySelector(".start-track")!.textContent = "endless riddim from pl8list. like it, and it comes back.";
|
||
startEl.hidden = false;
|
||
}
|
||
|
||
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); else showWelcome(); });
|
||
|
||
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 showSync(s: Pl8Status | undefined): void {
|
||
const connected = !!s?.connected;
|
||
syncStatus.classList.toggle("on", connected);
|
||
syncStatus.textContent = signedInUser?.guest
|
||
? "listening as a guest on this device. Sign in with pl8list to bring your follows and likes " +
|
||
"along; what you did here comes with you."
|
||
: 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;
|
||
}
|
||
|
||
sheets.account.onOpen = async () => {
|
||
deleteForm.hidden = true;
|
||
try { showSync((await api.me()).pl8list); } catch { /* keep what is shown */ }
|
||
};
|
||
|
||
$("sync-btn").addEventListener("click", async () => {
|
||
if (signedInUser?.guest) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
});
|
||
|
||
/// A guest's sign-out deletes everything they did: it takes a second tap.
|
||
let forgetArmed = false;
|
||
let forgetTimer = 0;
|
||
|
||
for (const [id, url] of [["logout-btn", "/auth/logout"], ["logout-all-btn", "/account/logout-all"]]) {
|
||
$(id).addEventListener("click", async (e) => {
|
||
const btn = e.currentTarget as HTMLButtonElement;
|
||
if (id === "logout-btn" && signedInUser?.guest && !forgetArmed) {
|
||
forgetArmed = true;
|
||
btn.textContent = "tap again: delete your likes, follows and history here";
|
||
btn.classList.add("danger");
|
||
clearTimeout(forgetTimer);
|
||
forgetTimer = window.setTimeout(() => {
|
||
forgetArmed = false;
|
||
btn.textContent = "forget me";
|
||
btn.classList.remove("danger");
|
||
}, 5000);
|
||
return;
|
||
}
|
||
try {
|
||
await api.postJSON(url);
|
||
localStorage.removeItem(NUDGE_KEY);
|
||
location.reload();
|
||
} catch (err) {
|
||
toast((err as Error).message, 4000);
|
||
}
|
||
});
|
||
}
|
||
|
||
$("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() });
|
||
for (const key of [FILTERS_KEY, MODE_KEY, HINT_KEY, NUDGE_KEY]) localStorage.removeItem(key);
|
||
heard.clear();
|
||
location.reload();
|
||
} catch (err) {
|
||
toast((err as Error).message, 4000);
|
||
}
|
||
});
|
||
|
||
function signedIn(user: User, pl8list?: Pl8Status): void {
|
||
signedInUser = user;
|
||
const guest = !!user.guest;
|
||
showSync(pl8list);
|
||
loginEl.hidden = true;
|
||
$("bar").hidden = false;
|
||
startEl.classList.remove("welcome");
|
||
accountBtn.textContent = guest ? "sign in" : (user.name || user.handle)[0]?.toUpperCase() ?? "?";
|
||
accountBtn.classList.toggle("guest", guest);
|
||
accountBtn.style.backgroundColor = guest ? "var(--glass-strong)" : accentForName(user.handle);
|
||
accountBtn.title = guest ? "listening as a guest: sign in, or forget me" : `@${user.handle}`;
|
||
$("account-handle").textContent = guest ? "listening as a guest" : `@${user.handle}`;
|
||
$("delete-handle").textContent = user.handle;
|
||
// A guest has one session and nothing on pl8list: signing out forgets them.
|
||
$("sync-title").textContent = guest ? "pl8list" : "pl8list sync";
|
||
$("sync-btn").textContent = guest ? "sign in with pl8list" : "resync with pl8list";
|
||
$("logout-btn").textContent = guest ? "forget me" : "sign out";
|
||
forgetArmed = false;
|
||
for (const id of ["logout-all-btn", "delete-open"]) $(id).hidden = guest;
|
||
// 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) openLinkOrFeed();
|
||
}
|
||
|
||
/// `/?t=handle/slug` opens on that track; the feed carries on after it.
|
||
/// `/?r=handle` opens that artist's radio sheet over the feed.
|
||
async function openLinkOrFeed(): Promise<void> {
|
||
const radio = new URLSearchParams(location.search).get("r");
|
||
if (radio) {
|
||
history.replaceState(null, "", location.pathname);
|
||
api.artist(radio).then(
|
||
({ artist }) => station.open(artist.handle, artist.name, api.imgUrl(artist.img)),
|
||
(err) => toast(`couldn't open that radio: ${(err as Error).message}`, 4000),
|
||
);
|
||
}
|
||
const link = new URLSearchParams(location.search).get("t")?.split("/");
|
||
if (link?.length === 2) {
|
||
history.replaceState(null, "", location.pathname);
|
||
try {
|
||
const { track } = await api.track(link[0], link[1]);
|
||
addCards([new Card(track, actions)]);
|
||
} catch (err) {
|
||
toast(`couldn't open that link: ${(err as Error).message}`, 4000);
|
||
}
|
||
}
|
||
loadMore();
|
||
}
|
||
|
||
api.me().then(
|
||
(me) => signedIn(me.user, me.pl8list),
|
||
() => showWelcome(),
|
||
);
|