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 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.
|
||||
export default function Modal({
|
||||
title,
|
||||
|
||||
@@ -2,12 +2,18 @@ import { type ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} 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 PanelGroup from "./PanelGroup";
|
||||
|
||||
@@ -29,7 +35,12 @@ export default function PanelGroups({
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
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 orderedAvailable = layout.order.filter((id) => byId.has(id));
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { modalCount } from "./Modal";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import {
|
||||
@@ -375,6 +376,9 @@ export default function PlayerModal({
|
||||
if (e.key === "Escape") {
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
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.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -809,6 +810,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
wake(); // keyboard use also reveals the controls
|
||||
switch (e.key) {
|
||||
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":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
@@ -861,6 +868,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else handleBack();
|
||||
break;
|
||||
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();
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
@@ -1332,7 +1341,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
style={tracksFade.style}
|
||||
onClick={(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 && (
|
||||
<>
|
||||
@@ -1395,7 +1404,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
ref={menuRef}
|
||||
onWheel={(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">
|
||||
{settingsTabs.map((tab) => (
|
||||
|
||||
@@ -129,6 +129,27 @@ let items: Notification[] = load();
|
||||
let listeners: Array<() => void> = [];
|
||||
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
|
||||
// emits, so we recompute these only when `items` changes.
|
||||
let cachedActive: Notification[] = [];
|
||||
@@ -243,7 +264,7 @@ export function notify(input: NotifyInput): number {
|
||||
: n,
|
||||
);
|
||||
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.
|
||||
return prior.id;
|
||||
}
|
||||
@@ -275,12 +296,17 @@ export function notify(input: NotifyInput): number {
|
||||
) {
|
||||
beep();
|
||||
}
|
||||
if (duration) setTimeout(() => dismiss(id), duration);
|
||||
armDismiss(id, duration);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Close the transient toast surface; the entry stays in the center's history. */
|
||||
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));
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,19 @@ export function useDismiss(
|
||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||
};
|
||||
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("keydown", onKey);
|
||||
|
||||
Reference in New Issue
Block a user