From f590be2bef67d8a7aebdda1c357ac956c7f8e149 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:39 +0200 Subject: [PATCH 1/4] fix(a11y): keyboard-reorder for panel groups (U-3.1.5) PanelGroups registered only a PointerSensor, so its focusable, "reorderable" group grips did nothing from the keyboard. Added KeyboardSensor + sortableKeyboardCoordinates, matching the other sortable lists (Playlists/PlexPlaylistView already had it). --- frontend/src/components/PanelGroups.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx index be4d270e..1b997d49 100644 --- a/frontend/src/components/PanelGroups.tsx +++ b/frontend/src/components/PanelGroups.tsx @@ -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)); From 94dfa6a34b0d51a8bc09137041d2080375a4e78f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:40 +0200 Subject: [PATCH 2/4] fix(player): Escape coordination, focused-Space, light-theme menus (U-C, U-3.1.4, U-3.3.12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one commit): - U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document listener bubbles before a player's window listener, so one Escape closes only the popover, not the popover AND the player). Modal exports modalCount(); both players' Escape defers when a Modal is open above them, so Escape closes the dialog, not the player under it and then the dialog. - U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro") instead of toggling playback — matching PlayerModal. - U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced text-white (white-on-white in light theme); they now use the player's own explicit dark panel (border-white/15 + bg-neutral-900/95), readable in both themes. --- frontend/src/components/Modal.tsx | 7 +++++++ frontend/src/components/PlayerModal.tsx | 4 ++++ frontend/src/components/PlexPlayer.tsx | 13 +++++++++++-- frontend/src/lib/useDismiss.ts | 8 +++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 85906b1d..99b5b6bb 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -8,6 +8,13 @@ import { useBackToClose } from "../lib/history"; let modalStack: number[] = []; let nextModalId = 1; +/** How many 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 ): backdrop + ESC + scroll-lock close. export default function Modal({ title, diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index e90e0716..1fc3d1d5 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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; } diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 8e9fccd4..f445d0d2 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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" >
{settingsTabs.map((tab) => ( diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts index 58c7fbe1..9845d2f7 100644 --- a/frontend/src/lib/useDismiss.ts +++ b/frontend/src/lib/useDismiss.ts @@ -17,7 +17,13 @@ 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.) + e.stopPropagation(); + onClose(); + } }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); From ed4b5e6413cb412452590073d574302449e8d7a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:29:40 +0200 Subject: [PATCH 3/4] fix(toast): clear a toast's auto-dismiss timer before re-arming it (C-3.25) A coalesced repeat re-surfaced the toast and armed a new dismiss timer without clearing the old one, so it vanished at the ORIGINAL deadline (6s not the expected 11s). Timers are now tracked in a Map and cleared before re-arming (and on manual dismiss). --- frontend/src/lib/notifications.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index dba09adf..0c318726 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -129,6 +129,20 @@ 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>(); + +function armDismiss(id: number, duration: number): void { + const existing = dismissTimers.get(id); + if (existing) clearTimeout(existing); + 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 +257,7 @@ export function notify(input: NotifyInput): number { : n, ); emit(); - if (duration) setTimeout(() => dismiss(prior.id), duration); + if (duration) armDismiss(prior.id, duration); // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet. return prior.id; } @@ -275,12 +289,17 @@ export function notify(input: NotifyInput): number { ) { beep(); } - if (duration) setTimeout(() => dismiss(id), duration); + if (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(); } From 4f7153bb7de862aa7f1d72b7c795b2eefd25d885 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 22:37:42 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(toast,overlay):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20close=20toast-timer=20edge,=20document=20Escape=20l?= =?UTF-8?q?imit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two low-severity items: - toast: armDismiss now always clears the prior timer (and arms only when duration is truthy), so a coalesced repeat that upgrades a toast to requiresInteraction no longer lets the original timer fire and dismiss it. Was a pre-existing gap the C-3.25 change hadn't closed. - overlay: documented the known limitation of useDismiss's blanket stopPropagation — a PlexPlayer menu open during an auto-skip countdown swallows the Escape that would also cancel the skip. Proper fix needs R8's shared layer-registry (topmost-only dispatch), not a broad stopPropagation; accepted until then. --- frontend/src/lib/notifications.ts | 23 +++++++++++++++-------- frontend/src/lib/useDismiss.ts | 6 ++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 0c318726..b57cc7c3 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -134,13 +134,20 @@ let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; // deadline and dismisses the re-surfaced toast early (C-3.25). const dismissTimers = new Map>(); -function armDismiss(id: number, duration: number): void { +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.set( - id, - setTimeout(() => dismiss(id), duration), - ); + if (existing) { + clearTimeout(existing); + dismissTimers.delete(id); + } + if (duration) + dismissTimers.set( + id, + setTimeout(() => dismiss(id), duration), + ); } // Cached derived snapshots — useSyncExternalStore needs stable references between @@ -257,7 +264,7 @@ export function notify(input: NotifyInput): number { : n, ); emit(); - if (duration) armDismiss(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; } @@ -289,7 +296,7 @@ export function notify(input: NotifyInput): number { ) { beep(); } - if (duration) armDismiss(id, duration); + armDismiss(id, duration); return id; } diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts index 9845d2f7..94b365bb 100644 --- a/frontend/src/lib/useDismiss.ts +++ b/frontend/src/lib/useDismiss.ts @@ -21,6 +21,12 @@ export function useDismiss( // 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(); }