A session's marks are weighed per field against when each page of a never-refetched list was read: a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the server's word. The live search lays them over its rows too, not only the related list. Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go through features/video/videoWrites and mark the video; the contract test covers those writes and catches a write that is named without being called on the spot. The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's menu, a notice's Undo); Restart on the related row after a description link goes back to it. The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last two open-coded reads. Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both places render from one list of controls, so the docked tooltips name the mode too. The English cap message and admin hint quote "Show more"; the hint says refunds are limited.
204 lines
6.8 KiB
TypeScript
204 lines
6.8 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 { 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: <SkipBack />,
|
|
name: t("player.previous"),
|
|
title: `${t("player.previous")} · Shift+←`,
|
|
onClick: onPrev,
|
|
disabled: index === 0,
|
|
labelFrom: "sm",
|
|
},
|
|
{
|
|
key: "next",
|
|
glyph: <SkipForward />,
|
|
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" ? <ListStart /> : autoMode === "random" ? <Shuffle /> : <ListEnd />,
|
|
name: autoName,
|
|
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
|
|
onClick: onCycleAuto,
|
|
lit: autoMode !== "off",
|
|
labelFrom: "md",
|
|
},
|
|
{
|
|
key: "loop",
|
|
glyph: loopMode === "one" ? <Repeat1 /> : <Repeat />,
|
|
name: loopName,
|
|
title: `${loopName} · ${t("player.loop.hint")}`,
|
|
onClick: onCycleLoop,
|
|
lit: loopMode !== "off",
|
|
labelFrom: "md",
|
|
},
|
|
];
|
|
const render = (c: Control) =>
|
|
docked ? (
|
|
<Button
|
|
key={c.key}
|
|
variant="overlay"
|
|
size="sm"
|
|
icon={c.trailing ? undefined : c.glyph}
|
|
onClick={c.onClick}
|
|
disabled={c.disabled}
|
|
lit={c.lit}
|
|
label={c.name}
|
|
title={c.title}
|
|
>
|
|
<span className={c.labelFrom === "sm" ? "hidden sm:inline" : "hidden md:inline"}>
|
|
{c.name}
|
|
</span>
|
|
{c.trailing && c.glyph}
|
|
</Button>
|
|
) : (
|
|
<IconButton
|
|
key={c.key}
|
|
onClick={c.onClick}
|
|
disabled={c.disabled}
|
|
lit={c.lit}
|
|
label={c.name}
|
|
title={c.title}
|
|
>
|
|
{c.glyph}
|
|
</IconButton>
|
|
);
|
|
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>
|
|
);
|
|
}
|