From e99eb77ca142d4173b33760f3bb971dcad70a346 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:14:37 -0500 Subject: [PATCH] Add Apple compatibility switch to the camera wizard (#24115) * add apple compatibility switch to the camera wizard * don't require every record stream to be h265 --- .../camera-wizard-apple-compatibility.spec.ts | 353 ++++++++++++++++++ web/public/locales/en/views/settings.json | 4 + .../settings/CameraWizardDialog.tsx | 7 + .../settings/wizard/Step3StreamConfig.tsx | 42 ++- .../settings/wizard/Step4Validation.tsx | 1 + web/src/types/cameraWizard.ts | 2 + web/src/utils/cameraUtil.ts | 23 ++ 7 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts diff --git a/web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts b/web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts new file mode 100644 index 0000000000..920b212dba --- /dev/null +++ b/web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts @@ -0,0 +1,353 @@ +/** + * Add-camera wizard - Apple/HEVC compatibility switch on Step 3. + * + * It writes the camera-level `ffmpeg.apple_compatibility` and starts on for + * Apple browsers. That default is user-agent driven, so the second describe + * pins an explicit Safari and Chrome UA instead of relying on the project's + * own. + * + * The save tests drive Step 4, which registers go2rtc streams and renders MSE + * previews; they mock those and assert only the captured config/set body. + */ + +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page, Locator } from "@playwright/test"; + +const MAIN_URI = "rtsp://admin:pw@192.168.1.100:554/stream1"; +const SUB_URI = "rtsp://admin:pw@192.168.1.100:554/stream2"; + +const SAFARI_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const CHROME_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +const APPLE_TITLE = "Improve playback on Apple devices"; + +const PROBE = { + success: true, + host: "192.168.1.100", + port: 80, + manufacturer: "Acme", + model: "Cam-1", + firmware_version: "1.0", + profiles_count: 2, + ptz_supported: false, + pan_tilt_supported: false, + presets_count: 0, + autotrack_supported: false, + rtsp_candidates: [ + { source: "GetStreamUri", profile_token: "profile_1", uri: MAIN_URI }, + { source: "GetStreamUri", profile_token: "profile_2", uri: SUB_URI }, + ], +}; + +function ffprobeJson(codec: string) { + return [ + { + return_code: 0, + stderr: [], + stdout: { + streams: [ + { + codec_type: "video", + codec_name: codec, + width: 1920, + height: 1080, + avg_frame_rate: "15/1", + }, + { codec_type: "audio", codec_name: "aac" }, + ], + }, + }, + ]; +} + +/** Mock ffprobe per stream URL; a null codec makes that probe fail. */ +async function mockFfprobe( + page: Page, + codecByUri: Record, +) { + await page.route("**/api/ffprobe**", (route) => { + const paths = new URL(route.request().url()).searchParams.get("paths"); + const match = Object.keys(codecByUri).find((uri) => paths?.includes(uri)); + const codec = match ? codecByUri[match] : null; + return route.fulfill({ + json: codec + ? ffprobeJson(codec) + : [{ return_code: 1, stderr: ["probe failed"], stdout: "" }], + }); + }); +} + +/** Open the wizard and drive Step 1 -> Step 2 -> Step 3. */ +async function gotoStep3(page: Page) { + await page.route("**/api/onvif/probe**", (route) => + route.fulfill({ json: PROBE }), + ); + + await page.getByRole("button", { name: /Add New Camera/i }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + await dialog.getByPlaceholder(/front_door/i).fill("hevc_test_camera"); + await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100"); + await dialog.getByRole("button", { name: /^Continue$/i }).click(); + + const next = dialog.getByRole("button", { name: /^Next$/i }); + await expect(next).toBeEnabled({ timeout: 10_000 }); + await next.click(); + + await expect( + dialog.getByRole("button", { name: /Add Another Stream/i }), + ).toBeVisible(); + return dialog; +} + +/** The role toggle for `role` on the nth stream card (0-based). */ +function roleSwitch(dialog: Locator, role: string, streamIndex = 0) { + return dialog + .locator("span.capitalize", { hasText: new RegExp(`^${role}$`) }) + .nth(streamIndex) + .locator("xpath=..") + .getByRole("switch"); +} + +/** Run "Test Connection" on the nth stream card and wait for the result. */ +async function testStream(dialog: Locator, streamIndex = 0) { + await dialog + .getByRole("button", { name: /Test Connection/i }) + .nth(streamIndex) + .click(); + await expect( + dialog.getByText("Connected", { exact: true }).nth(streamIndex), + ).toBeVisible(); +} + +/** Run "Test Connection" on the nth stream card and wait for it to fail. */ +async function failStream(dialog: Locator, streamIndex: number) { + await dialog + .getByRole("button", { name: /Test Connection/i }) + .nth(streamIndex) + .click(); + await expect(dialog.getByText("Test Failed", { exact: true })).toBeVisible(); +} + +function appleSwitch(dialog: Locator) { + return dialog + .locator("div.items-start.justify-between", { hasText: APPLE_TITLE }) + .getByRole("switch"); +} + +async function openCameraManagement(frigateApp: { + page: Page; + goto: (path: string) => Promise; +}) { + // not in the default mock; unmocked it 500s and trips the error collector + await frigateApp.page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: {} }), + ); + await frigateApp.goto("/settings?page=cameraManagement"); + await expect( + frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }), + ).toBeVisible(); +} + +test.describe("Camera wizard Apple compatibility @medium @mobile", () => { + test.beforeEach(async ({ frigateApp }) => { + await openCameraManagement(frigateApp); + }); + + test("appears only once the record stream is probed as H.265", async ({ + frigateApp, + }) => { + await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" }); + const dialog = await gotoStep3(frigateApp.page); + + // the probe leaves the stream untested, so the codec is unknown + await roleSwitch(dialog, "record").click(); + await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0); + + await testStream(dialog); + await expect(dialog.getByText(APPLE_TITLE)).toBeVisible(); + }); + + test("stays hidden for an H.264 record stream", async ({ frigateApp }) => { + await mockFfprobe(frigateApp.page, { [MAIN_URI]: "h264" }); + const dialog = await gotoStep3(frigateApp.page); + + await roleSwitch(dialog, "record").click(); + await testStream(dialog); + + await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0); + }); + + test("stays hidden for an H.265 stream with no recording role", async ({ + frigateApp, + }) => { + await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" }); + const dialog = await gotoStep3(frigateApp.page); + + // detect is assigned by default; no record or record_sub role + await testStream(dialog); + await expect(roleSwitch(dialog, "detect")).toBeChecked(); + + await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0); + }); + + test("appears for an H.265 record_sub stream", async ({ frigateApp }) => { + await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" }); + const dialog = await gotoStep3(frigateApp.page); + + await roleSwitch(dialog, "record_sub").click(); + await testStream(dialog); + + await expect(dialog.getByText(APPLE_TITLE)).toBeVisible(); + }); + + test("renders once when only one recording stream is H.265", async ({ + frigateApp, + }) => { + await mockFfprobe(frigateApp.page, { + [MAIN_URI]: "hevc", + [SUB_URI]: "h264", + }); + const dialog = await gotoStep3(frigateApp.page); + + await roleSwitch(dialog, "record").click(); + await testStream(dialog); + + await dialog.getByRole("button", { name: /Add Another Stream/i }).click(); + await roleSwitch(dialog, "record_sub", 1).click(); + await testStream(dialog, 1); + + // ffmpeg drops the tag on the H.264 output, so the H.265 one still wins + await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(1); + }); + + test("stays visible when another recording stream fails to probe", async ({ + frigateApp, + }) => { + await mockFfprobe(frigateApp.page, { + [MAIN_URI]: "hevc", + [SUB_URI]: null, + }); + const dialog = await gotoStep3(frigateApp.page); + + await roleSwitch(dialog, "record").click(); + await testStream(dialog); + await expect(dialog.getByText(APPLE_TITLE)).toBeVisible(); + + await dialog.getByRole("button", { name: /Add Another Stream/i }).click(); + await roleSwitch(dialog, "record_sub", 1).click(); + await failStream(dialog, 1); + + await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(1); + }); +}); + +test.describe("Camera wizard Apple compatibility default @medium @mobile", () => { + test.describe("on an Apple browser", () => { + test.use({ userAgent: SAFARI_UA }); + + test.beforeEach(async ({ frigateApp }) => { + await openCameraManagement(frigateApp); + }); + + test("starts on for an H.265 record stream and is saved", async ({ + frigateApp, + }) => { + const ffmpeg = await saveHevcCamera(frigateApp.page, { + startsOn: true, + toggle: false, + }); + expect(ffmpeg.apple_compatibility).toBe(true); + }); + + test("can still be turned off, which omits it from the save", async ({ + frigateApp, + }) => { + const ffmpeg = await saveHevcCamera(frigateApp.page, { + startsOn: true, + toggle: true, + }); + expect(ffmpeg).not.toHaveProperty("apple_compatibility"); + }); + }); + + test.describe("on a non-Apple browser", () => { + test.use({ userAgent: CHROME_UA }); + + test.beforeEach(async ({ frigateApp }) => { + await openCameraManagement(frigateApp); + }); + + test("starts off and is omitted so the global applies", async ({ + frigateApp, + }) => { + const ffmpeg = await saveHevcCamera(frigateApp.page, { + startsOn: false, + toggle: false, + }); + expect(ffmpeg).not.toHaveProperty("apple_compatibility"); + }); + + test("can be turned on, which writes it at camera level", async ({ + frigateApp, + }) => { + const ffmpeg = await saveHevcCamera(frigateApp.page, { + startsOn: false, + toggle: true, + }); + expect(ffmpeg.apple_compatibility).toBe(true); + }); + }); +}); + +/** + * Drive the whole wizard for an H.265 record stream, asserting the switch's + * starting state and optionally toggling it, then return the `ffmpeg` section + * of the camera that config/set received. + */ +async function saveHevcCamera( + page: Page, + { startsOn, toggle }: { startsOn: boolean; toggle: boolean }, +) { + await mockFfprobe(page, { [MAIN_URI]: "hevc" }); + + const saved: Record[] = []; + await page.route("**/api/config/set", (route) => { + saved.push(route.request().postDataJSON()); + return route.fulfill({ json: { success: true, require_restart: false } }); + }); + + const dialog = await gotoStep3(page); + await roleSwitch(dialog, "record").click(); + await testStream(dialog); + + await expect(appleSwitch(dialog)).toBeChecked({ checked: startsOn }); + if (toggle) { + await appleSwitch(dialog).click(); + await expect(appleSwitch(dialog)).toBeChecked({ checked: !startsOn }); + } + + await dialog.getByRole("button", { name: /^Next$/i }).click(); + const save = dialog.getByRole("button", { name: /Save New Camera/i }); + await expect(save).toBeEnabled({ timeout: 15_000 }); + await save.click(); + + // the camera PUT is the one carrying update_topic; go2rtc follows without it + await expect + .poll(() => saved.some((body) => "update_topic" in body), { + timeout: 15_000, + }) + .toBe(true); + + const cameraSave = saved.find((body) => "update_topic" in body) as { + update_topic: string; + config_data: { + cameras: Record; + }; + }; + expect(cameraSave.update_topic).toBe("config/cameras/hevc_test_camera/add"); + return cameraSave.config_data.cameras.hevc_test_camera.ffmpeg; +} diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 974de49e06..32338a0e92 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -427,6 +427,10 @@ "notConnected": "Not Connected", "featuresTitle": "Features", "go2rtc": "Reduce connections to camera", + "appleCompatibility": { + "title": "Improve playback on Apple devices", + "description": "Turn this on if you watch recordings in Safari or on an iPhone, iPad, or Mac." + }, "detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.", "rolesPopover": { "title": "Stream Roles", diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index f1cd4d8788..147939acf1 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -23,6 +23,7 @@ import type { import { processCameraName, calculateDetectDimensions, + hevcRecordingStreamId, } from "@/utils/cameraUtil"; import { cn } from "@/lib/utils"; @@ -185,6 +186,11 @@ export default function CameraWizardDialog({ wizardData.cameraName, ); + // re-checked here: roles and codecs may have changed since it was set + const appleCompatibility = + !!wizardData.appleCompatibility && + !!hevcRecordingStreamId(wizardData.streams); + // Convert wizard data to Frigate config format const configData: CameraConfigData = { cameras: { @@ -192,6 +198,7 @@ export default function CameraWizardDialog({ enabled: true, ...(friendlyName && { friendly_name: friendlyName }), ffmpeg: { + ...(appleCompatibility && { apple_compatibility: true }), inputs: wizardData.streams.map((stream, index) => { if (stream.restream) { const go2rtcStreamName = diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx index 9d4e97b89f..7d1c0bc6ce 100644 --- a/web/src/components/settings/wizard/Step3StreamConfig.tsx +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -26,7 +26,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; -import { isMobile } from "react-device-detect"; +import { isIOS, isMobile, isSafari } from "react-device-detect"; import { LuInfo, LuExternalLink, @@ -53,6 +53,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { hevcRecordingStreamId } from "@/utils/cameraUtil"; // Recording the sub stream from the same stream as record would just // re-record the main stream, so the two roles are mutually exclusive. @@ -389,6 +390,22 @@ export default function Step3StreamConfig({ const hasDetectRole = streams.some((s) => s.roles.includes("detect")); + const appleCompatibilityStreamId = useMemo( + () => hevcRecordingStreamId(streams), + [streams], + ); + + useEffect(() => { + // undefined, not false: a deliberate toggle-off must not be re-seeded + if ( + (isSafari || isIOS) && + appleCompatibilityStreamId && + wizardData.appleCompatibility === undefined + ) { + onUpdate({ appleCompatibility: true }); + } + }, [appleCompatibilityStreamId, wizardData.appleCompatibility, onUpdate]); + return (
@@ -778,7 +795,7 @@ export default function Step3StreamConfig({
-
+
{t("cameraWizard.step3.go2rtc")} @@ -788,6 +805,27 @@ export default function Step3StreamConfig({ onCheckedChange={() => setRestream(stream.id)} />
+ + {appleCompatibilityStreamId === stream.id && ( +
+
+
+ {t("cameraWizard.step3.appleCompatibility.title")} +
+

+ {t( + "cameraWizard.step3.appleCompatibility.description", + )} +

+
+ + onUpdate({ appleCompatibility: checked }) + } + /> +
+ )}
diff --git a/web/src/components/settings/wizard/Step4Validation.tsx b/web/src/components/settings/wizard/Step4Validation.tsx index d2e2c3e0f2..d1bd9bbfbe 100644 --- a/web/src/components/settings/wizard/Step4Validation.tsx +++ b/web/src/components/settings/wizard/Step4Validation.tsx @@ -268,6 +268,7 @@ export default function Step4Validation({ customUrl: wizardData.customUrl, streams: wizardData.streams, hasBackchannel: wizardData.hasBackchannel, + appleCompatibility: wizardData.appleCompatibility, onvif: wizardData.onvif, }; diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts index 07d5688013..18aeb42b41 100644 --- a/web/src/types/cameraWizard.ts +++ b/web/src/types/cameraWizard.ts @@ -119,6 +119,7 @@ export type WizardFormData = { probeCandidates?: string[]; // candidate URLs from probe candidateTests?: CandidateTestMap; // test results for candidates hasBackchannel?: boolean; // true if camera supports backchannel audio + appleCompatibility?: boolean; // camera level, covers both recording outputs onvif?: { enabled: boolean; host: string; @@ -163,6 +164,7 @@ export type CameraConfigData = { enabled: boolean; friendly_name?: string; ffmpeg: { + apple_compatibility?: boolean; inputs: { path: string; roles: string[]; diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts index 11df0550fc..94c11b690e 100644 --- a/web/src/utils/cameraUtil.ts +++ b/web/src/utils/cameraUtil.ts @@ -1,6 +1,7 @@ import { baseUrl } from "@/api/baseUrl"; import { generateFixedHash, isValidId } from "./stringUtil"; import type { LiveStreamMetadata } from "@/types/live"; +import type { StreamConfig } from "@/types/cameraWizard"; /** * Processes a user-entered camera name and returns both the final camera name @@ -205,3 +206,25 @@ const REPLAY_CAMERA_PREFIX = "_replay_"; export function isReplayCamera(name: string): boolean { return name.startsWith(REPLAY_CAMERA_PREFIX); } + +const HEVC_CODEC_NAMES = ["hevc", "h265"]; + +function isHevcCodec(codec?: string): boolean { + return HEVC_CODEC_NAMES.includes((codec ?? "").trim().toLowerCase()); +} + +function isRecordingStream(stream: StreamConfig): boolean { + return stream.roles.includes("record") || stream.roles.includes("record_sub"); +} + +/** + * First recording stream probed as H.265. The other record output's codec + * doesn't matter: ffmpeg drops `-tag:v hvc1` on anything that isn't HEVC. + */ +export function hevcRecordingStreamId( + streams: StreamConfig[], +): string | undefined { + return streams.find( + (s) => isRecordingStream(s) && isHevcCodec(s.testResult?.videoCodec), + )?.id; +}