Add discovery modes, track tags and an artist graph

Tracks get tags from their title, description, artist name and BPM
(styles such as riddim, versions such as VIP/dub/flip, and tempo), stored
in track_tags and shown on cards. Related pages the crawler reads are kept
in track_related, which links artists to each other.

The feed takes ?mode=: riddim (140 BPM, riddim artists and their graph
neighbours first), fresh, deep cuts, and an artist radio that plays an
artist and the artists around them. Opening a radio crawls that artist's
related pages first. The crawler searches riddim-adjacent terms first.

The filters panel picks the feed, each card has a radio button (r), and a
chip in the top bar returns to the normal feed.
This commit is contained in:
nvrl committed 2026-11-01 13:46:52 +01:00
1 parent b4470850bf
commit f6c505907a
14 files changed
+737 -36

No files matched your search

+14 -2
View File
@@ -22,6 +22,8 @@ export interface Track {
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[];
}
export interface Stats {
@@ -49,6 +51,12 @@ export interface Me {
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
/// Which part of the catalogue the feed draws from. `artist` is an artist radio:
/// that artist and the ones pl8list relates to them.
export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
export class HttpError extends Error {
@@ -84,8 +92,12 @@ export function sendEvent(track: Track, kind: EventKind, seconds?: number): void
}
}
export const feed = (filters: Filters) =>
getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + new URLSearchParams({ n: "10", ...filters }));
export function feed(filters: Filters, mode: Mode) {
const p = new URLSearchParams({ n: "10", ...filters });
if (mode.mode !== "classic") p.set("mode", mode.mode);
if (mode.mode === "artist") p.set("artist", mode.artist);
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
}
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
+4
View File
@@ -10,6 +10,7 @@ export interface CardActions {
share(card: Card): void;
next(card: Card): void;
follow(card: Card): void;
radio(card: Card): void;
/// Seeking only works on the playing card.
canSeek(card: Card): boolean;
duration(card: Card): number;
@@ -88,6 +89,8 @@ export class Card {
if (t.key) chip(t.key);
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
if (t.preview) chip("preview", "preview");
// Tempo tags repeat the BPM chip.
for (const tag of (t.tags ?? []).filter((g) => !/^\d+$/.test(g)).slice(0, 4)) chip(tag, "tag");
$(".counts").textContent =
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
@@ -98,6 +101,7 @@ export class Card {
$(".share").addEventListener("click", () => this.actions.share(this));
$(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this));
$(".radio").addEventListener("click", () => this.actions.radio(this));
this.el = el;
this.canvas = $<HTMLCanvasElement>(".wave");
+61 -6
View File
@@ -1,7 +1,7 @@
import "./style.css";
import * as api from "./api";
import type { Filters, Pl8Status, Stats, Track, User } from "./api";
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
import { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager";
import { Player } from "./player";
@@ -14,6 +14,7 @@ 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 MODE_KEY = "pl8inf.mode";
const HINT_KEY = "pl8inf.hintShown";
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
@@ -39,6 +40,7 @@ const visual = new Visualizer(player);
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;
@@ -46,6 +48,7 @@ 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 mode: Mode = loadMode();
let stats: Stats | null = null;
const current = () => cards[active] as Card | undefined;
@@ -68,14 +71,18 @@ async function loadMore(): Promise<void> {
if (loading) 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);
const data = await api.feed(filters, mode);
if (gen !== feedGen) return;
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."
setStatus(mode.mode === "artist"
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.`
: 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."
: "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { loading = false; }, 8000);
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
return;
}
setStatus("");
@@ -83,6 +90,7 @@ async function loadMore(): Promise<void> {
pager.refresh();
if (active < 0) activate(0);
} catch (err) {
if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) {
loading = false;
showLogin();
@@ -164,6 +172,7 @@ const actions: CardActions = {
share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed),
radio: (c) => setMode({ mode: "artist", artist: c.track.handle, name: c.track.artist_name }),
canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s),
@@ -468,12 +477,56 @@ function applyFilters(): void {
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
// Drop cards after the current one so the new filter applies right away.
refill();
}
/// Drop cards after the current one so a new filter or mode applies right away.
function refill(): void {
for (const c of cards.splice(active + 1)) c.release();
pager.refresh();
feedGen++;
loading = false;
loadMore();
}
// Feed modes --------------------------------------------------------------
const modeChip = $("mode-chip");
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 {
modeChip.hidden = mode.mode === "classic";
modeChip.textContent = `${modeName(mode)} ×`;
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
}
function setMode(m: Mode): void {
if (JSON.stringify(m) === JSON.stringify(mode)) return;
mode = m;
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
showMode();
closePanels();
toast(m.mode === "classic" ? "back to your feed" : `now playing: ${modeName(m)}`);
refill();
}
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement;
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
});
showMode();
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
@@ -503,6 +556,7 @@ document.addEventListener("keydown", (e) => {
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 === "r" && card) actions.radio(card);
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"); }
@@ -659,6 +713,7 @@ deleteForm.addEventListener("submit", async (e) => {
try {
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
localStorage.removeItem(FILTERS_KEY);
localStorage.removeItem(MODE_KEY);
localStorage.removeItem(HINT_KEY);
location.reload();
} catch (err) {
+22 -2
View File
@@ -82,6 +82,13 @@ button {
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent);
}
.panel {
position: fixed;
@@ -95,7 +102,19 @@ button {
box-shadow: 0 16px 40px #000a;
}
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; max-width: 24rem; }
.modes { display: flex; gap: 0.4rem; flex-wrap: wrap; flex-basis: 100%; margin: 0; padding: 0; border: 0; }
.modes legend { color: var(--muted); font-size: 0.8rem; padding: 0; margin-bottom: 0.3rem; }
.modes label { cursor: pointer; }
.modes input { position: absolute; opacity: 0; }
.modes label:has(input) {
padding: 0.25em 0.8em;
border-radius: 999px;
border: 1px solid #ffffff40;
font-size: 0.85rem;
}
.modes label:has(input:checked) { background: var(--accent); border-color: transparent; color: #000; }
.modes label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
#filters input[type=number] {
width: 4.5em;
background: #222;
@@ -266,7 +285,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
.follow {
.follow, .radio {
font-size: 0.75rem;
padding: 0.15em 0.75em;
border-radius: 999px;
@@ -285,6 +304,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.chips .free { background: #1f7a4d; }
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
.chips .preview { background: #6a4b16; }
.chips .tag { background: transparent; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--card-accent) 60%, transparent); }
.desc {
margin: 0;