import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
// 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 anything portalled to
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. So every full-screen overlay mounts its
// OWN `` inside itself — ui/Modal does it for every dialog, the players
// for themselves — and exactly one host shows the toasts: the one of the overlay OPENED last, else
// the app's. Same component, same look — and inside the overlay's aria-modal dialog, where a screen
// reader can still reach it.
//
// "Opened last", not "host mounted last": a host can remount while its overlay stays open —
// PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on
// top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the
// toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its
// rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders.
type Host = { id: string; order: number };
const overlayHosts = createStore([]);
const topHost = () => {
let top: Host | null = null;
for (const h of overlayHosts.get()) if (!top || h.order >= top.order) top = h;
return top?.id ?? null;
};
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
let lastTopIds: number[] = [];
const EMPTY_IDS: ReadonlySet = new Set();
export default function Toaster({
overlay = false,
order,
}: {
overlay?: boolean;
/** The owning overlay's `useOverlayOrder()`. Without one, the host ranks by its own mount. */
order?: number;
}) {
const { t } = useTranslation();
const id = useId();
const ownOrder = useOverlayOrder();
const rank = order ?? ownOrder;
useEffect(() => {
if (!overlay) return;
overlayHosts.set((hosts) => [...hosts, { id, order: rank }]);
return () => overlayHosts.set((hosts) => hosts.filter((h) => h.id !== id));
}, [overlay, id, rank]);
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
const Docked = useToastDock();
const isTop = top === (overlay ? id : null);
const toasts = isTop ? active : [];
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts
// that were already up as NEW nodes — and a screen reader announced every one of them again, an
// error assertively. So the ones it inherits are shown silently: no `role="alert"`, and the region
// stays `aria-live="off"` until they are in, turning polite (for the toasts that come after) in a
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
// again, i.e. the arming did nothing.
//
// "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
// toast up in the very batch that handed the app host the top spot, so it was classified as
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
// host's last screen either way.
//
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
// first pass had already overwritten it on the line below, so the takeover host classified its
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
const handover = useRef<{ top: boolean; ids: ReadonlySet }>({
top: false,
ids: EMPTY_IDS,
});
if (handover.current.top !== isTop)
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
const inherited = handover.current.ids;
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
// same value twice is harmless; that is the point of the ref.
if (isTop) lastTopIds = toasts.map((n) => n.id);
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!isTop) {
setArmed(false);
return;
}
const timer = window.setTimeout(() => setArmed(true), 0);
return () => window.clearTimeout(timer);
}, [isTop]);
return (
// Stable, always-mounted live region — a polite region must PRE-EXIST in the DOM for a screen
// reader to announce content later inserted into it (a region created together with its first
// message is unreliably announced). `aria-live` (not role="status") on purpose: role="status"
// implies aria-atomic="true", which would re-read the WHOLE stack on every toast; aria-live
// defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts additionally
// carry role="alert", which announces assertively on insertion regardless.
//
// Inside an overlay it sits ABOVE the bottom band: that is where the players keep their control
// bars (the Plex transport row, PlayerModal's docked bar), and the toast stack — on top of them at
// z-overlay, and catching the click — took play/pause, ±10, stop, prev/next and mute for as long
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
// uses for the same reason. Clicks between the cards go through to what is underneath.
// Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right
// of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19).
//
// Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top
// host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
{toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
)}
);
})}
{isTop && Docked && (
)}
);
}
/** The auto-dismiss countdown, started as far in as the toast already is. A toast handed to another
* host is a new node, and its bar used to start again from full while the dismiss timer — armed
* once, at notify time — did not: the toast vanished with the bar still two-thirds full. The offset
* is taken once, at mount; recomputed on a later render it would jump the running animation ahead. */
function CountdownBar({ since, duration, bar }: { since: number; duration: number; bar: string }) {
const [elapsed] = useState(() => Math.min(duration, Math.max(0, Date.now() - since)));
return (
);
}