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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user