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:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View File

@@ -0,0 +1,91 @@
import { useCallback, useContext, useState } from "react";
import {
PlayerContext,
type PlayerItem,
} from "../../contexts/player/PlayerContext.ts";
import { BANDCAMP_PLAYER } from "../../config/playerMode.ts";
import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts";
export interface PlayableRichContent {
type: string;
url: string;
title?: string;
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
/** Header subtitle — "YouTube", "SoundCloud", the site's own name. */
siteName?: string;
}
/**
* Start playback for a rich-content item.
*
* Everything except Bandcamp goes straight to the iframe embed, exactly as
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
* mode the page is resolved to its mp3 streams and played as a queue, which is
* what makes it autoplay and lets albums play through. Any failure — offline,
* preorder-only release, Bandcamp changing its markup — falls back to the embed.
*
* Returns whether playback actually started. A native-mode page with no stored
* embedUrl (an artist root, a /music index, a preorder) has nothing to fall
* back to, and the caller has to be told so the click isn't swallowed.
*/
export function usePlayRichContent() {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
const playEmbed = useCallback(
(rc: PlayableRichContent, dumpHref?: string): boolean => {
if (!rc.embedUrl) return false;
play({
kind: "embed",
embedUrl: rc.embedUrl,
title: rc.title,
type: rc.type,
dumpHref,
artworkUrl: rc.thumbnailUrl,
subtitle: rc.siteName,
});
return true;
},
[play],
);
const playRichContent = useCallback(
async (rc: PlayableRichContent, dumpHref?: string): Promise<boolean> => {
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
if (!native) return playEmbed(rc, dumpHref);
setPending(true);
try {
const items: PlayerItem[] = await resolveBandcampQueue(rc.url, {
dumpHref,
embedUrl: rc.embedUrl,
// Bandcamp's own album art wins; this is the fallback if the page
// doesn't carry one.
fallbackArtworkUrl: rc.thumbnailUrl,
});
playQueue(items, 0);
return true;
} catch (err) {
console.warn("bandcamp: native playback unavailable, using embed", err);
return playEmbed(rc, dumpHref);
} finally {
setPending(false);
}
},
[playEmbed, playQueue],
);
return { playRichContent, pending };
}
/**
* Whether a rich-content item can be played at all. In native mode a Bandcamp
* page is playable even without a stored embedUrl, since the streams are
* resolved from the page itself.
*/
export function canPlayRichContent(rc: PlayableRichContent): boolean {
if (rc.embedUrl) return true;
return rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
}

View File

