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 { IconButton } from "../../components/ui/IconButton"; import Tooltip from "../../components/ui/Tooltip"; import { StateMessage } from "../../components/ui/QueryState"; import { unavailable } from "../../components/ui/unavailable"; 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`. /** Why a control is off, where a key's focus reads it too: a browser's own tooltip (`title`, left on * the button for what it does) shows for a mouse alone. `stable`: the reason comes and goes under a * control that may hold the focus. */ export function ReasonTooltip({ reason, side, children, }: { reason: string | undefined; side?: "left"; children: ReactNode; }) { return ( {children} ); } /** 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) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only * in both: the docked bar once spoke the names from a width up, and on an iPad its row outgrew the * screen. What a control does, its glyph and its tooltip are said once; only the look differs. * Every control's tooltip carries its full name (and the shortcut, where it has one), so a glyph * 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, canPrev, canNext, prevReason, nextReason, autoMode, loopMode, onPrev, onNext, onCycleAuto, onCycleLoop, }: { variant: "docked" | "modal"; index: number; queueLength: number; /** Whether there is a playable item that way (one YouTube refused is passed over). */ canPrev: boolean; canNext: boolean; /** Why Previous / Next are off when items are left that way (only ones YouTube refused): the * button stays focusable and its tooltip says so. Unset at the queue's ends, which explain * themselves. */ prevReason?: string; nextReason?: string; autoMode: AutoMode; loopMode: LoopMode; onPrev: () => void; onNext: () => void; onCycleAuto: () => void; onCycleLoop: () => void; }) { const { t } = useTranslation(); const docked = variant === "docked"; // Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines. const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`; const divider = `h-4 w-px shrink-0 ${docked ? "bg-white/20" : "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; reason?: string; lit?: boolean; }; const step: Control[] = [ { key: "prev", glyph: , name: t("player.previous"), title: `${t("player.previous")} · Shift+←`, onClick: onPrev, disabled: !canPrev, reason: canPrev ? undefined : prevReason, }, { key: "next", glyph: , name: t("player.next"), title: `${t("player.next")} · Shift+→`, onClick: onNext, disabled: !canNext, reason: canNext ? undefined : nextReason, }, ]; // 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", }, { key: "loop", glyph: loopMode === "one" ? : , name: loopName, title: `${loopName} · ${t("player.loop.hint")}`, onClick: onCycleLoop, lit: loopMode !== "off", }, ]; const render = (c: Control) => ( {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 })}
); }