215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
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 (
|
|
<Tooltip hint={reason ?? ""} always={!!reason} side={side} stable>
|
|
{children}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/** 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: <SkipBack />,
|
|
name: t("player.previous"),
|
|
title: `${t("player.previous")} · Shift+←`,
|
|
onClick: onPrev,
|
|
disabled: !canPrev,
|
|
reason: canPrev ? undefined : prevReason,
|
|
},
|
|
{
|
|
key: "next",
|
|
glyph: <SkipForward />,
|
|
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" ? <ListStart /> : autoMode === "random" ? <Shuffle /> : <ListEnd />,
|
|
name: autoName,
|
|
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
|
|
onClick: onCycleAuto,
|
|
lit: autoMode !== "off",
|
|
},
|
|
{
|
|
key: "loop",
|
|
glyph: loopMode === "one" ? <Repeat1 /> : <Repeat />,
|
|
name: loopName,
|
|
title: `${loopName} · ${t("player.loop.hint")}`,
|
|
onClick: onCycleLoop,
|
|
lit: loopMode !== "off",
|
|
},
|
|
];
|
|
const render = (c: Control) => (
|
|
<ReasonTooltip key={c.key} reason={c.reason}>
|
|
<IconButton
|
|
variant={docked ? "overlay" : undefined}
|
|
onClick={c.onClick}
|
|
{...unavailable(c.disabled, c.reason)}
|
|
lit={c.lit}
|
|
label={c.name}
|
|
title={c.title}
|
|
>
|
|
{c.glyph}
|
|
</IconButton>
|
|
</ReasonTooltip>
|
|
);
|
|
return (
|
|
<>
|
|
{render(step[0]!)}
|
|
<span className={counter}>
|
|
{index + 1} / {queueLength}
|
|
</span>
|
|
{render(step[1]!)}
|
|
<span className={divider} />
|
|
{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<HTMLDivElement>;
|
|
clamp?: boolean;
|
|
}) {
|
|
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>;
|
|
if (clamp)
|
|
return (
|
|
<ClampedText
|
|
text={text}
|
|
lines={3}
|
|
moreLabel={t("player.descriptionMore")}
|
|
lessLabel={t("player.descriptionLess")}
|
|
textClassName={className}
|
|
>
|
|
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
|
</ClampedText>
|
|
);
|
|
return (
|
|
<div ref={ref} style={style} className={className}>
|
|
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
|
</div>
|
|
);
|
|
}
|