fix(ui): toasts raised while a full-screen player is open show inside it, not under its scrim

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 <body> 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 `<Toaster overlay />`, 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.
This commit is contained in:
2026-09-11 03:18:55 +02:00
parent 645d14f45d
commit 6de2cd950d
5 changed files with 118 additions and 3 deletions
@@ -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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
});
});
+4
View File
@@ -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({
</button>
)}
</div>
{/* 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. */}
<Toaster overlay />
</div>,
document.body,
);
+4
View File
@@ -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) {
</div>
</div>
)}
{/* 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. */}
<Toaster overlay />
</div>,
document.body,
);
@@ -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 <body> 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<typeof import("react-i18next")>();
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(
<div data-testid="app">
<Toaster />
</div>,
);
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(
<div data-testid="overlay">
<Toaster overlay />
</div>,
);
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(
<div onClick={backdrop}>
<Toaster overlay />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled();
});
});
+32 -3
View File
@@ -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<NotifLevel, { icon: typeof Info; color: string;
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
};
export default function Toaster() {
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
// context (its `@container` implies layout containment) — so a full-screen player, portalled to
// <body> 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 `<Toaster overlay />` 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() {
<div
aria-live="polite"
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined}
>
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];