Merge: R1 S2 — frontend interaction quick wins (5 fixes)
This commit is contained in:
@@ -8,6 +8,13 @@ import { useBackToClose } from "../lib/history";
|
|||||||
let modalStack: number[] = [];
|
let modalStack: number[] = [];
|
||||||
let nextModalId = 1;
|
let nextModalId = 1;
|
||||||
|
|
||||||
|
/** How many <Modal>s are currently open. The players (which use their own window-level Escape
|
||||||
|
* listeners, not this stack) check it so their Escape defers to a Modal opened above them —
|
||||||
|
* otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */
|
||||||
|
export function modalCount(): number {
|
||||||
|
return modalStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
|
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
|
||||||
export default function Modal({
|
export default function Modal({
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { type ReactNode } from "react";
|
|||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
import type { PanelLayout } from "../lib/panelLayout";
|
import type { PanelLayout } from "../lib/panelLayout";
|
||||||
import PanelGroup from "./PanelGroup";
|
import PanelGroup from "./PanelGroup";
|
||||||
|
|
||||||
@@ -29,7 +35,12 @@ export default function PanelGroups({
|
|||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
}) {
|
}) {
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
// The group grips are focusable and call themselves reorderable; without this only a pointer
|
||||||
|
// could actually move them (keyboard drag matched the other sortable lists).
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
const byId = new Map(items.map((it) => [it.id, it]));
|
const byId = new Map(items.map((it) => [it.id, it]));
|
||||||
|
|
||||||
const orderedAvailable = layout.order.filter((id) => byId.has(id));
|
const orderedAvailable = layout.order.filter((id) => byId.has(id));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import { modalCount } from "./Modal";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -375,6 +376,9 @@ export default function PlayerModal({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||||
if (document.fullscreenElement) return;
|
if (document.fullscreenElement) return;
|
||||||
|
// Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes
|
||||||
|
// it, ours would otherwise also fire and close the player underneath it (U-C).
|
||||||
|
if (modalCount() > 0) return;
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
|||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
import { modalCount } from "./Modal";
|
||||||
|
|
||||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
@@ -809,6 +810,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
wake(); // keyboard use also reveals the controls
|
wake(); // keyboard use also reveals the controls
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case " ":
|
case " ":
|
||||||
|
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
|
||||||
|
// playback when the focus isn't on a control — matching PlayerModal's guard.
|
||||||
|
if (tag === "BUTTON" || tag === "A") return;
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
case "k":
|
case "k":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePlay();
|
togglePlay();
|
||||||
@@ -861,6 +868,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
else handleBack();
|
else handleBack();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
|
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C).
|
||||||
|
if (modalCount() > 0) break;
|
||||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||||
else if (menuOpenRef.current) {
|
else if (menuOpenRef.current) {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -1332,7 +1341,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
style={tracksFade.style}
|
style={tracksFade.style}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
{detail.audio_streams.length > 1 && (
|
{detail.audio_streams.length > 1 && (
|
||||||
<>
|
<>
|
||||||
@@ -1395,7 +1404,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="glass-menu absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex gap-1">
|
<div className="mb-1.5 flex gap-1">
|
||||||
{settingsTabs.map((tab) => (
|
{settingsTabs.map((tab) => (
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ let items: Notification[] = load();
|
|||||||
let listeners: Array<() => void> = [];
|
let listeners: Array<() => void> = [];
|
||||||
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||||
|
|
||||||
|
// Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same
|
||||||
|
// toast) can CLEAR the previous timer first — otherwise the old one still fires at the original
|
||||||
|
// deadline and dismisses the re-surfaced toast early (C-3.25).
|
||||||
|
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function armDismiss(id: number, duration: number | undefined): void {
|
||||||
|
// Always clear any existing timer first. A falsy duration then arms nothing — so a toast that a
|
||||||
|
// coalesced repeat upgraded to requiresInteraction (duration undefined) correctly STAYS, instead
|
||||||
|
// of the original timer firing and dismissing the now-must-interact toast.
|
||||||
|
const existing = dismissTimers.get(id);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
dismissTimers.delete(id);
|
||||||
|
}
|
||||||
|
if (duration)
|
||||||
|
dismissTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismiss(id), duration),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
// Cached derived snapshots — useSyncExternalStore needs stable references between
|
||||||
// emits, so we recompute these only when `items` changes.
|
// emits, so we recompute these only when `items` changes.
|
||||||
let cachedActive: Notification[] = [];
|
let cachedActive: Notification[] = [];
|
||||||
@@ -243,7 +264,7 @@ export function notify(input: NotifyInput): number {
|
|||||||
: n,
|
: n,
|
||||||
);
|
);
|
||||||
emit();
|
emit();
|
||||||
if (duration) setTimeout(() => dismiss(prior.id), duration);
|
armDismiss(prior.id, duration); // clears the prior timer even when the repeat is now must-interact
|
||||||
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
|
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
|
||||||
return prior.id;
|
return prior.id;
|
||||||
}
|
}
|
||||||
@@ -275,12 +296,17 @@ export function notify(input: NotifyInput): number {
|
|||||||
) {
|
) {
|
||||||
beep();
|
beep();
|
||||||
}
|
}
|
||||||
if (duration) setTimeout(() => dismiss(id), duration);
|
armDismiss(id, duration);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the transient toast surface; the entry stays in the center's history. */
|
/** Close the transient toast surface; the entry stays in the center's history. */
|
||||||
export function dismiss(id: number): void {
|
export function dismiss(id: number): void {
|
||||||
|
const t = dismissTimers.get(id);
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t);
|
||||||
|
dismissTimers.delete(id);
|
||||||
|
}
|
||||||
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,19 @@ export function useDismiss(
|
|||||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
// Stop the same Escape from also reaching a player's window-level listener underneath —
|
||||||
|
// this document listener bubbles first, so one Escape closes only this popover, not the
|
||||||
|
// popover AND the player behind it. (U-C quick-fix.)
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (accepted, → R8 popover-platform): because this is a blanket
|
||||||
|
// stopPropagation, a PlexPlayer settings/tracks menu open DURING an auto-skip countdown
|
||||||
|
// swallows the Escape that would otherwise ALSO cancel the skip — Escape closes the menu,
|
||||||
|
// and the skip proceeds. Properly fixing this needs the shared layer-registry R8 builds
|
||||||
|
// (topmost-only dispatch), not a broad stopPropagation.
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", onDown);
|
document.addEventListener("mousedown", onDown);
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user