Mirror the Plex player split (plexPlayerHooks.ts / plexPlayerControls.tsx). The shell
keeps owning the shared refs and passes them into each hook; the once-bound keyboard
handler + layer-stack registration stay in the shell (they orchestrate every cluster).
Behaviour-preserving code-motion — PlayerModal 1332 -> 890 lines.
New lib/playerModalHooks.ts:
- useYtVolume (per-account wheel/arrow volume, on shared playerVolume.ts)
- useNativeControlsYield (iframe-focus overlay yield)
- useYtBigView (F-maximise + our true-fullscreen; YT-specific, not Plex's useFullscreen)
- useYtProgress (player create + resume + 5s checkpoint + auto-watch + auto-advance)
New components/playerModalControls.tsx: PlayerTransportControls (moved as-is) + the
AutoMode/LoopMode types.
Self-E2E (localdev): play a feed video -> plays; transport bar (Auto/Loop); wheel volume
persists ({"volume":100}); F maximises + docked bar; exit big view; Close destroys the
player. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.
Noted a pre-existing bug (kept byte-identical here, flagged separately): the native-yield
effect removes a "blur-sm" listener but adds "blur" — the blur listener leaks.
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
|
|
|
// 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.
|
|
|
|
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video
|
|
// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or
|
|
// neither ("off"). Both apply to any queued player (feed or playlist).
|
|
export type AutoMode = "off" | "next" | "prev" | "random";
|
|
export type LoopMode = "off" | "one" | "all";
|
|
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
|
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
|
|
|
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
|
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
|
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
|
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
|
* so a mode/icon change lands once. 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 navBtn = docked
|
|
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
|
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
|
const navLabel = docked ? "hidden sm:inline" : "";
|
|
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 toggle = (on: boolean) =>
|
|
docked
|
|
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
|
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
|
}`
|
|
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
|
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
|
}`;
|
|
const toggleLabel = docked ? "hidden md:inline" : "";
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={onPrev}
|
|
disabled={index === 0}
|
|
title={`${t("player.previous")} · Shift+←`}
|
|
className={navBtn}
|
|
>
|
|
<SkipBack className="h-4 w-4" />
|
|
<span className={navLabel}>{t("player.previous")}</span>
|
|
</button>
|
|
<span className={counter}>
|
|
{index + 1} / {queueLength}
|
|
</span>
|
|
<button
|
|
onClick={onNext}
|
|
disabled={index === queueLength - 1}
|
|
title={`${t("player.next")} · Shift+→`}
|
|
className={navBtn}
|
|
>
|
|
<span className={navLabel}>{t("player.next")}</span>
|
|
<SkipForward className="h-4 w-4" />
|
|
</button>
|
|
<span className={divider} />
|
|
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
|
<button
|
|
onClick={onCycleAuto}
|
|
title={t("player.autoAdvance.hint")}
|
|
className={toggle(autoMode !== "off")}
|
|
>
|
|
{autoMode === "prev" ? (
|
|
<SkipBack className="h-3.5 w-3.5" />
|
|
) : autoMode === "random" ? (
|
|
<Shuffle className="h-3.5 w-3.5" />
|
|
) : (
|
|
<SkipForward className="h-3.5 w-3.5" />
|
|
)}
|
|
<span className={toggleLabel}>
|
|
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={onCycleLoop}
|
|
title={t("player.loop.hint")}
|
|
className={toggle(loopMode !== "off")}
|
|
>
|
|
{loopMode === "one" ? (
|
|
<Repeat1 className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Repeat className="h-3.5 w-3.5" />
|
|
)}
|
|
<span className={toggleLabel}>
|
|
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
|
</span>
|
|
</button>
|
|
</>
|
|
);
|
|
}
|