v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 07:23:03 +00:00
parent b85a71d86e
commit 563daa720c
171 changed files with 3921 additions and 851 deletions

View File

@@ -1,3 +1,9 @@
/**
* Playing a link's preview in the player.
*
* @module
*/
import { useCallback, useContext, useState } from "react";
import {
PlayerContext,
@@ -6,10 +12,15 @@ import {
import { BANDCAMP_PLAYER } from "../../config/playerMode.ts";
import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts";
/** The part of a link's preview the player needs. */
export interface PlayableRichContent {
/** The provider, as in `RichContent.type`. */
type: string;
/** The link. */
url: string;
/** The title, for the player header. */
title?: string;
/** The provider's embed. */
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
@@ -17,20 +28,35 @@ export interface PlayableRichContent {
siteName?: string;
}
/** What {@link usePlayRichContent} returns. */
export interface UsePlayRichContentResult {
/**
* Starts playback of `rc`, played from the dump page `dumpHref`; resolves to
* whether playback actually started.
*/
playRichContent: (
rc: PlayableRichContent,
dumpHref?: string,
) => Promise<boolean>;
/** True while a Bandcamp page is being resolved. */
pending: boolean;
}
/**
* Start playback for a rich-content item.
* Returns a way to 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.
* 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.
* Playback may not start: 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() {
export function usePlayRichContent(): UsePlayRichContentResult {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
@@ -81,9 +107,9 @@ export function usePlayRichContent() {
}
/**
* 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.
* Checks whether `rc` 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;

View File

@@ -1,3 +1,19 @@
/**
* 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.
*
* @module
*/
import {
type RefObject,
useCallback,
@@ -12,20 +28,7 @@ import {
} from "../../utils/player/youtube.ts";
import type { YouTubePlaylistTrack } from "../../../shared/player.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.
*/
/** The part of the IFrame API's player used here. */
interface YTPlayer {
playVideo(): void;
playVideoAt(index: number): void;
@@ -36,11 +39,13 @@ interface YTPlayer {
destroy(): void;
}
/** A player state change. */
interface YTStateEvent {
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
data: number;
}
/** The IFrame API's `YT` global. */
interface YTNamespace {
Player: new (
el: HTMLIFrameElement,
@@ -53,20 +58,23 @@ interface YTNamespace {
) => YTPlayer;
}
/** YT.PlayerState's paused. */
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.
* How soon after the tab is hidden a pause is undone. 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;
/** How many pauses are undone per hiding of the tab. */
const MAX_RESUMES = 3;
// The globals YouTube's IFrame API script sets and calls.
/** The globals YouTube's IFrame API script sets and calls. */
declare global {
interface Window {
YT?: YTNamespace;
@@ -74,11 +82,13 @@ declare global {
}
}
/** The IFrame API script. */
const API_SRC = "https://www.youtube.com/iframe_api";
/** The IFrame API, once asked for. */
let apiPromise: Promise<YTNamespace> | null = null;
/** Load YouTube's IFrame API once per document, whoever asks first. */
/** Loads YouTube's IFrame API once per document, whoever asks first. */
function loadPlayerApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
@@ -104,27 +114,40 @@ function loadPlayerApi(): Promise<YTNamespace> {
return apiPromise;
}
/** A row of the tracklist. */
export interface PlaylistRow {
/** The video's id. */
videoId: string;
/** Its title, when the server's list has it. */
title?: string;
/** Its length in seconds, when the server's list has it. */
duration?: number;
}
export interface YouTubePlaylistControls {
/** What {@link useYouTubePlaylist} returns. */
export interface UseYouTubePlaylistResult {
/** The tracklist. */
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;
/** Plays the row at `index`. */
playAt(index: number): void;
/** Plays the next video. */
next(): void;
/** Plays the previous video. */
previous(): void;
}
/**
* Returns the tracklist and transport of the playlist `embedUrl` plays in the
* iframe `iframeRef`, when it plays one.
*/
export function useYouTubePlaylist(
embedUrl: string | null,
iframeRef: RefObject<HTMLIFrameElement | null>,
): YouTubePlaylistControls {
): UseYouTubePlaylistResult {
const listId = embedUrl ? embedListId(embedUrl) : null;
const [tracks, setTracks] = useState<YouTubePlaylistTrack[]>([]);