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
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent fb8ab56c41
commit 98706482f5
7 changed files with 430 additions and 2 deletions
@@ -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 =
@@ -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 (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
@@ -778,7 +795,7 @@ export default function Step3StreamConfig({
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="space-y-3 rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step3.go2rtc")}
@@ -788,6 +805,27 @@ export default function Step3StreamConfig({
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
{appleCompatibilityStreamId === stream.id && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="text-sm">
{t("cameraWizard.step3.appleCompatibility.title")}
</div>
<p className="text-xs text-muted-foreground">
{t(
"cameraWizard.step3.appleCompatibility.description",
)}
</p>
</div>
<Switch
checked={wizardData.appleCompatibility ?? false}
onCheckedChange={(checked) =>
onUpdate({ appleCompatibility: checked })
}
/>
</div>
)}
</div>
</div>
</CardContent>
@@ -268,6 +268,7 @@ export default function Step4Validation({
customUrl: wizardData.customUrl,
streams: wizardData.streams,
hasBackchannel: wizardData.hasBackchannel,
appleCompatibility: wizardData.appleCompatibility,
onvif: wizardData.onvif,
};
+2
View File
@@ -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[];
+23
View File
@@ -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;
}