feat(player): clip mode - mark a range of what is playing and cut it into a download

Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
2026-10-10 03:58:27 +02:00
parent f221c1962a
commit 50157cc2fd
28 changed files with 2121 additions and 602 deletions
+366 -284
View File
@@ -29,6 +29,7 @@ import {
MoreHorizontal,
Pause,
Play,
Scissors,
Settings,
SkipBack,
SkipForward,
@@ -48,6 +49,8 @@ import {
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import ClipPanel from "./ClipPanel";
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
@@ -288,8 +291,31 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
);
// keep the control bar up while a menu is open
const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings");
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
// next item starts without one (reset against the item it was opened on).
const [clip, setClip] = useState<ClipRange | null>(null);
const [clipFor, setClipFor] = useState(id);
if (clipFor !== id) {
setClipFor(id);
setClip(null);
}
const clipRef = useLatest(clip);
// The scissors that opened the panel is gone while it is open; closing it gives the focus back.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const closeClip = useCallback(() => {
refocusClip.current = true;
setClip(null);
}, []);
useEffect(() => {
if (clip != null || !refocusClip.current) return;
refocusClip.current = false;
clipButtonRef.current?.focus();
}, [clip]);
// keep the control bar up while a menu — or the clip panel — is open
const menuOpenRef = useLatest(
panel === "more" || panel === "tracks" || panel === "settings" || clip != null,
);
const menuRef = useRef<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
@@ -1006,6 +1032,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
info: () => togglePanel("info"),
stats: () => setStatsOpen((v) => !v),
clip: () => {
if (clipRef.current) closeClip();
else setClip(openAt(absRef.current, durationRef.current));
},
markIn: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markIn(c ?? openAt(t, d), t, d));
},
markOut: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markOut(c ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d));
},
back: (ev) => {
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
// Back button (shared cascade).
@@ -1018,11 +1058,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `back`'s cascade is
// what closes them; anything else layered above us takes the key.
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
const ownPanelOpen =
panelRef.current !== "none" || statsOpenRef.current || clipRef.current != null;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
back("key");
// The clip panel is the player's own too, and closes before the player does.
if (clipRef.current) closeClip();
else back("key");
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
@@ -1032,7 +1075,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
back("escape");
if (clipRef.current) closeClip();
else back("escape");
},
});
};
@@ -1063,6 +1107,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
fineSeekStepRef,
panelRef,
statsOpenRef,
clipRef,
closeClip,
absRef,
durationRef,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
@@ -1345,309 +1393,343 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
: "opacity-0 pointer-events-none"
}`}
>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
{/* Clip mode: the clip panel takes the seek bar's and the control row's place (it has a
timeline of its own). Its seeks wait for the release: a seek may restart the transcode. */}
{clip ? (
<div className="overflow-hidden rounded-xl">
<ClipPanel
src={{ source: "plex", ref: id, audioTrack: audioOrd ?? 0 }}
clip={clip}
onChange={setClip}
duration={duration}
current={abs}
onSeek={(time, final) => {
if (final) seekTo(time);
}}
onPlayFrom={(time) => seekTo(time, true)}
onClose={closeClip}
/>
</div>
) : (
<>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. The track stays thin
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
makes a drag scrub instead of panning the page. */}
<div
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}}
onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
const tt = frac * duration;
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(null);
}}
onMouseLeave={() => setHover(null)}
>
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}}
onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
const tt = frac * duration;
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(null);
}}
onMouseLeave={() => setHover(null)}
>
{fmt(hover.t)}
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
>
{fmt(hover.t)}
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
touch double-tap are). */}
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && go(nextId)}
disabled={!nextId}
>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev}
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && go(nextId)}
disabled={!nextId}
>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
</Ctrl>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
none of them. The level shows above it while hovered. A pointer drag hands the focus
back so Space and the arrows keep driving the player, not the slider. */}
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
row has to fit a portrait screen. */}
<div
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
<div
className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
pushes later while paused, since the remaining time then rides the advancing clock. */}
{duration > 0 && (
<span className="ml-1.5 text-white/45">
(
{
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
.time
}
)
</span>
)}
</div>
{duration > 0 && (
<span className="ml-1.5 text-white/45">
(
{
clockParts(
new Date(now.getTime() + Math.max(0, duration - abs) * 1000),
prefs,
).time
}
)
</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
<div className="ml-auto flex items-center gap-2">
{/* 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) && (
<>
<IconButton
variant="overlay"
size="player"
round
ref={setTracksReference}
{...tracksPop.getReferenceProps()}
aria-expanded={panel === "tracks"}
label={t("plex.player.tracks")}
>
<Languages />
</IconButton>
<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={tracksFadeRef}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...tracksFadeStyle, 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) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<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) => (
<PopoverItem
key={it.id}
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(() => closePanel("more"), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<>
<IconButton
variant="overlay"
size="player"
round
ref={setTracksReference}
{...tracksPop.getReferenceProps()}
aria-expanded={panel === "tracks"}
label={t("plex.player.tracks")}
>
{it.icon}
{it.label}
</PopoverItem>
))}
<Languages />
</IconButton>
<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={tracksFadeRef}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...tracksFadeStyle, 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) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* Clip mode (`c`): mark a range of what is playing and cut it into a download. */}
{duration > 0 && (
<Ctrl
label={t("clip.open")}
onClick={() => setClip(openAt(abs, duration))}
buttonRef={clipButtonRef}
>
<Scissors className="w-5 h-5" />
</Ctrl>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<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) => (
<PopoverItem
key={it.id}
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(() => closePanel("more"), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
>
{it.icon}
{it.label}
</PopoverItem>
))}
</div>
</PopoverSurface>
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
</PopoverSurface>
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
did nothing there, and the windowed player already fills the window. */}
{canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
{canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl>
)}
</div>
</div>
</>
)}
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —