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:
91
src/hooks/player/usePlayRichContent.ts
Normal file
91
src/hooks/player/usePlayRichContent.ts
Normal 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";
|
||||
}
|
||||
262
src/hooks/player/useYouTubePlaylist.ts
Normal file
262
src/hooks/player/useYouTubePlaylist.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user