v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
78
src/utils/player/bandcamp.ts
Normal file
78
src/utils/player/bandcamp.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
|
||||
|
||||
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
|
||||
interface BandcampTrack {
|
||||
index: number;
|
||||
trackNum?: number;
|
||||
title: string;
|
||||
duration?: number;
|
||||
streamUrl: string | null;
|
||||
streamable: boolean;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
interface BandcampAlbum {
|
||||
sourceUrl: string;
|
||||
itemType: "album" | "track";
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artworkUrl?: string;
|
||||
tracks: BandcampTrack[];
|
||||
resolvedAt: number;
|
||||
}
|
||||
|
||||
export interface BandcampContext {
|
||||
dumpHref?: string;
|
||||
/** Kept on every item so playback can fall back to the iframe later. */
|
||||
embedUrl?: string;
|
||||
/** Used only if the page carries no album art of its own. */
|
||||
fallbackArtworkUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Bandcamp page into a playable queue.
|
||||
*
|
||||
* Throws on any failure — callers treat that as "fall back to the embed".
|
||||
* `force` skips the server's cache, for re-resolving after a signature expires.
|
||||
*/
|
||||
export async function resolveBandcampQueue(
|
||||
pageUrl: string,
|
||||
ctx: BandcampContext = {},
|
||||
{ force = false }: { force?: boolean } = {},
|
||||
): Promise<PlayerItem[]> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/bandcamp/tracks?url=${encodeURIComponent(pageUrl)}${
|
||||
force ? "&force=1" : ""
|
||||
}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
|
||||
|
||||
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
|
||||
const album = body.data;
|
||||
if (!body.success || !album) throw new Error("resolve returned no data");
|
||||
|
||||
const items = album.tracks
|
||||
.filter((t): t is BandcampTrack & { streamUrl: string } =>
|
||||
t.streamable && typeof t.streamUrl === "string"
|
||||
)
|
||||
.map((t): PlayerItem => ({
|
||||
kind: "stream",
|
||||
streamUrl: t.streamUrl,
|
||||
type: "bandcamp",
|
||||
// The row names the track; the artist rides along on the subtitle line.
|
||||
title: t.title,
|
||||
subtitle: album.artist,
|
||||
duration: t.duration,
|
||||
trackNum: t.trackNum,
|
||||
artworkUrl: album.artworkUrl ?? ctx.fallbackArtworkUrl,
|
||||
dumpHref: ctx.dumpHref,
|
||||
resolveUrl: album.sourceUrl,
|
||||
resolveIndex: t.index,
|
||||
resolvedAt: album.resolvedAt,
|
||||
embedUrl: ctx.embedUrl,
|
||||
}));
|
||||
|
||||
if (items.length === 0) throw new Error("no streamable tracks");
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user