Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
110 lines
4.0 KiB
TypeScript
110 lines
4.0 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
|
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
|
|
|
// 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 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>
|
|
</>
|
|
);
|
|
}
|