mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 08:46:59 +01:00
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:
1 parent
b4470850bf
commit
f6c505907a
14 files changed
+737
-36
No files matched your search
+14
-2
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user