The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
146 lines
5.2 KiB
TypeScript
146 lines
5.2 KiB
TypeScript
import type { CSSProperties, Ref } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
|
import type { VideoDetail } from "../lib/api";
|
|
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
|
import { Button } from "./ui/Button";
|
|
import { StateMessage } from "./ui/QueryState";
|
|
import { renderDescription } from "./descriptionLinks";
|
|
|
|
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
|
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
|
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
|
|
|
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
|
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
|
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
|
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
|
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
|
export function PlayerTransportControls({
|
|
variant,
|
|
index,
|
|
queueLength,
|
|
autoMode,
|
|
loopMode,
|
|
onPrev,
|
|
onNext,
|
|
onCycleAuto,
|
|
onCycleLoop,
|
|
}: {
|
|
variant: "docked" | "modal";
|
|
index: number;
|
|
queueLength: number;
|
|
autoMode: AutoMode;
|
|
loopMode: LoopMode;
|
|
onPrev: () => void;
|
|
onNext: () => void;
|
|
onCycleAuto: () => void;
|
|
onCycleLoop: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const docked = variant === "docked";
|
|
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
|
|
const chrome = docked ? "overlay" : "ghost";
|
|
const navLabel = docked ? "hidden sm:inline" : "";
|
|
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
|
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
|
const toggleLabel = docked ? "hidden md:inline" : "";
|
|
return (
|
|
<>
|
|
<Button
|
|
variant={chrome}
|
|
size="sm"
|
|
icon={<SkipBack />}
|
|
onClick={onPrev}
|
|
disabled={index === 0}
|
|
label={t("player.previous")}
|
|
title={`${t("player.previous")} · Shift+←`}
|
|
>
|
|
<span className={navLabel}>{t("player.previous")}</span>
|
|
</Button>
|
|
<span className={counter}>
|
|
{index + 1} / {queueLength}
|
|
</span>
|
|
<Button
|
|
variant={chrome}
|
|
size="sm"
|
|
onClick={onNext}
|
|
disabled={index === queueLength - 1}
|
|
label={t("player.next")}
|
|
title={`${t("player.next")} · Shift+→`}
|
|
>
|
|
<span className={navLabel}>{t("player.next")}</span>
|
|
<SkipForward />
|
|
</Button>
|
|
<span className={divider} />
|
|
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
|
<Button
|
|
variant={chrome}
|
|
size="sm"
|
|
pressed={autoMode !== "off"}
|
|
icon={
|
|
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
|
|
}
|
|
onClick={onCycleAuto}
|
|
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
|
|
title={t("player.autoAdvance.hint")}
|
|
>
|
|
<span className={toggleLabel}>
|
|
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
variant={chrome}
|
|
size="sm"
|
|
pressed={loopMode !== "off"}
|
|
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
|
|
onClick={onCycleLoop}
|
|
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
|
|
title={t("player.loop.hint")}
|
|
>
|
|
<span className={toggleLabel}>
|
|
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
|
</span>
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
|
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
|
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
|
|
* `ref` / `style` / `className` dress the text's box only. */
|
|
export function PlayerDescription({
|
|
detail,
|
|
currentId,
|
|
onSeek,
|
|
onLoadVideo,
|
|
className,
|
|
style,
|
|
ref,
|
|
}: {
|
|
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
|
currentId: string;
|
|
onSeek: (seconds: number) => void;
|
|
onLoadVideo: (id: string, start: number | null) => void;
|
|
className: string;
|
|
style?: CSSProperties;
|
|
ref?: Ref<HTMLDivElement>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
|
if (detail.isError && !detail.data)
|
|
return (
|
|
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
|
{t("player.descriptionFailed")}
|
|
</StateMessage>
|
|
);
|
|
const text = detail.data?.description;
|
|
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
|
return (
|
|
<div ref={ref} style={style} className={className}>
|
|
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
|
</div>
|
|
);
|
|
}
|