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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user