mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Each comment sits as a dot under the moment it was left at. When the music gets there the dot pings and a bubble grows out of it on a spark, then drifts up and fades; a mouse over a dot peeks at it. The server fetches the newest 60 comments from pl8list on demand and keeps them for 15 minutes. A setting turns them off.
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
// 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;
|
|
/// From titles, descriptions and BPM, see backend/src/tags.rs.
|
|
tags?: string[];
|
|
/// In an artist radio: why this track is in it.
|
|
why?: Why;
|
|
}
|
|
|
|
/// `seed`: by the radio's artist; `related`: by an artist next to them;
|
|
/// `via`: one step further, through the artist `handle`.
|
|
export type Why = { kind: "seed" } | { kind: "related" } | { kind: "via"; handle: string; name: string };
|
|
|
|
export interface Artist {
|
|
handle: string;
|
|
name: string;
|
|
img: string | null;
|
|
verified: boolean;
|
|
tracks: number;
|
|
}
|
|
|
|
/// An artist and their neighbours in the artist graph, for the radio sheet.
|
|
export interface ArtistInfo {
|
|
artist: Artist;
|
|
neighbours: { handle: string; name: string; img: string | null; weight: number }[];
|
|
tags: string[];
|
|
followed: boolean;
|
|
/// Still crawling for neighbours; ask again in a moment.
|
|
exploring: boolean;
|
|
}
|
|
|
|
export interface Stats {
|
|
tracks: number;
|
|
artists: number;
|
|
artists_crawled: number;
|
|
served: number;
|
|
liked: number;
|
|
}
|
|
|
|
export interface User {
|
|
handle: string;
|
|
name: string | null;
|
|
/// Listening without a pl8list account.
|
|
guest?: boolean;
|
|
}
|
|
|
|
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>>;
|
|
|
|
/// 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; img?: string | null };
|
|
|
|
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike" | "undislike";
|
|
|
|
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 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 artist = (handle: string) => getJSON<ArtistInfo>("/local/artist?" + new URLSearchParams({ handle }));
|
|
|
|
export const track = (handle: string, slug: string) =>
|
|
getJSON<{ track: Track }>("/local/track?" + new URLSearchParams({ handle, slug }));
|
|
|
|
/// A comment left at a moment of the track (`at`, seconds in).
|
|
export interface Comment {
|
|
id: number;
|
|
at: number;
|
|
body: string;
|
|
handle: string;
|
|
name: string;
|
|
img: string | null;
|
|
verified: boolean;
|
|
}
|
|
|
|
export const comments = (trackId: number) =>
|
|
getJSON<{ comments: Comment[] }>("/local/comments?" + new URLSearchParams({ id: String(trackId) }));
|
|
|
|
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}`;
|
|
/// A link that opens this app on the track.
|
|
export const appTrackUrl = (t: Track) => `${location.origin}/?t=${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}`;
|
|
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
|
|
/// A link that opens this app on an artist's radio sheet.
|
|
export const appRadioUrl = (handle: string) => `${location.origin}/?r=${encodeURIComponent(handle)}`;
|
|
// Images come through our server, so pl8list never sees the visitor's address.
|
|
export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
|
|
export const artistImg = (t: Track) => imgUrl(t.artist_img);
|
|
|
|
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}`;
|
|
}
|