@@ -0,0 +1,262 @@
import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
embedListId,
fetchPlaylistTracks,
type YouTubeTrack,
} from "../../utils/player/youtube.ts";
/**
* Tracklist and transport for a YouTube playlist embed.
*
* A Bandcamp album becomes a real queue of streams, but a YouTube playlist
* stays inside the iframe: YouTube holds the list and advances through it. So
* the two halves come from two places — the titles and lengths from the server
* (the embed exposes neither), the current position and the controls from the
* IFrame Player API attached to the iframe we already render.
*
* Both halves are optional. Without the server's titles there is no list; with
* a list but no API (script blocked, embedding restricted) the rows are shown
* but inert, since nothing can be told to play.
*/
interface YTPlayer {
playVideo(): void;
playVideoAt(index: number): void;
nextVideo(): void;
previousVideo(): void;
getPlaylist(): string[] | null;
getPlaylistIndex(): number;
destroy(): void;
}
interface YTStateEvent {
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
data: number;
}
interface YTNamespace {
Player: new (
el: HTMLIFrameElement,
opts: {
events: {
onReady?: () => void;
onStateChange?: (event: YTStateEvent) => void;
};
},
) => YTPlayer;
}
const PAUSED = 2;
/**
* An embed that has `enablejsapi` on pauses itself when the page goes to the
* background — something a plain embed does not do, and nothing on our side
* asked for: it breaks listening while working in another tab. A pause landing
* in the moment the tab is hidden is therefore undone. The window is narrow
* and the retries few on purpose, so a pause that comes later (a media key, a
* phone call, the user coming back and hitting space) stands.
*/
const RESUME_WINDOW_MS = 4000;
const MAX_RESUMES = 3;
// The globals YouTube's IFrame API script sets and calls.
declare global {
interface Window {
YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void;
}
}
const API_SRC = "https://www.youtube.com/iframe_api";
let apiPromise: Promise<YTNamespace> | null = null;
/** Load YouTube's IFrame API once per document, whoever asks first. */
function loadPlayerApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
const w = window;
if (w.YT?.Player) {
resolve(w.YT);
return;
}
// The API calls this global when it finishes loading; chain any existing
// one rather than clobbering it.
const previous = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
previous?.();
if (w.YT?.Player) resolve(w.YT);
else reject(new Error("YouTube IFrame API loaded without a Player"));
};
const script = document.createElement("script");
script.src = API_SRC;
script.async = true;
script.onerror = () => reject(new Error("YouTube IFrame API failed"));
document.head.appendChild(script);
});
return apiPromise;
}
export interface PlaylistRow {
videoId: string;
title?: string;
duration?: number;
}
export interface YouTubePlaylistControls {
rows: PlaylistRow[];
/** Position in `rows`, or -1 while the player hasn't reported one. */
index: number;
/** Whether the embed can be driven — rows are clickable only then. */
ready: boolean;
playAt(index: number): void;
next(): void;
previous(): void;
}
export function useYouTubePlaylist(
embedUrl: string | null,
iframeRef: RefObject<HTMLIFrameElement | null>,
): YouTubePlaylistControls {
const listId = embedUrl ? embedListId(embedUrl) : null;
const [tracks, setTracks] = useState<YouTubeTrack[]>([]);
// Video IDs as the player itself sees them: the authority on order and
// length, and the only source for a playlist the server couldn't read.
const [playerIds, setPlayerIds] = useState<string[] | null>(null);
const [index, setIndex] = useState(-1);
const [ready, setReady] = useState(false);
const playerRef = useRef<YTPlayer | null>(null);
const hiddenAt = useRef(0);
const resumes = useRef(0);
useEffect(() => {
const onVisibility = () => {
hiddenAt.current = document.hidden ? Date.now() : 0;
if (!document.hidden) resumes.current = 0;
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
// Everything below is about one embed; when that changes, drop it all in the
// same render rather than letting a stale list flash under the new video.
const [shownUrl, setShownUrl] = useState(embedUrl);
if (shownUrl !== embedUrl) {
setShownUrl(embedUrl);
setTracks([]);
setPlayerIds(null);
setIndex(-1);
setReady(false);
}
useEffect(() => {
if (!listId) return;
let cancelled = false;
fetchPlaylistTracks(listId)
.then((t) => {
if (!cancelled) setTracks(t);
})
.catch((err) => {
console.warn("youtube: no tracklist for this playlist", err);
});
return () => {
cancelled = true;
};
}, [listId]);
useEffect(() => {
if (!listId || !embedUrl) return;
let cancelled = false;
let player: YTPlayer | null = null;
loadPlayerApi()
.then((YT) => {
const iframe = iframeRef.current;
if (cancelled || !iframe) return;
const sync = () => {
if (!player) return;
const list = player.getPlaylist?.();
setPlayerIds(Array.isArray(list) && list.length > 0 ? list : null);
const at = player.getPlaylistIndex?.();
setIndex(typeof at === "number" ? at : -1);
};
player = new YT.Player(iframe, {
// onStateChange is what reports the list advancing on its own.
events: {
onReady: () => {
if (cancelled) return;
setReady(true);
sync();
},
onStateChange: (event) => {
if (cancelled) return;
sync();
if (
event.data === PAUSED && document.hidden &&
Date.now() - hiddenAt.current < RESUME_WINDOW_MS &&
resumes.current < MAX_RESUMES
) {
resumes.current += 1;
player?.playVideo();
}
},
},
});
playerRef.current = player;
})
.catch((err) => {
console.warn("youtube: player API unavailable", err);
});
return () => {
cancelled = true;
playerRef.current = null;
// destroy() takes the iframe with it, which is why the element is keyed
// on the embed URL: React only ever removes its wrapper, never a node
// that has gone out from under it.
try {
player?.destroy();
} catch {
// already gone with the unmounted subtree
}
};
}, [listId, embedUrl, iframeRef]);
const rows = useMemo<PlaylistRow[]>(() => {
if (!playerIds) {
return tracks.map((t) => ({
videoId: t.videoId,
title: t.title,
duration: t.duration,
}));
}
// Positions usually line up with the scrape; fall back to matching by ID
// so a list that gained or lost an entry since still reads correctly.
const byId = new Map(tracks.map((t) => [t.videoId, t]));
return playerIds.map((videoId, i) => {
const track = tracks[i]?.videoId === videoId
? tracks[i]
: byId.get(videoId);
return { videoId, title: track?.title, duration: track?.duration };
});
}, [playerIds, tracks]);
const playAt = useCallback((i: number) => {
playerRef.current?.playVideoAt(i);
}, []);
const next = useCallback(() => playerRef.current?.nextVideo(), []);
const previous = useCallback(() => playerRef.current?.previousVideo(), []);
return { rows, index, ready, playAt, next, previous };
}