Files
siftlode/frontend/src/features/player/playerModalControls.tsx
T
peter 15a3603513 fix(player): a step onto an unplayable video carries on the way it went
Previous onto an embed-blocked or removed video skipped forward again (the
auto-advance direction), straight back to the item just left. The player now
remembers the direction of the step that reached the item and the videos
YouTube refused this session: an error carries on that way, Previous / Next
pass over known-dead items without loading them, and go dark when only those
are left. A step of your own skips with auto-advance off too.
2026-11-01 01:14:50 +01:00

209 lines
7.0 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,
canPrev,
canNext,
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;
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: !canPrev,
labelFrom: "sm",
},
{
key: "next",
glyph: <SkipForward />,
name: t("player.next"),
title: `${t("player.next")} · Shift+→`,
onClick: onNext,
disabled: !canNext,
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>
);
}