diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index b41c84c..51c3a4f 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -1115,3 +1115,14 @@ {"id":"evt-954awzf35t82","type":"node.status_changed","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T01:00:17.828Z","parents":["evt-fj77a4q216h5"],"hash":"98b76ddba2a716012a86e7b526e82dadd9f7b3af6f0b0351b0c60965af4a24c2"} {"id":"evt-rz1v74g035zs","type":"node.created","subject":"dec-s4pec5x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Remote access is over the user's VPN; no internet-facing video","body":"Asked and decided on 2026-09-20. Watching from outside the house goes over the VPN the user already runs, so the app stays a LAN service: no ports opened, no public URL, and nothing to change in the code. What was considered and rejected for now: forwarding UDP 8189 with webrtcAdditionalHosts set to a public name (lowest latency, but a second public entrance and it breaks where UDP is blocked); ICE over TCP through a stream proxy; serving HLS through our own session-checked routes, which needs no extra ports and would be the only option keeping video behind the admin login, at two to six seconds of latency; and a TURN server, which works nearly everywhere but is another service to run and secure. Worth remembering if this is ever revisited: with WebRTC as built, only the signaling is behind the admin session, while the media itself is reachable by anyone holding a session description, which is acceptable on a private network and would not be on a public one.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T01:21:16.094Z","parents":["evt-954awzf35t82"],"hash":"a9a3f359844d787da4eff3069660c1d3e196bdebf3d1a8dde62358f686fa1089"} {"id":"evt-xsejqxb5hpfy","type":"edge.added","subject":"dec-s4pec5x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-s4pec5x","to":"gol-9jctv92"},"at":"2026-09-20T01:21:16.097Z","parents":["evt-rz1v74g035zs"],"hash":"bd47d1b2e8ac5dce3947f39bdd1bd11cbf03889c9e9af87f06e2ed4dbf6fc934"} +{"id":"evt-rbb76bh618ed","type":"node.created","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Play recordings in a pop-out window, like the live view","body":"Asked by the user on 2026-09-20. Clips used to play inline on the Recordings page; now Play opens /recordings/play?clip=… in a window of its own, the same shape as the live pop-out: the video fills the window on black, with a fading overlay showing the camera and the time, and the window title naming both. One window is reused for whichever clip is opened next, rather than one per clip. It stays a plain link with a target, so a blocked popup still opens a tab. The page checks the session and resolves the clip inside the recordings folder, so a request can only name a clip that folder holds.","status":"open","owner":null,"attrs":{}},"at":"2026-09-20T10:38:29.425Z","parents":["evt-xsejqxb5hpfy"],"hash":"a22aeaad4819a20d38451f7d18c81f7a6c04390a804bdbce496b8f033b5761b1"} +{"id":"evt-9nb9mq2j0w3b","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-gq9faq4","to":"gol-k56hc69"},"at":"2026-09-20T10:38:29.430Z","parents":["evt-rbb76bh618ed"],"hash":"06688954d7c59cfbc2283ba0231aa85e46cc9d1ed80fd7e56e7be918c9c9e448"} +{"id":"evt-800hfqagdn2p","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-gq9faq4","to":"src/app/recordings/play/"},"at":"2026-09-20T10:38:29.432Z","parents":["evt-9nb9mq2j0w3b"],"hash":"25d2521eb48e4b798a3760c22484020f8a5f0a5ec552d1955565ef56ef5548b1"} +{"id":"evt-k65y96se0rgc","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-gq9faq4","to":"src/app/recordings/clip-list.tsx"},"at":"2026-09-20T10:38:29.433Z","parents":["evt-800hfqagdn2p"],"hash":"1cf40030985ca1bf0110fe4dc73df817ecd4bb2f034b2a66fc9fe84b944b7576"} +{"id":"evt-sgx0vnhx7na9","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-gq9faq4","to":"src/app/popout.ts"},"at":"2026-09-20T10:38:29.434Z","parents":["evt-k65y96se0rgc"],"hash":"518450444fd3557c1d4e267a5d4c91bba55ad78efbcc28da910decb4f8350d45"} +{"id":"evt-efxy52npt9s4","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-gq9faq4","to":"area:ui"},"at":"2026-09-20T10:38:29.435Z","parents":["evt-sgx0vnhx7na9"],"hash":"5376696122c3778f78299768608c2a6539ed1ad481b74cde37af0686a8bd6c5d"} +{"id":"evt-2ebzn1n59az4","type":"edge.added","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-gq9faq4","to":"area:video"},"at":"2026-09-20T10:38:29.436Z","parents":["evt-efxy52npt9s4"],"hash":"e36fb32a4f03907a7e8736a6409f82aef539e3987da18ebab1e361b1957b86b6"} +{"id":"evt-w9hb2my68r0b","type":"node.created","subject":"ver-b8c9754","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Play opens a clip in its own window, reused for the next clip, with the camera and time over the video and a link fallback when popups are blocked; the page checks the session and refuses anything that isn't a clip on disk","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T10:38:37.807Z","parents":["evt-2ebzn1n59az4"],"hash":"0d38543b31237f16519fed02d1c2b53bea1c68b0742ca689283906094b6eba4e"} +{"id":"evt-vkgakfas0f53","type":"edge.added","subject":"ver-b8c9754","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-b8c9754","to":"iss-gq9faq4"},"at":"2026-09-20T10:38:37.810Z","parents":["evt-w9hb2my68r0b"],"hash":"ab36d9c45c1912884211a7c8d2f2a4733a889bd342a9c3a588a1c1e16904f078"} +{"id":"evt-nrrkd6tappeg","type":"verification.recorded","subject":"ver-b8c9754","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/recordings/play/page.tsx and clip-player.tsx, with openClipPopout/CLIP_WINDOW_NAME added to src/app/popout.ts (openPopoutWindow now shared with the camera pop-out). Tests: clip-list.test.tsx (window reused, default prevented, link fallback, nothing plays inline), play/page.test.tsx (access first, traversal and missing clips are not found, unnamed and unknown cameras, window title), clip-player.test.tsx (source, controls, overlay fade). Also fixed three test files that never cleaned up the DOM between renders, which is what made the new test fail depending on order. Full suite 955 tests pass, 99.66% lines; tsc, eslint and next build clean, which lists ƒ /recordings/play. Not yet seen in a browser."},"at":"2026-09-20T10:38:37.811Z","parents":["evt-vkgakfas0f53"],"hash":"532171a6aff75b5059af1ab2f06b8bd069c53c15ba117c308be5403b8530e3f0"} +{"id":"evt-sm4xsqhdqc2j","type":"node.status_changed","subject":"iss-gq9faq4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T10:38:38.921Z","parents":["evt-nrrkd6tappeg"],"hash":"24720443df5d9bca04ec60a36ad497b9664fb671488d46f1538cd340373cf0af"} diff --git a/src/app/cameras/[id]/client-controls.test.tsx b/src/app/cameras/[id]/client-controls.test.tsx index cf19d6a..9068ca2 100644 --- a/src/app/cameras/[id]/client-controls.test.tsx +++ b/src/app/cameras/[id]/client-controls.test.tsx @@ -3,6 +3,9 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra import { renderToString } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +/** Without this each render piles up in the document and tests read each other's DOM. */ +afterEach(cleanup); + const refresh = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) })); diff --git a/src/app/cameras/[id]/live-panel.test.tsx b/src/app/cameras/[id]/live-panel.test.tsx index 6a19307..690d93c 100644 --- a/src/app/cameras/[id]/live-panel.test.tsx +++ b/src/app/cameras/[id]/live-panel.test.tsx @@ -1,8 +1,11 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import LivePanel from "./live-panel"; +/** Without this each render piles up in the document and tests read each other's DOM. */ +afterEach(cleanup); + vi.mock("./live/live-player", () => ({ default: ({ cameraId, name, stream }: { cameraId: string; name: string; stream: string }) => (
{`${name} ${cameraId} ${stream}`}
diff --git a/src/app/popout.ts b/src/app/popout.ts index cafabdc..0d2305a 100644 --- a/src/app/popout.ts +++ b/src/app/popout.ts @@ -13,20 +13,33 @@ export const popoutUrl = (id: string, refreshMs: number) => `/cameras/${id}/live /** 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 { +/** + * Opens (or reuses) a pop-out window, sized to the picture's aspect ratio. Returns false + * if the browser blocked it, so the caller can fall back to an ordinary link. + */ +export function openPopoutWindow(url: string, name: string, 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}`, - ); + const win = window.open(url, name, `popup,width=${width},height=${height}`); win?.focus(); return win !== null; } +/** Opens (or reuses) one camera's live pop-out. */ +export function openPopout(id: string, refreshMs: number, aspect = 16 / 9): boolean { + return openPopoutWindow(popoutUrl(id, refreshMs), popoutWindowName(id), aspect); +} + +/** Clips play in a window of their own, reused for whichever clip is opened next. */ +export const CLIP_WINDOW_NAME = "onvif-dashboard-recording"; + +export const clipPopoutUrl = (clipId: string) => `/recordings/play?clip=${encodeURIComponent(clipId)}`; + +export function openClipPopout(clipId: string, aspect = 16 / 9): boolean { + return openPopoutWindow(clipPopoutUrl(clipId), CLIP_WINDOW_NAME, aspect); +} + function channel(): BroadcastChannel | null { return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL); } diff --git a/src/app/recordings/clip-list.test.tsx b/src/app/recordings/clip-list.test.tsx index 69d2ee2..737e55a 100644 --- a/src/app/recordings/clip-list.test.tsx +++ b/src/app/recordings/clip-list.test.tsx @@ -41,15 +41,32 @@ describe("ClipList", () => { expect(screen.getAllByText("Unknown camera")).toHaveLength(2); }); - it("plays a clip from our own route and closes the player again", () => { + it("plays a clip in a window of its own, reused for the next clip", () => { + const focus = vi.fn(); + const open = vi.spyOn(window, "open").mockReturnValue({ focus } as unknown as Window); list(); - fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]); - const video = document.querySelector("video")!; - expect(video.getAttribute("src")).toBe(clipUrl(clips[0])); - expect(screen.getByRole("heading").textContent).toContain("Porch"); - fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(document.querySelector("video")).toBeNull(); + const play = screen.getAllByRole("link", { name: "Play" })[0]; + expect(play.getAttribute("href")).toBe(`/recordings/play?clip=${encodeURIComponent(clips[0].id)}`); + expect(play.getAttribute("target")).toBe("onvif-dashboard-recording"); + expect(fireEvent.click(play)).toBe(false); // default prevented: the window replaced the link + + expect(open).toHaveBeenCalledWith( + `/recordings/play?clip=${encodeURIComponent(clips[0].id)}`, + "onvif-dashboard-recording", + expect.stringMatching(/^popup,width=\d+,height=\d+$/), + ); + expect(focus).toHaveBeenCalled(); + expect(document.querySelector("video")).toBeNull(); // nothing plays in the list itself + + fireEvent.click(screen.getAllByRole("link", { name: "Play" })[1]); + expect(open.mock.calls[1][1]).toBe("onvif-dashboard-recording"); // same window + }); + + it("falls back to following the link when the popup is blocked", () => { + vi.spyOn(window, "open").mockReturnValue(null); + list(); + expect(fireEvent.click(screen.getAllByRole("link", { name: "Play" })[0])).toBe(true); }); it("asks before deleting, and can be cancelled", async () => { @@ -63,18 +80,16 @@ describe("ClipList", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("deletes a clip, closes it if it was playing, and reloads the list", async () => { + it("deletes a clip and reloads the list", async () => { const fetchMock = stubFetch({ [`DELETE ${clipUrl(clips[0]).split("?")[0]}`]: () => new Response(null, { status: 204 }), }); list(); - fireEvent.click(screen.getAllByRole("button", { name: "Play" })[0]); fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]); fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" })); await waitFor(() => expect(refresh).toHaveBeenCalled()); expect(String(fetchMock.mock.calls[0][0])).toBe(clipUrl(clips[0])); - expect(document.querySelector("video")).toBeNull(); }); it("says when a delete fails, and keeps the clip listed", async () => { diff --git a/src/app/recordings/clip-list.tsx b/src/app/recordings/clip-list.tsx index c07b600..bf29c6b 100644 --- a/src/app/recordings/clip-list.tsx +++ b/src/app/recordings/clip-list.tsx @@ -1,9 +1,10 @@ "use client"; -import { CircleAlert, Film, Play, Trash2, X } from "lucide-react"; +import { CircleAlert, Film, Play, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import type { Clip } from "@/lib/recordings"; +import { clipPopoutUrl, CLIP_WINDOW_NAME, openClipPopout } from "../popout"; /** "2.4 MB" */ function sizeLabel(bytes: number): string { @@ -14,7 +15,6 @@ const clipUrl = (id: string) => `/api/recordings/clip?clip=${encodeURIComponent( export default function ClipList({ clips, names }: { clips: Clip[]; names: Record }) { const router = useRouter(); - const [playing, setPlaying] = useState(null); const [confirming, setConfirming] = useState(null); const [error, setError] = useState(null); @@ -26,7 +26,6 @@ export default function ClipList({ clips, names }: { clips: Clip[]; names: Recor setError("That clip could not be deleted."); return; } - if (playing?.id === clip.id) setPlaying(null); router.refresh(); } @@ -40,21 +39,6 @@ export default function ClipList({ clips, names }: { clips: Clip[]; names: Recor return (
- {playing && ( -
-
-

- {names[playing.cameraId] ?? "Unknown camera"} · {new Date(playing.startedAt).toLocaleString()} -

- -
-
- )} - {error && (

@@ -93,10 +77,18 @@ export default function ClipList({ clips, names }: { clips: Clip[]; names: Recor ) : ( - +