Add camera pop-out window

- "Pop out" on each dashboard card opens /cameras/[id]/live in a named
  popup sized to the main stream (a plain link if popups are blocked).
- The live page shows only the frame, never stretched past its own size,
  with a fading name/time/refresh-rate overlay and error handling.
- The card pauses its own polling while its pop-out is open; the two
  coordinate over a BroadcastChannel.
- Share frame rendering (FrameImage) between card and pop-out.
- Ignore a stray build cache folder; export vrek log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 15:02:39 -05:00
parent f4d40dbea6
commit ebd488b672
11 changed files with 649 additions and 23 deletions

2
.gitignore vendored
View File

@ -16,6 +16,8 @@
# next.js
/.next/
/out/
# stray build cache from a build run one level up
/cameras/
# production
/build

View File

@ -657,3 +657,29 @@
{"id":"evt-4bnfbpmc0ey6","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-p7vdbng","to":"src/app/cameras/[id]/edit-forms.tsx"},"at":"2026-09-19T19:43:50.857Z","parents":["evt-rgxyz1vn75ky"],"hash":"7cb30d41bde30c172326c0ed2c9a78504227cdd4290fe14ef1251d83f712c28d"}
{"id":"evt-jgwjmsj36n2n","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-p7vdbng","to":"area:ui"},"at":"2026-09-19T19:43:50.858Z","parents":["evt-4bnfbpmc0ey6"],"hash":"25f02f7dc52f4bda1a43a5cdc322999d28fd2ebae314255039894301476c593e"}
{"id":"evt-a2q4cc2fn7wc","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-p7vdbng","to":"iss-tgk8sgp"},"at":"2026-09-19T19:43:50.859Z","parents":["evt-jgwjmsj36n2n"],"hash":"81b034aef5c9e15d27ccf4958a8c65e9c601ba248a3456a397fe983e8776a263"}
{"id":"evt-zndg9fqn2k5m","type":"node.created","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Pop out a camera's live snapshot into its own window","body":"A \"Pop out\" button on each dashboard card opens /cameras/[id]/live in a named popup window (window.open with name camera-<id>, so a second click reuses it). The page shows only the frame, fit to the window (object-contain, dark background), polled through the same useCameraSnapshot hook as the card (pauses when hidden, stops on error with Retry). It starts at the dashboard's refresh rate via ?refresh= and has its own rate selector. A small overlay shows the camera name and the time of the last frame, and fades when the mouse is still. While a pop-out is open, the card pauses its own polling, dims its frame and says \"Showing in pop-out\", so the camera isn't polled twice. Pop-out and card coordinate over a BroadcastChannel (the pop-out announces open and closed, and answers a ping from a card that mounts later), so a dashboard reload still knows. The page checks access and validates the id against the registry like /cameras/[id] (pri-m1csgrm). No new server routes or dependencies: the existing snapshot route already serves the main stream at full resolution. Decided with the user 2026-09-19: pause the card; fit-to-window only (no 1:1 view); name and time overlay. Out of scope: real video (RTSP to browser).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T19:47:15.358Z","parents":["evt-a2q4cc2fn7wc"],"hash":"e8106def3ebd13135e7a905f0538b9853c536eca595daa57dc81c9e30f0094be"}
{"id":"evt-1qjzxa73h42z","type":"edge.added","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-mt5zv64","to":"gol-6q1q5mr"},"at":"2026-09-19T19:47:15.359Z","parents":["evt-zndg9fqn2k5m"],"hash":"a3adb272547f5320a27bb0c693aaff34cb3e4e71e6aaf468093cf99cc3213208"}
{"id":"evt-a5nfzwqnma1t","type":"edge.added","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-mt5zv64","to":"src/app/camera-card.tsx"},"at":"2026-09-19T19:47:15.360Z","parents":["evt-1qjzxa73h42z"],"hash":"6b409ebfb4c22448a8bdd3e31d38ca12dd81b5f1bce739afe6c19174b16b58a7"}
{"id":"evt-hksd7r1mqnmd","type":"edge.added","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-mt5zv64","to":"src/app/cameras/[id]/live/page.tsx"},"at":"2026-09-19T19:47:15.361Z","parents":["evt-a5nfzwqnma1t"],"hash":"713d7ef79bf2c71958c0e8480745796c7dc03008dfe4c76e3ad6ff3561828bcf"}
{"id":"evt-at4cj275af2j","type":"edge.added","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-mt5zv64","to":"area:ui"},"at":"2026-09-19T19:47:15.362Z","parents":["evt-hksd7r1mqnmd"],"hash":"30674fbab77e34bf702390a30c47b3889f7d46173b81055ec0b194ec5837f727"}
{"id":"evt-zkbjn3129r2j","type":"node.updated","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Standing rule derived from dec-1vnz5fw (Lucide chosen 2026-09-19) and applied in iss-scj42jq, iss-chrdd0v, iss-egy082x, iss-s935a4n, iss-j8zzg1r and iss-mt5zv64.\n\n1. Source: icons come from lucide-react, imported by name (`import { KeyRound } from \"lucide-react\"`); Next 16 optimizes those imports by default. No other icon library, icon font or hand-drawn SVG icon without a new dependency question (pri-mz2jxpb). Check that a name exists in the installed version before using it.\n2. Meaning: an icon never carries meaning alone. Status, warnings and actions always have visible text (e.g. the status badge's 'Needs login'), and state is never conveyed by color or icon alone.\n3. Accessibility: icons next to text are decorative and get `aria-hidden`. A control with only an icon must have an `aria-label` that says what it does. Warnings keep role=\"alert\" and their text. Adding an icon must not change a control's accessible name.\n4. Sizing and layout: size with Tailwind classes, not the `size` prop: `size-3.5` in small text and badges, `size-4` in buttons, inline messages and section headings, `size-5` in banners, `size-6` in page headings. Lay out with `inline-flex items-center gap-1…2`, and use `shrink-0` beside wrapping text.\n5. Motion and color: spinners use LoaderCircle with `animate-spin motion-reduce:animate-none`. Colored icons and badges have `dark:` variants.\n6. Consistency: one concept, one icon across the app. Current mapping: Cctv = a camera (live status; camera page heading); LoaderCircle = in progress; KeyRound = login; ShieldAlert = unsecured or not activated; ShieldCheck = secured or signed in; WifiOff = offline; CircleAlert = error message; CircleCheck = success message; TriangleAlert = needs attention (setup needed, clock drift, stream-restart warning, read-only note); RefreshCw = retry or refresh; Radar = scan; Timer = refresh rate; Trash2 = forget; Lock = save a login, or the sign-in heading; Save = save settings; Pencil = edit; X = cancel; LogIn/LogOut = session; ExternalLink = opens another site in a new tab; AppWindow = a camera's pop-out window (the Pop out link, and \"Showing in pop-out\"); ArrowLeft = back to the dashboard; Video = streams; SunMoon = image and day/night settings; Clock = time and NTP; Network = network settings; Copy → Check = copy to clipboard / copied. Reuse these; if a new concept needs an icon, add it here.\n7. Tests: assert icons by their `lucide-<name>` class with toContain, since Lucide also adds alias classes (Trash2 → 'trash' and 'trash-2'), and assert every rendered `svg.lucide` is aria-hidden (see src/app/icons.test.tsx).\n\nWhy: icons should make the dashboard faster to scan without making it less accessible or less consistent. Rules out: icon-only status, icons that change accessible names, ad-hoc SVGs, and mixing icon sets."}},"at":"2026-09-19T19:50:35.607Z","parents":["evt-at4cj275af2j"],"hash":"7ba99cb1a4c0fb1d42eae43a577770b33b158ff7338e0d0839c30a4499d52015"}
{"id":"evt-t3hqkac1y365","type":"node.created","subject":"ver-y1f3cw0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Pop out link opens /cameras/[id]/live in a named popup sized to the main stream (falls back to a link if blocked); the live page checks access and validates the camera; the view polls, fades its overlay, handles errors; the card pauses polling while its pop-out is open and resumes after; suite, lint, type-check and build clean","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:50:38.519Z","parents":["evt-zkbjn3129r2j"],"hash":"77a58abe109a255f35159a4036ea9d96d6e365f7c9056fce77f35c2df0c629f7"}
{"id":"evt-wsygcbp4wew9","type":"edge.added","subject":"ver-y1f3cw0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-y1f3cw0","to":"iss-mt5zv64"},"at":"2026-09-19T19:50:38.520Z","parents":["evt-t3hqkac1y365"],"hash":"d39a775088f73bd03d05f89a4d970a10d29aaec61c0699b3a9d56f7d66b79ca6"}
{"id":"evt-awbc9xdfygnz","type":"verification.recorded","subject":"ver-y1f3cw0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"npx vitest run --coverage → 46 files, 647 tests pass (99.84% lines); tsc, eslint clean; next build lists ƒ /cameras/[id]/live. Tests: src/app/popout.test.ts, camera-card.test.tsx \"pop-out (iss-mt5zv64)\", src/app/cameras/[id]/live/page.test.tsx, live-view.test.tsx"},"at":"2026-09-19T19:50:38.521Z","parents":["evt-wsygcbp4wew9"],"hash":"f52cf400fc79482e2ed255df3facb4da8a71a45cc2bde1c90f61d83a850a5626"}
{"id":"evt-bccr7jx8j9dy","type":"node.created","subject":"ver-2weq6nk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: Pop out opens one window per camera at full size, the card shows \"Showing in pop-out\" and stops polling, and closing the window resumes the card","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:50:39.957Z","parents":["evt-awbc9xdfygnz"],"hash":"992c903df63aab8e47a57ef2fc140dea75046b1eeb53f594bf39b391ec4eb49b"}
{"id":"evt-6dx2683hh7ra","type":"edge.added","subject":"ver-2weq6nk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-2weq6nk","to":"iss-mt5zv64"},"at":"2026-09-19T19:50:39.958Z","parents":["evt-bccr7jx8j9dy"],"hash":"67b8183d4ae87a3720d54b74aaaeb3865dbe04d2b07091a69690ebf69e0fc508"}
{"id":"evt-gj6095st959x","type":"verification.recorded","subject":"ver-2weq6nk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User check after restarting next start"},"at":"2026-09-19T19:50:39.959Z","parents":["evt-6dx2683hh7ra"],"hash":"8f0ceb4c6300c7647d4aeb1d4ff649266f5008131d3774b7a32d886d37c88b39"}
{"id":"evt-bjgmx6g72taa","type":"node.status_changed","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T19:50:44.014Z","parents":["evt-gj6095st959x"],"hash":"88a401dabb3bc34c0f4cad50d0a1dab607feb891ca232ddaef4af1e81d3850b3"}
{"id":"evt-2n1cbeknh1hw","type":"node.created","subject":"fnd-dw1pqcw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"The ONVIF snapshot on camera …af54 is 1200×536 (sub-stream size) for every profile, not the 4096×1860 main stream","body":"Checked read-only on 2026-09-19 through our /api/cameras/…af54/snapshot route in the user's signed-in browser. Profile_1 (mainStream, 4096×1860) and Profile_2 (subStream, 1200×536) both return a 1200×536 baseline JPEG: about 206 KB, 4:2:0 chroma, luma quantization ≈ quality 81. At 1:1 the camera's own image already shows stair-stepped diagonals and jagged on-screen-display text, which suggests a cheap downscale inside the camera. The dashboard then shows it about 2x smaller on the card and scales it up in the pop-out (about 2x on a Retina display), which adds softness. So the odd antialiasing starts in the camera, and the pop-out makes it worse. A full-resolution still likely needs Hikvision ISAPI (/ISAPI/Streaming/channels/101/picture), which is not yet verified on this camera.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"http://localhost:3000/api/cameras/22ec0000-8b90-11b5-845d-d03bf404af54/snapshot","note":"Local app route; decoded in the browser with createImageBitmap, JPEG markers parsed by hand; ?profile=Profile_1 and Profile_2 compared"}],"as_of":"2026-09-19"}},"at":"2026-09-19T19:57:38.977Z","parents":["evt-bjgmx6g72taa"],"hash":"259053e14b8104cbb93df275cdaba6164f28c3c6ffb15d9f46a07ac729665df5"}
{"id":"evt-kd4byxatx87j","type":"edge.added","subject":"fnd-dw1pqcw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-dw1pqcw","to":"iss-mt5zv64"},"at":"2026-09-19T19:57:38.979Z","parents":["evt-2n1cbeknh1hw"],"hash":"4409b9e07be659e882ffeea14901c1e2dbff7bf5a4e7870d525bcadc643da55d"}
{"id":"evt-vyq8f62h333a","type":"edge.added","subject":"fnd-dw1pqcw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-dw1pqcw","to":"gol-6q1q5mr"},"at":"2026-09-19T19:57:38.980Z","parents":["evt-kd4byxatx87j"],"hash":"23bd950e2c1d2bfae489d8cda7ae04028f32982efa3dc565885e079e68827245"}
{"id":"evt-5rfzp4kpkz0d","type":"node.created","subject":"iss-3vck3js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Probe Hikvision ISAPI for full-resolution snapshots","body":"Per fnd-dw1pqcw, the ONVIF snapshot is 1200×536 whatever the profile, while the main stream is 4096×1860. Check read-only whether GET /ISAPI/Streaming/channels/101/picture (digest auth) returns a main-stream-size JPEG on camera …af54. Record the result as a finding: its size and quality, and which login it accepts. Hikvision keeps ONVIF users separate from web users, so the Operator ONVIF login (\"camera\") may be refused. If so, find out what a least-privilege web account needs before designing anything. If it works: add the ISAPI snapshot behind the vendor module (pri-p9h51hx), with ONVIF as the fallback, and let the pop-out use it. Needs the user's go-ahead before touching the camera, and before adding any second per-camera login.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:01:06.904Z","parents":["evt-vyq8f62h333a"],"hash":"e0fb3c278175eb1483fe0380921b0fbc8fc7e2a41c37f3f74fb2e7c6e8e70098"}
{"id":"evt-jarrqz4865sf","type":"edge.added","subject":"iss-3vck3js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-3vck3js","to":"gol-6q1q5mr"},"at":"2026-09-19T20:01:06.906Z","parents":["evt-5rfzp4kpkz0d"],"hash":"6007c607d1a2eb7c5fa87a4c80f5984b5adabf70fc07f42bd0c69ecd601b74e6"}
{"id":"evt-0829k70der31","type":"edge.added","subject":"iss-3vck3js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-3vck3js","to":"src/lib/camera.ts"},"at":"2026-09-19T20:01:06.907Z","parents":["evt-jarrqz4865sf"],"hash":"59ab8ec52dd841497404d4046aa5dde4e34431aef90fc7fe718835dde23d44f9"}
{"id":"evt-ydfbgq1g5s2n","type":"edge.added","subject":"iss-3vck3js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-3vck3js","to":"area:device"},"at":"2026-09-19T20:01:06.908Z","parents":["evt-0829k70der31"],"hash":"6b06fe095e04a92397f9de06766ffb13f1b185da49504cb3b597cd6c3dc26641"}
{"id":"evt-867k0s2fnszr","type":"edge.added","subject":"iss-3vck3js","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-3vck3js","to":"iss-mt5zv64"},"at":"2026-09-19T20:01:06.909Z","parents":["evt-ydfbgq1g5s2n"],"hash":"d1d3d770037df84343164748ec8b359c9a8425ac28d8ef771576695e3bc9bffe"}
{"id":"evt-qjekzvjdf9yf","type":"node.updated","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"A \"Pop out\" button on each dashboard card opens /cameras/[id]/live in a named popup window (window.open with name camera-<id>, so a second click reuses it). The page shows only the frame, centered on a dark background at no more than its own size: fnd-dw1pqcw found the ONVIF snapshot is only 1200×536, so stretching it would only blur it. The frame is polled through the same useCameraSnapshot hook as the card (pauses when hidden, stops on error with Retry). It starts at the dashboard's refresh rate via ?refresh= and has its own rate selector. A small overlay shows the camera name and the time of the last frame, and fades when the mouse is still. While a pop-out is open, the card pauses its own polling, dims its frame and says \"Showing in pop-out\", so the camera isn't polled twice. Pop-out and card coordinate over a BroadcastChannel (the pop-out announces open and closed, and answers a ping from a card that mounts later), so a dashboard reload still knows. The page checks access and validates the id against the registry like /cameras/[id] (pri-m1csgrm). No new server routes or dependencies. Decided with the user 2026-09-19: pause the card; never stretch past the frame's own size; name and time overlay. Out of scope: real video (RTSP to browser). Full-resolution stills: separate follow-up issue."}},"at":"2026-09-19T20:01:09.995Z","parents":["evt-867k0s2fnszr"],"hash":"1d977e3235b7783221f22133dd3a50fc35ae6ed977d2e4ed3234ccb2ef49e5fc"}
{"id":"evt-wdpzzhh7p6gt","type":"node.created","subject":"ver-r6hm6fx","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser: Pop out opens one window per camera, the frame is not stretched past its own size, the card shows \"Showing in pop-out\" while it is open","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:02:27.847Z","parents":["evt-qjekzvjdf9yf"],"hash":"91c6a70aca65b51a9b29246210132486f1eadfa96dd6b31942b8ef63daf456b8"}
{"id":"evt-ayk36tqppyzy","type":"edge.added","subject":"ver-r6hm6fx","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-r6hm6fx","to":"iss-mt5zv64"},"at":"2026-09-19T20:02:27.849Z","parents":["evt-wdpzzhh7p6gt"],"hash":"099836c18a6b4af0731379a71ecacd0008bf92c8acc72b452c4bcd8ccf2c754c"}
{"id":"evt-kq708ggdmxjh","type":"verification.recorded","subject":"ver-r6hm6fx","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User checked in the browser on 2026-09-19 (\"looks good\")"},"at":"2026-09-19T20:02:27.850Z","parents":["evt-ayk36tqppyzy"],"hash":"4912547d89c2ac6b46b447b9a01c89c60905982c5858b1c02f82e8ebacad0170"}
{"id":"evt-mhcr8a835jb7","type":"node.status_changed","subject":"iss-mt5zv64","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T20:02:29.498Z","parents":["evt-kq708ggdmxjh"],"hash":"32cbe454fa3ac9e4fdab2c334cecff996981fc67162ecbf4b5088ff06d629e89"}

