import type { CSSProperties, ReactNode, Ref } from "react"; import { useTranslation } from "react-i18next"; import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import type { VideoDetail } from "../../lib/api"; import type { AutoMode, LoopMode } from "../../lib/playerModes"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import { StateMessage } from "../../components/ui/QueryState"; import { ClampedText } from "../../components/ui/ClampedText"; 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 modes. * Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal * glass theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view * docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a * control does, its glyph and its tooltip are said once; only the primitive and the label's * visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has * one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles, * not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its * own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). 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"; 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 autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`; const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`; type Control = { key: string; glyph: ReactNode; name: string; title: string; onClick: () => void; disabled?: boolean; lit?: boolean; /** From which width the docked bar shows the name beside the glyph. */ labelFrom: "sm" | "md"; /** The glyph after the name (Next points the way it goes). */ trailing?: boolean; }; const step: Control[] = [ { key: "prev", glyph: , name: t("player.previous"), title: `${t("player.previous")} · Shift+←`, onClick: onPrev, disabled: index === 0, labelFrom: "sm", }, { key: "next", glyph: , name: t("player.next"), title: `${t("player.next")} · Shift+→`, onClick: onNext, disabled: index === queueLength - 1, labelFrom: "sm", trailing: true, }, ]; // Persistent playback settings — cycle on click, saved to your account. const modes: Control[] = [ { key: "auto", glyph: autoMode === "prev" ? : autoMode === "random" ? : , name: autoName, title: `${autoName} · ${t("player.autoAdvance.hint")}`, onClick: onCycleAuto, lit: autoMode !== "off", labelFrom: "md", }, { key: "loop", glyph: loopMode === "one" ? : , name: loopName, title: `${loopName} · ${t("player.loop.hint")}`, onClick: onCycleLoop, lit: loopMode !== "off", labelFrom: "md", }, ]; const render = (c: Control) => docked ? ( ) : ( {c.glyph} ); return ( <> {render(step[0]!)} {index + 1} / {queueLength} {render(step[1]!)} {modes.map(render)} ); } /** 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. `clamp` cuts it to three lines with a * More / Less (the panel, where the description sits under the actions and is rarely read whole). */ export function PlayerDescription({ detail, currentId, onSeek, onLoadVideo, className, style, ref, clamp = false, }: { 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; clamp?: boolean; }) { const { t } = useTranslation(); if (detail.isLoading) return
{t("player.loading")}
; if (detail.isError && !detail.data) return ( void detail.refetch()}> {t("player.descriptionFailed")} ); const text = detail.data?.description; if (!text) return
{t("player.noDescription")}
; if (clamp) return ( {renderDescription(text, { currentId, onSeek, onLoadVideo, t })} ); return (
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
); }