fix(plex): the player's ⋯ and tracks lists are real menus, and every close returns the focus
They were hand-built divs: no menu roles, no arrow keys, and Escape left the focus on <body>. Both run on the popover engine now (media-tone rows), and the tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
This commit is contained in:
@@ -82,7 +82,8 @@ import {
|
||||
} from "../lib/layerStack";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Slider } from "./ui/NativeControls";
|
||||
import { formatUnit } from "../lib/format";
|
||||
|
||||
@@ -104,6 +105,10 @@ const RECLAIM_WINDOW_MS = 120_000;
|
||||
const BACK_TO_PREV_S = 3;
|
||||
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
|
||||
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
||||
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
||||
const PLAYER_MENU =
|
||||
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
|
||||
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||||
const TAP_SKIP_S = 10;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
@@ -236,7 +241,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
|
||||
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
||||
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||
"sync",
|
||||
@@ -244,9 +248,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const audioRef = useRef<number | null>(null);
|
||||
const menuOpenRef = useRef(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement | null>(null);
|
||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||
const {
|
||||
@@ -265,21 +267,40 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
onPointerEnter: () => holdOnHover(true),
|
||||
onPointerLeave: () => holdOnHover(false),
|
||||
};
|
||||
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||
// The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
|
||||
// what closes it (below).
|
||||
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
|
||||
useDismiss(moreOpen, () => setMoreOpen(false), [moreRef, menuTriggerRef]);
|
||||
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
|
||||
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
|
||||
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
|
||||
const tracksFade = useScrollFade();
|
||||
const tracksFadeRef = tracksFade.ref;
|
||||
const setTracksNode = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
tracksRef.current = node as HTMLDivElement | null;
|
||||
tracksFadeRef(node);
|
||||
// The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
|
||||
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
|
||||
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
|
||||
// them into the fullscreen element when there is one, so they show in fullscreen too.
|
||||
const morePop = useAnchoredPopover({
|
||||
open: moreOpen,
|
||||
// The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it.
|
||||
onOpenChange: (open) => {
|
||||
if (open && menuOpen) setMenuOpen(false);
|
||||
else setMoreOpen(open);
|
||||
},
|
||||
[tracksFadeRef],
|
||||
placement: "top-end",
|
||||
offset: 8,
|
||||
});
|
||||
const tracksPop = useAnchoredPopover({
|
||||
open: tracksOpen,
|
||||
onOpenChange: setTracksOpen,
|
||||
placement: "top-end",
|
||||
offset: 8,
|
||||
fitHeight: true,
|
||||
});
|
||||
const tracksFade = useScrollFade();
|
||||
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||||
const setMoreReference = morePop.refs.setReference;
|
||||
const moreTriggerRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
menuTriggerRef.current = node;
|
||||
setMoreReference(node);
|
||||
},
|
||||
[setMoreReference],
|
||||
);
|
||||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
@@ -1593,74 +1614,89 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
|
||||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
||||
a track keeps it open: the change is seen where it was made, and the other group is
|
||||
one arrow away. */}
|
||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||
<div className="relative">
|
||||
<>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={tracksTriggerRef}
|
||||
onClick={() => setTracksOpen((o) => !o)}
|
||||
ref={tracksPop.refs.setReference}
|
||||
{...tracksPop.getReferenceProps()}
|
||||
aria-expanded={tracksOpen}
|
||||
label={t("plex.player.tracks")}
|
||||
>
|
||||
<Languages />
|
||||
</IconButton>
|
||||
{tracksOpen && (
|
||||
<PopoverSurface
|
||||
popover={tracksPop}
|
||||
ariaLabel={t("plex.player.tracks")}
|
||||
className={`${PLAYER_MENU} w-56`}
|
||||
>
|
||||
{/* Portaled, but a React child of the player: a click or a wheel here must not
|
||||
reach the stage's play-toggle and volume handlers. */}
|
||||
<div
|
||||
ref={setTracksNode}
|
||||
style={tracksFade.style}
|
||||
ref={tracksFade.ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||
style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
|
||||
className="overflow-auto no-scrollbar p-2"
|
||||
>
|
||||
{detail.audio_streams.length > 1 && (
|
||||
<>
|
||||
<div role="group" aria-label={t("plex.player.audio")}>
|
||||
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||||
{detail.audio_streams.map((a) => (
|
||||
<Pressable
|
||||
<PopoverItem
|
||||
key={a.ord}
|
||||
onClick={() => changeAudio(a.ord)}
|
||||
aria-pressed={(audioOrd ?? 0) === a.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||||
tone="media"
|
||||
role="menuitemradio"
|
||||
checked={(audioOrd ?? 0) === a.ord}
|
||||
label={a.label}
|
||||
onSelect={() => changeAudio(a.ord)}
|
||||
className={`${PLAYER_ROW} truncate ${
|
||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</Pressable>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{textSubs.length > 0 && (
|
||||
<>
|
||||
<div role="group" aria-label={t("plex.player.subtitles")}>
|
||||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||
<Pressable
|
||||
onClick={() => changeSubtitle(null)}
|
||||
aria-pressed={subOrd == null}
|
||||
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
|
||||
subOrd == null ? "text-accent" : "text-white"
|
||||
}`}
|
||||
<PopoverItem
|
||||
tone="media"
|
||||
role="menuitemradio"
|
||||
checked={subOrd == null}
|
||||
label={t("plex.player.subOff")}
|
||||
onSelect={() => changeSubtitle(null)}
|
||||
className={`${PLAYER_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
|
||||
>
|
||||
{t("plex.player.subOff")}
|
||||
</Pressable>
|
||||
{textSubs.map((s) => (
|
||||
<Pressable
|
||||
key={s.ord}
|
||||
onClick={() => changeSubtitle(s.ord)}
|
||||
aria-pressed={subOrd === s.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
||||
subOrd === s.ord ? "text-accent" : "text-white"
|
||||
</PopoverItem>
|
||||
{textSubs.map((sub) => (
|
||||
<PopoverItem
|
||||
key={sub.ord}
|
||||
tone="media"
|
||||
role="menuitemradio"
|
||||
checked={subOrd === sub.ord}
|
||||
label={sub.label}
|
||||
onSelect={() => changeSubtitle(sub.ord)}
|
||||
className={`${PLAYER_ROW} truncate ${
|
||||
subOrd === sub.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</Pressable>
|
||||
{sub.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
||||
@@ -1670,40 +1706,39 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={menuTriggerRef}
|
||||
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
|
||||
ref={moreTriggerRef}
|
||||
{...morePop.getReferenceProps()}
|
||||
aria-expanded={moreOpen || menuOpen}
|
||||
label={t("plex.player.more")}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</IconButton>
|
||||
{moreOpen && (
|
||||
<div
|
||||
ref={moreRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 flex w-60 flex-col rounded-xl p-1.5 text-sm text-white shadow-2xl"
|
||||
>
|
||||
<PopoverSurface
|
||||
popover={morePop}
|
||||
ariaLabel={t("plex.player.more")}
|
||||
className={`${PLAYER_MENU} w-60 p-1.5`}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
|
||||
{moreItems.map((it) => (
|
||||
<Pressable
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
// Back on the ⋯ before the tool opens: its row is about to unmount, and a
|
||||
// dialog opened from here returns the focus to where it found it.
|
||||
menuTriggerRef.current?.focus();
|
||||
it.run();
|
||||
}}
|
||||
className="touch-target flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left hover:bg-white/10"
|
||||
tone="media"
|
||||
label={it.label}
|
||||
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
|
||||
// opened from here returns the focus to where it found it.
|
||||
onSelect={() => closeThenAct(() => setMoreOpen(false), it.run)}
|
||||
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
|
||||
>
|
||||
{it.icon}
|
||||
{it.label}
|
||||
</Pressable>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
returnFocusTo={menuTriggerRef}
|
||||
settingsTabs={settingsTabs}
|
||||
activeTab={activeTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
|
||||
Reference in New Issue
Block a user