Play recordings in a pop-out window, like the live view
Play now opens /recordings/play?clip=… in a window of its own: the video on black, fitted to the window, with a fading overlay naming the camera and the time. One window is reused for whichever clip is opened next, and it stays an ordinary link so a blocked popup still opens a tab. The page checks the session and resolves the clip inside the recordings folder. Also add DOM cleanup to three test files that had none: renders piled up in the document and tests read each other's markup, which is what made the new test pass or fail depending on its position in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5886c010ea
commit
0acd69b9ca
@ -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"}
|
||||
|
||||
@ -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 }) }));
|
||||
|
||||
|
||||
@ -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 }) => (
|
||||
<div data-testid="player">{`${name} ${cameraId} ${stream}`}</div>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<string, string> }) {
|
||||
const router = useRouter();
|
||||
const [playing, setPlaying] = useState<Clip | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{playing && (
|
||||
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">
|
||||
{names[playing.cameraId] ?? "Unknown camera"} · {new Date(playing.startedAt).toLocaleString()}
|
||||
</h2>
|
||||
<button onClick={() => setPlaying(null)} className="inline-flex items-center gap-1 text-sm underline">
|
||||
<X aria-hidden className="size-4" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<video key={playing.id} src={clipUrl(playing.id)} controls autoPlay className="mt-2 w-full rounded bg-black" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="inline-flex items-center gap-1.5 text-sm text-red-600">
|
||||
<CircleAlert aria-hidden className="size-4 shrink-0" />
|
||||
@ -93,10 +77,18 @@ export default function ClipList({ clips, names }: { clips: Clip[]; names: Recor
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-3">
|
||||
<button onClick={() => setPlaying(clip)} className="inline-flex items-center gap-1 underline">
|
||||
{/* A link, so it still opens a tab if popups are blocked. */}
|
||||
<a
|
||||
href={clipPopoutUrl(clip.id)}
|
||||
target={CLIP_WINDOW_NAME}
|
||||
onClick={(e) => {
|
||||
if (openClipPopout(clip.id)) e.preventDefault();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 underline"
|
||||
>
|
||||
<Play aria-hidden className="size-4" />
|
||||
Play
|
||||
</button>
|
||||
</a>
|
||||
<button onClick={() => setConfirming(clip.id)} className="inline-flex items-center gap-1 underline">
|
||||
<Trash2 aria-hidden className="size-4" />
|
||||
Delete
|
||||
|
||||
47
src/app/recordings/play/clip-player.test.tsx
Normal file
47
src/app/recordings/play/clip-player.test.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
import ClipPlayer from "./clip-player";
|
||||
|
||||
/** Without this each render piles up in the document and tests read each other's DOM. */
|
||||
afterEach(cleanup);
|
||||
|
||||
const clip: Clip = {
|
||||
id: "cam-22ec0000-8b90-11b5-845d-d03bf404af54-sub-rec/2026-09-19_17-06-18-101298.mp4",
|
||||
cameraId: "22ec0000-8b90-11b5-845d-d03bf404af54",
|
||||
startedAt: "2026-09-19T22:06:18.000Z",
|
||||
bytes: 2_582_503,
|
||||
};
|
||||
|
||||
/** The strip over the video: the only element holding the clip's time. */
|
||||
const overlay = () => document.querySelector("time")!.closest("div")!;
|
||||
|
||||
describe("ClipPlayer", () => {
|
||||
it("fades the overlay when the mouse is still and brings it back on movement", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
render(<ClipPlayer clip={clip} name="Porch" />);
|
||||
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("plays the clip through our own route, with the camera and time over it", () => {
|
||||
render(<ClipPlayer clip={clip} name="Porch" />);
|
||||
const video = screen.getByLabelText("Porch recording") as HTMLVideoElement;
|
||||
expect(video.getAttribute("src")).toBe(`/api/recordings/clip?clip=${encodeURIComponent(clip.id)}`);
|
||||
expect(video.controls).toBe(true);
|
||||
expect(video.className).toContain("max-h-full");
|
||||
expect(overlay().textContent).toContain("Porch");
|
||||
expect(document.querySelector("time")?.getAttribute("dateTime")).toBe(clip.startedAt);
|
||||
});
|
||||
|
||||
});
|
||||
47
src/app/recordings/play/clip-player.tsx
Normal file
47
src/app/recordings/play/clip-player.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Film } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
|
||||
/** How long the overlay stays after the mouse stops moving, as in the live pop-out. */
|
||||
const OVERLAY_MS = 2500;
|
||||
|
||||
export default function ClipPlayer({ clip, name }: { clip: Clip; name: string }) {
|
||||
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 (
|
||||
<div className="absolute inset-0 flex items-center justify-center" onMouseMove={poke}>
|
||||
<video
|
||||
src={`/api/recordings/clip?clip=${encodeURIComponent(clip.id)}`}
|
||||
controls
|
||||
autoPlay
|
||||
aria-label={`${name} recording`}
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 flex flex-wrap items-center gap-2 bg-black/60 px-4 py-2 text-sm transition-opacity motion-reduce:transition-none ${
|
||||
active ? "opacity-100" : "opacity-0"
|
||||
} focus-within:opacity-100`}
|
||||
>
|
||||
<Film aria-hidden className="size-4 shrink-0" />
|
||||
<span className="font-medium">{name}</span>
|
||||
<time dateTime={clip.startedAt} className="text-zinc-300">
|
||||
{new Date(clip.startedAt).toLocaleString()}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/app/recordings/play/page.test.tsx
Normal file
86
src/app/recordings/play/page.test.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Clip } from "@/lib/recordings";
|
||||
|
||||
const requirePageAccess = vi.fn();
|
||||
vi.mock("@/lib/access", () => ({ requirePageAccess }));
|
||||
|
||||
const listClips = vi.fn<() => Promise<Clip[]>>();
|
||||
const allCameraRecords = vi.fn();
|
||||
vi.mock("@/lib/recordings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/recordings")>()),
|
||||
listClips,
|
||||
}));
|
||||
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||
allCameraRecords,
|
||||
}));
|
||||
|
||||
class NotFound extends Error {}
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new NotFound();
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: PlayClipPage, generateMetadata } = await import("./page");
|
||||
|
||||
const ID = "22ec0000-8b90-11b5-845d-d03bf404af54";
|
||||
const CLIP = `cam-${ID}-sub-rec/2026-09-19_17-06-18-101298.mp4`;
|
||||
const clip: Clip = { id: CLIP, cameraId: ID, startedAt: "2026-09-19T22:06:18.000Z", bytes: 2_582_503 };
|
||||
const record = { id: ID, urn: `urn:uuid:${ID}`, host: "192.168.17.129", port: 80, name: "Porch", lastSeen: "" };
|
||||
|
||||
const props = (id: string = CLIP) => ({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({ clip: id }),
|
||||
});
|
||||
/** No ?clip= at all, which a default parameter would quietly paper over. */
|
||||
const noClip = { params: Promise.resolve({}), searchParams: Promise.resolve({}) };
|
||||
const markup = async (id?: string) => renderToStaticMarkup(await PlayClipPage(props(id)));
|
||||
|
||||
beforeEach(() => {
|
||||
requirePageAccess.mockReset().mockResolvedValue(undefined);
|
||||
listClips.mockReset().mockResolvedValue([clip]);
|
||||
allCameraRecords.mockReset().mockResolvedValue([record]);
|
||||
});
|
||||
|
||||
describe("/recordings/play", () => {
|
||||
it("checks access before looking anything up", async () => {
|
||||
requirePageAccess.mockRejectedValue(new Error("redirect /login"));
|
||||
await expect(PlayClipPage(props())).rejects.toThrow("redirect /login");
|
||||
await expect(generateMetadata(props())).rejects.toThrow("redirect /login");
|
||||
expect(listClips).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plays the clip through our own route, with the camera and time shown", async () => {
|
||||
const html = await markup();
|
||||
expect(html).toContain(`/api/recordings/clip?clip=${encodeURIComponent(CLIP)}`.replace(/&/g, "&"));
|
||||
expect(html).toContain("Porch");
|
||||
expect(html).toContain('dateTime="2026-09-19T22:06:18.000Z"');
|
||||
});
|
||||
|
||||
it("titles the window after the camera and the time", async () => {
|
||||
const { title } = await generateMetadata(props());
|
||||
expect(title).toContain("Porch");
|
||||
});
|
||||
|
||||
it("falls back to the address for an unnamed camera, and says so for an unknown one", async () => {
|
||||
allCameraRecords.mockResolvedValue([{ ...record, name: undefined }]);
|
||||
expect(await markup()).toContain("192.168.17.129:80");
|
||||
allCameraRecords.mockResolvedValue([]);
|
||||
expect(await markup()).toContain("Unknown camera");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a clip id that isn't ours", "../../etc/passwd"],
|
||||
["a clip that isn't on disk", `cam-${ID}-main-rec/2026-01-01_00-00-00-000000.mp4`],
|
||||
["an empty clip id", ""],
|
||||
])("is not found for %s", async (_label, id) => {
|
||||
await expect(PlayClipPage(props(id))).rejects.toBeInstanceOf(NotFound);
|
||||
});
|
||||
|
||||
it("is not found when no clip is named at all", async () => {
|
||||
await expect(PlayClipPage(noClip)).rejects.toBeInstanceOf(NotFound);
|
||||
await expect(generateMetadata(noClip)).rejects.toBeInstanceOf(NotFound);
|
||||
});
|
||||
});
|
||||
45
src/app/recordings/play/page.tsx
Normal file
45
src/app/recordings/play/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { requirePageAccess } from "@/lib/access";
|
||||
import { allCameraRecords } from "@/lib/camera-registry";
|
||||
import { cameraIdFromFolder, clipFile, listClips } from "@/lib/recordings";
|
||||
import ClipPlayer from "./clip-player";
|
||||
|
||||
/**
|
||||
* One motion clip, filling a pop-out window, like the live view (vrek iss-1rc5j2z). The
|
||||
* clip is named by folder and file name, both checked against fixed patterns and resolved
|
||||
* inside the recordings folder, so a request can only name a clip that folder holds.
|
||||
*/
|
||||
|
||||
async function clipFor(id: string) {
|
||||
if (!clipFile(id)) notFound();
|
||||
const clip = (await listClips()).find((c) => c.id === id);
|
||||
if (!clip) notFound();
|
||||
return clip;
|
||||
}
|
||||
|
||||
async function cameraName(cameraId: string): Promise<string> {
|
||||
const camera = (await allCameraRecords()).find((c) => c.id === cameraId);
|
||||
return camera?.name ?? (camera ? `${camera.host}:${camera.port}` : "Unknown camera");
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: PageProps<"/recordings/play">): Promise<Metadata> {
|
||||
await requirePageAccess();
|
||||
const { clip: id } = await searchParams;
|
||||
const clip = await clipFor(typeof id === "string" ? id : "");
|
||||
const name = await cameraName(clip.cameraId);
|
||||
return { title: `${name} · ${new Date(clip.startedAt).toLocaleString()}` };
|
||||
}
|
||||
|
||||
export default async function PlayClipPage({ searchParams }: PageProps<"/recordings/play">) {
|
||||
await requirePageAccess();
|
||||
const { clip: id } = await searchParams;
|
||||
const clip = await clipFor(typeof id === "string" ? id : "");
|
||||
const owner = cameraIdFromFolder(clip.id.split("/")[0]) ?? clip.cameraId;
|
||||
|
||||
return (
|
||||
<main className="relative flex-1 bg-black font-sans text-white">
|
||||
<ClipPlayer clip={clip} name={await cameraName(owner)} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user