From 6de2cd950d6a067ccdcab6da5987c5779ff21d2a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 03:18:55 +0200 Subject: [PATCH] fix(ui): toasts raised while a full-screen player is open show inside it, not under its scrim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's Toaster lives in the content column, which is a stacking context (its `@container` implies layout containment), so both full-screen players — portalled to at the same z-index — painted over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a click on it closed the player. An overlay now mounts its own ``, and exactly one host shows the toasts: the most recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there. Both players get one (the Plex player's also works in native fullscreen, where nothing outside the fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim, bottom-left, and clicking it leaves the player open. --- .../src/components/PlayerModal.dom.test.tsx | 17 ++++++ frontend/src/components/PlayerModal.tsx | 4 ++ frontend/src/components/PlexPlayer.tsx | 4 ++ .../src/components/ui/Toaster.dom.test.tsx | 61 +++++++++++++++++++ frontend/src/components/ui/Toaster.tsx | 35 ++++++++++- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/ui/Toaster.dom.test.tsx diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 201d86eb..194580a4 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -31,6 +31,7 @@ vi.mock("./DownloadButton", () => ({ default: () => null })); import PlayerModal from "./PlayerModal"; import { ConfirmProvider, useConfirm } from "./ConfirmProvider"; import { api, type Video } from "../lib/api"; +import { clearAll, notify } from "../lib/notifications"; beforeAll(() => { class Stub { @@ -249,3 +250,19 @@ describe("PlayerModal — removing the playing video from its playlist", () => { await waitFor(() => expect(document.activeElement).toBe(trash("A"))); }); }); + +describe("PlayerModal — toasts raised while it is open", () => { + afterEach(() => act(() => clearAll())); + + it("shows them inside the player, not under its scrim", async () => { + // The app's Toaster is in a stacking context below this body-level portal: a failed Remove + // reported by a toast was drawn under the 80% black scrim, i.e. still silent. + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + act(() => void notify({ level: "error", message: "Couldn't remove the video." })); + expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video."); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 216ee9d4..d0ac068c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -48,6 +48,7 @@ import { fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { closeThenNavigate, useBackToClose } from "../lib/history"; import ChannelNameLink from "./ChannelNameLink"; +import Toaster from "./ui/Toaster"; import { afterRemoval } from "../lib/playerQueue"; import { useNativeControlsYield, @@ -960,6 +961,9 @@ export default function PlayerModal({ )} + {/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own + Toaster is underneath this scrim. See ui/Toaster. */} + , document.body, ); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index e3cecf74..4d99f0bc 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -43,6 +43,7 @@ import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { PlexStatsOverlay } from "./PlexStatsOverlay"; import { createFragMeter } from "../lib/plexStats"; import { UpNextCard } from "./UpNextCard"; +import Toaster from "./ui/Toaster"; import { useAutoSkip, useControlsVisibility, @@ -1379,6 +1380,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )} + {/* The app's Toaster is underneath this full-screen player (and outside it in native + fullscreen), so toasts raised while watching show here instead. See ui/Toaster. */} + , document.body, ); diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx new file mode 100644 index 00000000..ab67ce1b --- /dev/null +++ b/frontend/src/components/ui/Toaster.dom.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +// +// Which Toaster shows the toasts. The app's own sits in the content column, which is a stacking +// context — so a full-screen player portalled to at the same z painted over every toast: a +// failed Remove raised while watching was drawn under the player's scrim, and clicking it closed +// the player. An overlay mounts its own host, and exactly one host shows the toasts. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +import Toaster from "./Toaster"; +import { clearAll, notify } from "../../lib/notifications"; + +afterEach(() => { + cleanup(); + act(() => clearAll()); +}); + +const hosts = () => Array.from(document.querySelectorAll("[aria-live='polite']")); + +describe("Toaster — one host shows the toasts", () => { + it("moves the toasts into an open overlay, and back when it closes", () => { + const app = render( +
+ +
, + ); + act(() => void notify({ level: "error", message: "Couldn't remove the video." })); + expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video."); + + const overlay = render( +
+ +
, + ); + expect(screen.getByTestId("overlay").textContent).toContain("Couldn't remove the video."); + expect(screen.getByTestId("app").textContent).not.toContain("Couldn't remove the video."); + // Both live regions stay mounted — a polite region has to pre-exist to be announced. + expect(hosts()).toHaveLength(2); + + overlay.unmount(); + expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video."); + app.unmount(); + }); + + it("does not let a click on a toast reach the overlay's backdrop", () => { + const backdrop = vi.fn(); + render( +
+ +
, + ); + act(() => void notify({ level: "error", message: "boom" })); + fireEvent.click(screen.getByText("boom")); + expect(backdrop).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx index aa834082..2720feeb 100644 --- a/frontend/src/components/ui/Toaster.tsx +++ b/frontend/src/components/ui/Toaster.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { useEffect, useId, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications"; @@ -11,9 +11,36 @@ export const LEVEL_STYLE: Record at the same z-overlay, painted over every toast: a failure raised while watching was +// drawn under an 80% black, blurred scrim, and a click on it closed the player. Such an overlay +// mounts its OWN `` inside itself instead, and exactly one host shows the +// toasts: the most recently mounted overlay, else the app's. Same component, same look — and inside +// the overlay's aria-modal dialog, where a screen reader can still reach it. +let overlayHosts: string[] = []; +const hostListeners = new Set<() => void>(); +const subscribeHosts = (l: () => void) => { + hostListeners.add(l); + return () => void hostListeners.delete(l); +}; +const topHost = () => overlayHosts[overlayHosts.length - 1] ?? null; +const setHosts = (next: string[]) => { + overlayHosts = next; + hostListeners.forEach((l) => l()); +}; + +export default function Toaster({ overlay = false }: { overlay?: boolean }) { const { t } = useTranslation(); - const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); + const id = useId(); + useEffect(() => { + if (!overlay) return; + setHosts([...overlayHosts, id]); + return () => setHosts(overlayHosts.filter((h) => h !== id)); + }, [overlay, id]); + const top = useSyncExternalStore(subscribeHosts, topHost, topHost); + const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); + const toasts = top === (overlay ? id : null) ? active : []; return ( // Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a @@ -25,6 +52,8 @@ export default function Toaster() {
e.stopPropagation() : undefined} > {toasts.map((toast) => { const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];