Merge: R1 S2 — frontend interaction quick wins (5 fixes)

This commit is contained in:
2026-07-21 22:58:53 +02:00
6 changed files with 76 additions and 7 deletions
+7
View File
@@ -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,
+13 -2
View File
@@ -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));
+4
View File
@@ -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;
} }
+11 -2
View File
@@ -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) => (
+28 -2
View File
@@ -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();
} }
+13 -1
View File
@@ -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);