feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls, the hide timer starts with playback, a double-tap on a side third skips 10 s, the seek bar is a 2.75rem touch-none hit area with its head shown on touch. The row is play/pause, Back by position, Next only with a next item, mute, time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download) and fullscreen; Stop, Restart and the ±10 s buttons are gone. A started card shows the video card's Continue + Restart pair (one shared MediaResumeButtons, beside the picture's button), and its menu lists the restart; the player opens that item at 0 (fromStart). YouTube modal: the description opens from an ⓘ, the queue arrows sit inside the screen.
This commit is contained in:
@@ -17,13 +17,12 @@ import {
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
subShadowCss,
|
||||
type PlexPlayerPrefs,
|
||||
} from "../lib/plexPlayerPrefs";
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { createFragMeter } from "../lib/plexStats";
|
||||
@@ -100,6 +99,13 @@ const MAX_SESSION_RECLAIMS = 2;
|
||||
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
|
||||
const RECLAIM_WINDOW_MS = 120_000;
|
||||
|
||||
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
|
||||
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).
|
||||
const TAP_SKIP_S = 10;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||
@@ -108,9 +114,11 @@ const RECLAIM_WINDOW_MS = 120_000;
|
||||
|
||||
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
|
||||
// the queue instead of the item's own episode neighbours.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||
// `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it
|
||||
// moves on to starts at its own resume point.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean };
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
||||
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
|
||||
@@ -224,7 +232,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||
const aoffRef = useRef(prefs.audioOffset);
|
||||
aoffRef.current = prefs.audioOffset;
|
||||
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
|
||||
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",
|
||||
@@ -235,9 +246,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tracksRef = useRef<HTMLDivElement | null>(null);
|
||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
||||
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 { uiVisible, wake, holdOnHover } = useControlsVisibility({
|
||||
const {
|
||||
uiVisible,
|
||||
wake,
|
||||
holdOnHover,
|
||||
toggle: toggleControls,
|
||||
show: showControls,
|
||||
} = useControlsVisibility({
|
||||
barAutoHideRef,
|
||||
menuOpenRef,
|
||||
videoRef,
|
||||
@@ -250,6 +267,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||
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.
|
||||
@@ -572,6 +590,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// the cache when the player opened is not corrected by the refetch that follows; the player's own
|
||||
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
|
||||
const startedForRef = useRef<string | null>(null);
|
||||
// Raised by the first `go()`: `fromStart` belongs to the item the player opened on, and coming BACK
|
||||
// to that item (Back within 3 s of the next) resumes it like any other. A flag, not a consumed
|
||||
// prop: StrictMode replays this effect, and a consumed one resumed on the replay.
|
||||
const movedOnRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!detail || detail.id !== id || startedForRef.current === id) return;
|
||||
startedForRef.current = id;
|
||||
@@ -585,9 +607,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
audioRef.current = aOrd;
|
||||
setAudioOrd(aOrd);
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
|
||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||
const resume =
|
||||
detail.status !== "watched" && !(fromStart && !movedOnRef.current)
|
||||
? detail.position_seconds || 0
|
||||
: 0;
|
||||
void loadSession(resume);
|
||||
}, [detail, id, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -785,6 +810,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// reset below and would otherwise hand the old position to the new item.
|
||||
settleProgress();
|
||||
clockLiveRef.current = false;
|
||||
movedOnRef.current = true;
|
||||
setAbs(0);
|
||||
setId(otherId);
|
||||
}
|
||||
@@ -801,6 +827,55 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
: null
|
||||
: detail?.next_id;
|
||||
|
||||
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
|
||||
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
|
||||
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
|
||||
const backOrPrev = useCallback(() => {
|
||||
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
|
||||
else seekTo(0);
|
||||
}, [prevId, go, seekTo]);
|
||||
|
||||
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
|
||||
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
|
||||
// Kept from `pointerdown`, because the `click` that follows is not a PointerEvent everywhere.
|
||||
const pointerRef = useRef("mouse");
|
||||
// The picture's taps: one shows/hides the controls; a second within DOUBLE_TAP_MS on the left or
|
||||
// right third skips ∓TAP_SKIP_S, as does each further one (C5), and takes back the first tap's
|
||||
// show/hide. The middle third has no skip, so a quick second tap there just toggles again.
|
||||
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
|
||||
null,
|
||||
);
|
||||
const onPictureClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (pointerRef.current !== "touch") {
|
||||
togglePlay();
|
||||
return;
|
||||
}
|
||||
// The wrapper's own click wakes the controls, which would undo a hide.
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const x = r.width > 0 ? (e.clientX - r.left) / r.width : 0.5;
|
||||
const zone = x < 1 / 3 ? -1 : x > 2 / 3 ? 1 : 0;
|
||||
const now = performance.now();
|
||||
const prev = tapRef.current;
|
||||
if (zone !== 0 && prev && prev.zone === zone && now - prev.at < DOUBLE_TAP_MS) {
|
||||
if (prev.skips === 0) showControls(prev.wasShown);
|
||||
const skips = prev.skips + 1;
|
||||
tapRef.current = { ...prev, at: now, skips };
|
||||
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
|
||||
flashOsd(
|
||||
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
|
||||
n: skips * TAP_SKIP_S,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
|
||||
toggleControls();
|
||||
},
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
|
||||
);
|
||||
|
||||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||||
// earlier keyboard/back handlers that cancel a running countdown.
|
||||
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
|
||||
@@ -837,6 +912,45 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
];
|
||||
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
||||
|
||||
// The ⋯ menu's rows (C5), in the lab's order. Settings and the media info need the item's detail.
|
||||
const ico = "h-4 w-4 shrink-0";
|
||||
const moreItems = [
|
||||
...(detail
|
||||
? [
|
||||
{
|
||||
id: "settings",
|
||||
label: t("plex.player.settings"),
|
||||
icon: <Settings className={ico} />,
|
||||
run: () => setMenuOpen(true),
|
||||
},
|
||||
{
|
||||
id: "info",
|
||||
label: t("plex.player.infoBtn"),
|
||||
icon: <Info className={ico} />,
|
||||
run: () => setInfoOpen((v) => !v),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "stats",
|
||||
label: t("plex.player.stats.title"),
|
||||
icon: <Gauge className={ico} />,
|
||||
run: () => setStatsOpen((v) => !v),
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: t("plex.player.help"),
|
||||
icon: <Keyboard className={ico} />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
label: t("plex.player.download"),
|
||||
icon: <Download className={ico} />,
|
||||
run: download,
|
||||
},
|
||||
];
|
||||
|
||||
// Auto-advance to the next episode when one finishes.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -887,6 +1001,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
setMoreOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
@@ -1030,6 +1145,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
setMoreOpen(false);
|
||||
} else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
@@ -1086,7 +1202,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
|
||||
onMouseMove={wake}
|
||||
onPointerDown={(e) => {
|
||||
pointerRef.current = e.pointerType;
|
||||
}}
|
||||
// A pointer MOVE wakes the bar; a touch has none of its own, and the mouse events a browser
|
||||
// fakes after a tap would show the bar the tap just hid.
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== "touch") wake();
|
||||
}}
|
||||
onClick={wake}
|
||||
onWheel={onWheel}
|
||||
// Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid
|
||||
@@ -1129,8 +1252,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Fill the wrapper (which is sized to the viewport / screen) and letterbox by aspect via
|
||||
// object-contain. NOT `w-auto h-auto max-*`: that caps the video at its intrinsic resolution
|
||||
// (a 720p stream → 1280×720) and never scales it UP, so in fullscreen it sat tiny and centred.
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
// `touch-manipulation`: a double-tap is the skip, never the browser's zoom.
|
||||
className="w-full h-full object-contain touch-manipulation"
|
||||
onClick={onPictureClick}
|
||||
playsInline
|
||||
>
|
||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||
@@ -1234,7 +1358,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<div
|
||||
{...holdWhileHovered}
|
||||
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0"
|
||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
@@ -1311,14 +1435,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{/* Bottom controls */}
|
||||
<div
|
||||
{...holdWhileHovered}
|
||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* 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. */}
|
||||
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="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
|
||||
className="group relative flex h-11 cursor-pointer touch-none items-center"
|
||||
onPointerDown={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
try {
|
||||
@@ -1337,11 +1463,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setHover({ frac, t: tt });
|
||||
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
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)}
|
||||
>
|
||||
@@ -1354,65 +1486,56 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
{fmt(hover.t)}
|
||||
</div>
|
||||
)}
|
||||
<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 className="relative h-1.5 w-full rounded-full bg-white/25">
|
||||
<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}%`,
|
||||
}}
|
||||
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
|
||||
style={{ width: `${bufPct}%` }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||||
style={{ width: `${headPct}%` }}
|
||||
/>
|
||||
<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"
|
||||
}`}
|
||||
style={{ left: `${headPct}%` }}
|
||||
/>
|
||||
{/* 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
|
||||
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.rewind10")}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
|
||||
align="start"
|
||||
>
|
||||
<Skip10 dir="back" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
<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={t("plex.player.forward10")}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
|
||||
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
|
||||
onClick={backOrPrev}
|
||||
>
|
||||
<Skip10 dir="forward" />
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
||||
<Square className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{(detail?.kind === "episode" || queue) && (
|
||||
<>
|
||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
</>
|
||||
{nextId && (
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1427,8 +1550,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
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
|
||||
row has to fit a portrait screen. */}
|
||||
<div
|
||||
className="relative w-24"
|
||||
className="relative w-24 [@media(hover:none)]:hidden"
|
||||
onPointerEnter={() => setVolHover(true)}
|
||||
onPointerLeave={() => setVolHover(false)}
|
||||
>
|
||||
@@ -1449,7 +1574,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs tabular-nums text-white/90">
|
||||
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
|
||||
{fmt(abs)} / {fmt(duration)}
|
||||
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
||||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
||||
@@ -1537,46 +1662,57 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||||
{detail && (
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={menuTriggerRef}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-expanded={menuOpen}
|
||||
label={t("plex.player.settings")}
|
||||
{/* 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={menuTriggerRef}
|
||||
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
|
||||
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"
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
settingsTabs={settingsTabs}
|
||||
activeTab={activeTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
prefs={prefs}
|
||||
patchPrefs={patchPrefs}
|
||||
changeAudioOffset={changeAudioOffset}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
<Download className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{moreItems.map((it) => (
|
||||
<Pressable
|
||||
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"
|
||||
>
|
||||
{it.icon}
|
||||
{it.label}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{menuOpen && (
|
||||
<PlexPlayerSettings
|
||||
menuRef={menuRef}
|
||||
settingsTabs={settingsTabs}
|
||||
activeTab={activeTab}
|
||||
setSettingsTab={setSettingsTab}
|
||||
prefs={prefs}
|
||||
patchPrefs={patchPrefs}
|
||||
changeAudioOffset={changeAudioOffset}
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||
</Ctrl>
|
||||
|
||||
Reference in New Issue
Block a user