View File

@ -55,6 +55,58 @@ describe("CameraCard", () => {
);
});
describe("pop-out (iss-mt5zv64)", () => {
const settle = () => new Promise((r) => setTimeout(r, 30));
it("links to the live page, and opens it as a popup sized to the main stream", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window);
renderWithQuery(<CameraCard cam={cam} intervalMs={500} />);
await screen.findByText("1920×1080");
const link = screen.getByRole("link", { name: "Pop out" });
expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}/live?refresh=500`);
expect(link.getAttribute("target")).toBe(`camera-${cam.id}`);
expect(fireEvent.click(link)).toBe(false); // default prevented: the popup replaced the link
expect(open).toHaveBeenCalledWith(
`/cameras/${cam.id}/live?refresh=500`,
`camera-${cam.id}`,
expect.stringMatching(/^popup,width=\d+,height=\d+$/),
);
const [, , features] = open.mock.calls[0];
const [w, h] = features!.match(/\d+/g)!.map(Number);
expect(w / h).toBeCloseTo(16 / 9, 1);
});
it("falls back to following the link when the popup is blocked", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
vi.spyOn(window, "open").mockReturnValue(null);
renderWithQuery(<CameraCard cam={cam} intervalMs={500} />);
expect(fireEvent.click(screen.getByRole("link", { name: "Pop out" }))).toBe(true);
});
it("pauses its own polling while the camera shows in a pop-out, and resumes after", async () => {
const fetchMock = stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
await waitFor(() => expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBeGreaterThanOrEqual(2));
const popout = new BroadcastChannel("camera-popouts");
popout.postMessage({ type: "open", id: cam.id });
expect(await screen.findByText("Showing in pop-out")).toBeTruthy();
const whilePoppedOut = calls(fetchMock, `GET ${base}/snapshot`).length;
await new Promise((r) => setTimeout(r, 150));
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBe(whilePoppedOut);
popout.postMessage({ type: "closed", id: cam.id });
await waitFor(() => expect(screen.queryByText("Showing in pop-out")).toBeNull());
await waitFor(() =>
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBeGreaterThan(whilePoppedOut),
);
popout.close();
await settle();
});
});
it("falls back to the model, then 'Unnamed camera', for the title", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={{ ...cam, name: undefined }} intervalMs={1000} />);

View File

@ -1,6 +1,7 @@
"use client";
import {
AppWindow,
CircleAlert,
ExternalLink,
KeyRound,
@ -12,7 +13,7 @@ import {
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
useCameraInfo,
@ -25,6 +26,8 @@ import {
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import { deviceLine, streamSummary } from "./camera-summary";
import FrameImage from "./frame-image";
import { openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout";
const DEFAULT_USERNAME = "camera";
@ -79,38 +82,70 @@ function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: (
}
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
const imgRef = useRef<HTMLImageElement>(null);
// Each frame gets one object URL, created and revoked in the same effect so it is freed
// exactly once, including under Strict Mode's double rendering.
useEffect(() => {
const img = imgRef.current;
if (!frame || !img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
function Snapshot({
cam,
frame,
paused,
poppedOut,
}: {
cam: CameraSummary;
frame?: Blob;
paused: boolean;
poppedOut: boolean;
}) {
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
<img
ref={imgRef}
<FrameImage
frame={frame}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
{!frame && !paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
{poppedOut ? (
<div className="absolute inset-0 flex items-center justify-center gap-1.5 text-sm font-medium">
<AppWindow aria-hidden className="size-4 shrink-0" />
Showing in pop-out
</div>
) : (
!frame &&
!paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)
)}
</div>
);
}
/** Whether this camera is currently shown in a pop-out window. */
function usePoppedOut(id: string) {
const [poppedOut, setPoppedOut] = useState(false);
useEffect(() => watchPopout(id, setPoppedOut), [id]);
return poppedOut;
}
/**
* A link, so it still opens a tab if popups are blocked; with JavaScript it opens a
* popup window sized to the main stream.
*/
function PopoutLink({ cam, intervalMs, aspect }: { cam: CameraSummary; intervalMs: number; aspect?: number }) {
return (
<a
href={popoutUrl(cam.id, intervalMs)}
target={popoutWindowName(cam.id)}
onClick={(e) => {
if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault();
}}
className="inline-flex items-center gap-1 underline"
>
<AppWindow aria-hidden className="size-3.5" />
Pop out
</a>
);
}
function CredentialsForm({
cam,
reason,
@ -222,7 +257,9 @@ export default function CameraCard({
intervalMs: number;
}) {
const info = useCameraInfo(cam.id);
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
const poppedOut = usePoppedOut(cam.id);
// The pop-out polls the camera itself, so the card doesn't poll it a second time.
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess && !poppedOut);
const resetCamera = useResetCamera(cam.id);
const [editingLogin, setEditingLogin] = useState(false);
const problem: CameraProblem | null = info.error ?? snapshot.error;
@ -234,13 +271,20 @@ export default function CameraCard({
const device = info.data ? deviceLine(info.data) : null;
const summary = info.data ? streamSummary(info.data.profiles) : null;
const main = info.data?.profiles[0];
const aspect = main?.width && main.height ? main.width / main.height : undefined;
const showSetup = problem?.kind === "inactive";
const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin);
return (
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
<Snapshot
cam={cam}
frame={snapshot.data}
paused={problem !== null || !info.isSuccess}
poppedOut={poppedOut}
/>
<div>
<div className="flex items-center justify-between gap-2">
@ -256,6 +300,7 @@ export default function CameraCard({
{cam.host}:{cam.port}
</span>
<span className="flex gap-3 text-xs">
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button

View File

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { calls, json, renderWithQuery, stubFetch } from "../../../../../test/dom";
import LiveView from "./live-view";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn() }),
usePathname: () => "/cameras/x/live",
}));
const ID = "11111111-2222-3333-4444-555555555555";
const snap = `GET /api/cameras/${ID}/snapshot`;
const frame = () => new Response(new Blob(["jpeg"], { type: "image/jpeg" }));
const overlay = () => screen.getByText("Porch").closest("div")!;
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:frame");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
describe("LiveView", () => {
it("shows the live frame, the camera name, the frame time and a rate selector", async () => {
stubFetch({ [snap]: frame });
renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={1000} />);
expect(screen.getByText("Loading…")).toBeTruthy();
await waitFor(() => expect(screen.getByRole("img", { name: "Porch" }).getAttribute("src")).toBe("blob:frame"));
// Never stretched beyond the frame's own size (fnd-dw1pqcw).
const img = screen.getByRole("img", { name: "Porch" });
expect(img.className).toContain("max-w-full");
expect(img.className.split(" ")).not.toContain("w-full");
expect(img.className.split(" ")).not.toContain("h-full");
expect(overlay().querySelector("time")?.getAttribute("dateTime")).toMatch(/^\d{4}-/);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("1000");
});
it("fades the overlay when the mouse is still and brings it back on movement", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
stubFetch({ [snap]: frame });
renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={5000} />);
expect(overlay().className).toContain("opacity-100");
await act(() => vi.advanceTimersByTimeAsync(3000));
expect(overlay().className).toContain("opacity-0");
fireEvent.mouseMove(overlay().parentElement!);
expect(overlay().className).toContain("opacity-100");
await act(() => vi.advanceTimersByTimeAsync(3000));
expect(overlay().className).toContain("opacity-0");
} finally {
vi.useRealTimers();
}
});
it("stops on a failed frame, keeps the overlay up, and retries on request", async () => {
let fail = true;
const fetchMock = stubFetch({ [snap]: () => (fail ? json({ error: "Camera timed out" }, 502) : frame()) });
renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={20} />);
expect((await screen.findByRole("alert")).textContent).toContain("Stopped: Camera timed out");
const afterFailure = calls(fetchMock, snap).length;
await new Promise((r) => setTimeout(r, 100));
expect(calls(fetchMock, snap).length).toBe(afterFailure);
fail = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(calls(fetchMock, snap).length).toBeGreaterThan(afterFailure);
});
it("sends a signed-out user to sign in and back to this window", async () => {
stubFetch({ [snap]: () => json({ error: "Sign in required" }, 401) });
renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={1000} />);
expect((await screen.findByRole("alert")).textContent).toContain("Your session has ended.");
expect(screen.getByRole("link", { name: "Sign in again" }).getAttribute("href")).toBe(
`/login?next=${encodeURIComponent(`/cameras/${ID}/live`)}`,
);
});
it("points camera login or setup problems back to the dashboard", async () => {
stubFetch({ [snap]: () => json({ error: "bad login", code: "auth" }, 401) });
renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={1000} />);
expect((await screen.findByRole("alert")).textContent).toContain("Fix it from the dashboard");
});
it("tells the dashboard it is open while mounted", async () => {
stubFetch({ [snap]: frame });
const seen: unknown[] = [];
const card = new BroadcastChannel("camera-popouts");
card.onmessage = (e) => seen.push(e.data);
const { unmount } = renderWithQuery(<LiveView id={ID} name="Porch" intervalMs={1000} />);
await waitFor(() => expect(seen).toContainEqual({ type: "open", id: ID }));
unmount();
await waitFor(() => expect(seen).toContainEqual({ type: "closed", id: ID }));
card.close();
});
});

View File

@ -0,0 +1,100 @@
"use client";
import { CircleAlert, LogIn, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCameraSnapshot, useResetCamera } from "../../../camera-queries";
import FrameImage from "../../../frame-image";
import { announcePopout } from "../../../popout";
import RefreshRateSelect from "../../../refresh-rate-select";
/** How long the overlay stays after the mouse stops moving. */
const OVERLAY_MS = 2500;
/** Shows the overlay while the mouse moves, then fades it out. */
function useRecentActivity() {
const [active, setActive] = useState(true);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const poke = () => {
setActive(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setActive(false), OVERLAY_MS);
};
useEffect(() => {
timer.current = setTimeout(() => setActive(false), OVERLAY_MS);
return () => clearTimeout(timer.current);
}, []);
return { active, poke };
}
export default function LiveView({ id, name, intervalMs }: { id: string; name: string; intervalMs: number }) {
const snapshot = useCameraSnapshot(id, intervalMs, true);
const reset = useResetCamera(id);
const { active, poke } = useRecentActivity();
const problem = snapshot.error;
useEffect(() => announcePopout(id), [id]);
return (
<div className="absolute inset-0" onMouseMove={poke}>
{snapshot.data && (
// Shown at its own size at most: stretching a small camera snapshot only blurs it
// (vrek fnd-dw1pqcw). Larger windows get a border instead.
<div className="absolute inset-0 flex items-center justify-center">
<FrameImage
frame={snapshot.data}
alt={name}
className={`max-h-full max-w-full object-contain ${problem ? "opacity-40" : ""}`}
/>
</div>
)}
{!snapshot.data && !problem && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-400">Loading…</div>
)}
<div
className={`absolute inset-x-0 top-0 flex flex-wrap items-center justify-between gap-3 bg-black/60 px-4 py-2 text-sm transition-opacity motion-reduce:transition-none ${
active || problem ? "opacity-100" : "opacity-0"
} focus-within:opacity-100`}
>
<span className="font-medium">
{name}
{snapshot.dataUpdatedAt > 0 && (
<span className="ml-2 font-normal text-zinc-300">
<time dateTime={new Date(snapshot.dataUpdatedAt).toISOString()}>
{new Date(snapshot.dataUpdatedAt).toLocaleTimeString()}
</time>
</span>
)}
</span>
<RefreshRateSelect value={intervalMs} />
</div>
{problem && (
<div
role="alert"
className="absolute inset-x-0 bottom-0 flex flex-wrap items-center justify-between gap-2 bg-black/70 px-4 py-2 text-sm text-red-300"
>
<span className="inline-flex items-center gap-1.5">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{problem.kind === "signed-out"
? "Your session has ended."
: problem.kind === "error"
? `Stopped: ${problem.message}`
: "This camera needs attention. Fix it from the dashboard, then retry."}
</span>
{problem.kind === "signed-out" ? (
<a href={`/login?next=${encodeURIComponent(`/cameras/${id}/live`)}`} className="inline-flex items-center gap-1 underline">
<LogIn aria-hidden className="size-4" />
Sign in again
</a>
) : (
<button onClick={reset} className="inline-flex items-center gap-1 underline">
<RefreshCw aria-hidden className="size-4" />
Retry
</button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,86 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraRecord } from "@/lib/camera-registry";
const requirePageAccess = vi.fn();
vi.mock("@/lib/access", () => ({ requirePageAccess }));
const getCameraRecord = vi.fn<(id: string) => Promise<CameraRecord | null>>();
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord,
}));
// The client view is tested on its own; here it is a marker carrying its props.
vi.mock("./live-view", () => ({
default: (props: object) => <div data-testid="live">{JSON.stringify(props)}</div>,
}));
class NotFound extends Error {}
vi.mock("next/navigation", () => ({
notFound: () => {
throw new NotFound();
},
}));
const { default: LivePage, generateMetadata } = await import("./page");
const ID = "22ec0000-8b90-11b5-845d-d03bf404af2e";
const record: CameraRecord = {
id: ID,
urn: `urn:uuid:${ID}`,
host: "192.168.17.129",
port: 80,
name: "I91ET",
lastSeen: "2026-09-19T00:00:00.000Z",
};
const props = (id = ID, refresh?: string) => ({
params: Promise.resolve({ id }),
searchParams: Promise.resolve(refresh === undefined ? {} : { refresh }),
});
const markup = async (...args: Parameters<typeof props>) => renderToStaticMarkup(await LivePage(props(...args)));
const decoded = (html: string) => html.replace(/&quot;/g, '"');
beforeEach(() => {
requirePageAccess.mockReset().mockResolvedValue(undefined);
getCameraRecord.mockReset().mockResolvedValue(record);
});
describe("/cameras/[id]/live", () => {
it("checks access before anything else", async () => {
requirePageAccess.mockRejectedValue(new Error("redirect /login"));
await expect(LivePage(props())).rejects.toThrow("redirect /login");
await expect(generateMetadata(props())).rejects.toThrow("redirect /login");
expect(getCameraRecord).not.toHaveBeenCalled();
});
it("shows the registry camera at the requested refresh rate", async () => {
expect(decoded(await markup(ID, "250"))).toContain(
JSON.stringify({ id: ID, name: "I91ET", intervalMs: 250 }),
);
});
it("falls back to the default rate and names an unnamed camera", async () => {
getCameraRecord.mockResolvedValue({ ...record, name: undefined });
expect(decoded(await markup(ID, "7"))).toContain(
JSON.stringify({ id: ID, name: "Unnamed camera", intervalMs: 1000 }),
);
});
it("titles the window after the camera", async () => {
expect(await generateMetadata(props())).toEqual({ title: "I91ET · live" });
getCameraRecord.mockResolvedValue({ ...record, name: undefined });
expect(await generateMetadata(props())).toEqual({ title: "Camera · live" });
});
it.each([
["a malformed id", "../../etc", record],
["an unknown camera", ID, null],
["a registry entry outside the private network", ID, { ...record, host: "8.8.8.8" }],
])("is not found for %s", async (_l, id, found) => {
getCameraRecord.mockResolvedValue(found);
await expect(LivePage(props(id))).rejects.toBeInstanceOf(NotFound);
await expect(generateMetadata(props(id))).rejects.toBeInstanceOf(NotFound);
});
});

View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { requirePageAccess } from "@/lib/access";
import { isAllowedHost } from "@/lib/camera";
import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { refreshMsSchema } from "../../../refresh-rate";
import LiveView from "./live-view";
async function cameraFor(id: string) {
if (!isValidCameraId(id)) notFound();
const record = await getCameraRecord(id);
if (!record || !isAllowedHost(record.host)) notFound();
return record;
}
export async function generateMetadata({ params }: PageProps<"/cameras/[id]/live">): Promise<Metadata> {
await requirePageAccess();
const record = await cameraFor((await params).id);
return { title: `${record.name ?? "Camera"} · live` };
}
/**
* A camera's live frame alone, filling a pop-out window (vrek iss-mt5zv64). Like
* /cameras/[id], the camera comes from the registry and must be a private address.
*/
export default async function LivePage({ params, searchParams }: PageProps<"/cameras/[id]/live">) {
await requirePageAccess();
const [{ id }, { refresh }] = await Promise.all([params, searchParams]);
const record = await cameraFor(id);
return (
<main className="relative flex-1 bg-black font-sans text-white">
<LiveView id={record.id} name={record.name ?? "Unnamed camera"} intervalMs={refreshMsSchema.parse(refresh)} />
</main>
);
}

20
src/app/frame-image.tsx Normal file
View File

@ -0,0 +1,20 @@
"use client";
import { useEffect, useRef } from "react";
/** Shows one snapshot frame from a Blob, giving each frame one object URL that is freed exactly once. */
export default function FrameImage({ frame, alt, className }: { frame: Blob; alt: string; className?: string }) {
const imgRef = useRef<HTMLImageElement>(null);
// Created and revoked in the same effect, so it is freed once even under Strict Mode.
useEffect(() => {
const img = imgRef.current;
if (!img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
return <img ref={imgRef} alt={alt} className={className} />;
}

100
src/app/popout.test.ts Normal file
View File

@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { announcePopout, openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout";
const ID = "11111111-2222-3333-4444-555555555555";
const OTHER = "99999999-2222-3333-4444-555555555555";
// BroadcastChannel delivers asynchronously; give it a turn of the event loop.
const settle = () => new Promise((r) => setTimeout(r, 20));
const cleanups: (() => void)[] = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()!();
});
describe("popout URLs", () => {
it("builds the live page URL and a per-camera window name", () => {
expect(popoutUrl(ID, 500)).toBe(`/cameras/${ID}/live?refresh=500`);
expect(popoutWindowName(ID)).toBe(`camera-${ID}`);
});
});
describe("openPopout", () => {
it("opens a popup sized to the stream's aspect ratio and focuses it", () => {
vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(2000);
const focus = vi.fn();
const open = vi.spyOn(window, "open").mockReturnValue({ focus } as unknown as Window);
expect(openPopout(ID, 1000, 4096 / 1860)).toBe(true);
expect(open).toHaveBeenCalledWith(`/cameras/${ID}/live?refresh=1000`, `camera-${ID}`, "popup,width=1280,height=581");
expect(focus).toHaveBeenCalled();
});
it("uses 16:9 and at most 80% of a small screen", () => {
vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(1000);
const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window);
openPopout(ID, 1000);
expect(open.mock.calls[0][2]).toBe("popup,width=800,height=450");
});
it("uses the default width when the screen size is unknown", () => {
vi.spyOn(window.screen, "availWidth", "get").mockReturnValue(0);
const open = vi.spyOn(window, "open").mockReturnValue({ focus: vi.fn() } as unknown as Window);
openPopout(ID, 1000);
expect(open.mock.calls[0][2]).toBe("popup,width=1280,height=720");
});
it("reports a blocked popup", () => {
vi.spyOn(window, "open").mockReturnValue(null);
expect(openPopout(ID, 1000)).toBe(false);
});
});
describe("pop-out and card coordination", () => {
it("tells a watching card when its camera's pop-out opens and closes", async () => {
const onChange = vi.fn();
cleanups.push(watchPopout(ID, onChange));
const close = announcePopout(ID);
await settle();
expect(onChange).toHaveBeenLastCalledWith(true);
close();
await settle();
expect(onChange).toHaveBeenLastCalledWith(false);
});
it("answers a card that starts watching after the pop-out opened", async () => {
cleanups.push(announcePopout(ID));
await settle();
const onChange = vi.fn();
cleanups.push(watchPopout(ID, onChange));
await settle();
expect(onChange).toHaveBeenCalledWith(true);
});
it("ignores other cameras' pop-outs", async () => {
const onChange = vi.fn();
cleanups.push(watchPopout(ID, onChange));
cleanups.push(announcePopout(OTHER));
await settle();
expect(onChange).not.toHaveBeenCalled();
});
it("announces closing when the window goes away", async () => {
const onChange = vi.fn();
cleanups.push(watchPopout(ID, onChange));
cleanups.push(announcePopout(ID));
await settle();
window.dispatchEvent(new Event("pagehide"));
await settle();
expect(onChange).toHaveBeenLastCalledWith(false);
});
it("does nothing where BroadcastChannel is unavailable", () => {
vi.stubGlobal("BroadcastChannel", undefined);
const onChange = vi.fn();
expect(() => {
watchPopout(ID, onChange)();
announcePopout(ID)();
}).not.toThrow();
expect(onChange).not.toHaveBeenCalled();
});
});

64
src/app/popout.ts Normal file
View File

@ -0,0 +1,64 @@
/**
* Pop-out windows for a camera's live frame (vrek iss-mt5zv64). The pop-out and the
* dashboard card talk over a BroadcastChannel, so a card pauses its own polling while its
* camera is shown in a pop-out, even after the dashboard is reloaded.
*/
const CHANNEL = "camera-popouts";
type PopoutMessage = { type: "open" | "closed"; id: string } | { type: "ping" };
export const popoutUrl = (id: string, refreshMs: number) => `/cameras/${id}/live?refresh=${refreshMs}`;
/** One window per camera: opening it again reuses the window instead of adding another. */
export const popoutWindowName = (id: string) => `camera-${id}`;
/** Opens (or reuses) the pop-out, sized to the stream's aspect ratio. Returns false if blocked. */
export function openPopout(id: string, refreshMs: number, aspect = 16 / 9): boolean {
// availWidth can read 0 (e.g. headless); then use the default width.
const width = Math.round(Math.min(1280, window.screen.availWidth * 0.8 || 1280));
const height = Math.round(width / aspect);
const win = window.open(
popoutUrl(id, refreshMs),
popoutWindowName(id),
`popup,width=${width},height=${height}`,
);
win?.focus();
return win !== null;
}
function channel(): BroadcastChannel | null {
return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
}
/** Run in the pop-out: tells cards it is open until it closes. Returns the cleanup. */
export function announcePopout(id: string): () => void {
const ch = channel();
if (!ch) return () => {};
const post = (msg: PopoutMessage) => ch.postMessage(msg);
ch.onmessage = (e: MessageEvent<PopoutMessage>) => {
if (e.data?.type === "ping") post({ type: "open", id });
};
// Closing the window doesn't unmount React, so announce on pagehide as well.
const closed = () => post({ type: "closed", id });
window.addEventListener("pagehide", closed);
post({ type: "open", id });
return () => {
window.removeEventListener("pagehide", closed);
closed();
ch.close();
};
}
/** Run in a card: reports whether camera `id` is showing in a pop-out. Returns the cleanup. */
export function watchPopout(id: string, onChange: (open: boolean) => void): () => void {
const ch = channel();
if (!ch) return () => {};
ch.onmessage = (e: MessageEvent<PopoutMessage>) => {
const msg = e.data;
if (msg?.type !== "ping" && msg?.id === id) onChange(msg.type === "open");
};
// Ask pop-outs that were opened before this card mounted.
ch.postMessage({ type: "ping" } satisfies PopoutMessage);
return () => ch.close();
}