mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 00:46:59 +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
+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}`;
|
||||
}
|
||||
Reference in new issue
Block a user