Add sub stream recording with adaptive quality playback (#24009)

* add sub stream recording with adaptive quality playback

Optionally record a second, lower bitrate stream alongside the main
recording stream via a `record_sub` input role and `record.sub` config block, with its own retention windows.
Recordings rows now carry the stream type plus the media details needed to serve both streams from one manifest: video codec, audio presence, audio codec and rate, and a record-time keyframe index.

Playback resolves coverage across both streams and merges them into a single VOD sequence, falling back to a discontinuity manifest with per-clip init segments when the media signatures differ. The player exposes a quality selector, and an auto governor picks the stream from stall time, bandwidth, codec support, and the save-data hint.

* fix tests and i18n
This commit is contained in:
Josh Hawkins
2026-08-22 11:40:42 -05:00
parent 8de6216c61
commit d67304a84d
68 changed files with 6813 additions and 602 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+25
View File
@@ -867,6 +867,31 @@ test.describe("Multi-Camera Export from History @high", () => {
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
route.fulfill({ json: [] }),
);
// registered after the broad recordings route so it wins the match;
// coverage is an object, and the array above crashes the player
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
route.fulfill({
json: {
spans: [
{
start_time: playbackTime - 3600,
end_time: playbackTime + 600,
streams: ["main"],
},
],
codecs_compatible: true,
streams: {
main: {
video_codec: "h264",
audio_rate: null,
audio_codec: null,
has_audio: false,
bitrate: 2_000_000,
},
},
},
}),
);
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
}
+16 -1
View File
@@ -13,6 +13,20 @@
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
},
"cameraOff": "Camera is off",
"quality": {
"auto": "Auto",
"autoLow": "Playing low quality (limited bandwidth)",
"autoLowCodec": "Playing low quality (Original not supported by this browser)",
"autoLowSaveData": "Playing low quality (data saver)",
"main": "Original",
"sub": "Low",
"notSupportedBrowser": "Not supported by this browser",
"label": "Quality",
"noAudio": "No audio",
"audioRate": "{{rate}} kHz audio",
"audioCodecRate": "{{codec}} {{rate}} kHz",
"noRecordings": "No recordings in this time range"
},
"stats": {
"streamType": {
"title": "Stream Type:",
@@ -46,7 +60,8 @@
"submittedFrigatePlus": "Successfully submitted frame to Frigate+"
},
"error": {
"submitFrigatePlusFailed": "Failed to submit frame to Frigate+"
"submitFrigatePlusFailed": "Failed to submit frame to Frigate+",
"playRecordingsFailed": "Failed to play recordings (error {{code}}): {{message}}"
}
}
}
+52
View File
@@ -196,6 +196,10 @@
"record": {
"label": "Record output arguments",
"description": "Default output arguments for record role streams."
},
"record_sub": {
"label": "Sub stream record output arguments",
"description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set."
}
},
"retry_interval": {
@@ -522,6 +526,54 @@
"description": "Preview quality level (very_low, low, medium, high, very_high)."
}
},
"sub": {
"label": "Sub stream recording",
"description": "Settings for recording a second, lower quality stream.",
"enabled": {
"label": "Enable sub stream recording",
"description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention."
},
"continuous": {
"label": "Sub stream continuous retention",
"description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.",
"days": {
"label": "Retention days",
"description": "Days to retain recordings."
}
},
"motion": {
"label": "Sub stream motion retention",
"description": "Number of days to retain sub stream recordings triggered by motion.",
"days": {
"label": "Retention days",
"description": "Days to retain recordings."
}
},
"alerts": {
"label": "Sub stream alert retention",
"description": "Retention settings for sub stream recordings of alerts.",
"days": {
"label": "Retention days",
"description": "Number of days to retain recordings of detection events."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
}
},
"detections": {
"label": "Sub stream detection retention",
"description": "Retention settings for sub stream recordings of detections.",
"days": {
"label": "Retention days",
"description": "Number of days to retain recordings of detection events."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
}
}
},
"enabled_in_config": {
"label": "Original recording state",
"description": "Indicates whether recording was enabled in the original static configuration."
+52
View File
@@ -707,6 +707,10 @@
"record": {
"label": "Record output arguments",
"description": "Default output arguments for record role streams."
},
"record_sub": {
"label": "Sub stream record output arguments",
"description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set."
}
},
"retry_interval": {
@@ -1048,6 +1052,54 @@
"description": "Preview quality level (very_low, low, medium, high, very_high)."
}
},
"sub": {
"label": "Sub stream recording",
"description": "Settings for recording a second, lower quality stream.",
"enabled": {
"label": "Enable sub stream recording",
"description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention."
},
"continuous": {
"label": "Sub stream continuous retention",
"description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.",
"days": {
"label": "Retention days",
"description": "Days to retain recordings."
}
},
"motion": {
"label": "Sub stream motion retention",
"description": "Number of days to retain sub stream recordings triggered by motion.",
"days": {
"label": "Retention days",
"description": "Days to retain recordings."
}
},
"alerts": {
"label": "Sub stream alert retention",
"description": "Retention settings for sub stream recordings of alerts.",
"days": {
"label": "Retention days",
"description": "Number of days to retain recordings of detection events."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
}
},
"detections": {
"label": "Sub stream detection retention",
"description": "Retention settings for sub stream recordings of detections.",
"days": {
"label": "Retention days",
"description": "Number of days to retain recordings of detection events."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
}
}
},
"enabled_in_config": {
"label": "Original recording state",
"description": "Indicates whether recording was enabled in the original static configuration."
+1
View File
@@ -21,6 +21,7 @@
},
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"subOnlyQuality": "Only low quality recordings are available in this time range",
"events": {
"label": "Events",
"aria": "Select events",
+7 -1
View File
@@ -398,6 +398,7 @@
"title": "Stream Roles",
"detect": "Main feed for object detection.",
"record": "Saves segments of the video feed based on configuration settings.",
"record_sub": "Lower quality recordings for adaptive playback and extended retention (typically the camera sub stream).",
"audio": "Feed for audio based detection."
},
"featuresPopover": {
@@ -1528,6 +1529,7 @@
"manual": "Manual arguments",
"inherit": "Inherit from camera setting",
"none": "None",
"sameAsRecord": "Same as record output arguments",
"useGlobalSetting": "Inherit from global setting",
"selectPreset": "Select preset",
"manualPlaceholder": "Enter FFmpeg arguments",
@@ -1648,9 +1650,12 @@
"inputRoles": {
"summary": "{{count}} roles selected",
"empty": "No roles available",
"roleInUse": "Already assigned to another stream",
"recordSubConflict": "A stream cannot have both record and record_sub roles",
"options": {
"detect": "Detect",
"record": "Record",
"record_sub": "Record (Sub Stream)",
"audio": "Audio"
}
},
@@ -1952,7 +1957,8 @@
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function."
"noRecordRole": "No streams have the record role defined. Recording will not function.",
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
@@ -20,6 +20,13 @@ const ffmpegArgsWidget = (
},
});
const recordSubArgsWidget = () =>
ffmpegArgsWidget("output_args.record_sub", {
allowInherit: true,
forceSplitLayout: true,
unsetLabelKey: "configForm.ffmpegArgs.sameAsRecord",
});
const ffmpeg: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/ffmpeg_presets",
@@ -75,6 +82,8 @@ const ffmpeg: SectionConfigOverrides = {
output_args: "/configuration/ffmpeg_presets#output-args-presets",
"inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets",
"output_args.record": "/configuration/ffmpeg_presets#output-args-presets",
"output_args.record_sub":
"/configuration/ffmpeg_presets#output-args-presets",
"inputs.roles": "/configuration/cameras/#setting-up-camera-inputs",
apple_compatibility:
"/configuration/camera_specific#h265-cameras-via-safari",
@@ -112,9 +121,11 @@ const ffmpeg: SectionConfigOverrides = {
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
inputs: {
@@ -148,6 +159,7 @@ const ffmpeg: SectionConfigOverrides = {
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
},
@@ -176,6 +188,7 @@ const ffmpeg: SectionConfigOverrides = {
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
record_sub: recordSubArgsWidget(),
},
},
},
@@ -15,6 +15,19 @@ const record: SectionConfigOverrides = {
);
},
},
{
key: "no-record-sub-role",
messageKey: "configMessages.record.noRecordSubRole",
severity: "warning",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
const sub = ctx.formData?.sub as Record<string, unknown> | undefined;
if (!sub?.enabled) return false;
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) =>
i.roles?.includes("record_sub"),
);
},
},
],
fieldDocs: {
"alerts.pre_capture":
@@ -34,6 +47,7 @@ const record: SectionConfigOverrides = {
"motion",
"alerts",
"detections",
"sub",
"preview",
"export",
],
@@ -67,6 +81,12 @@ const record: SectionConfigOverrides = {
"detections.retain.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
"sub.alerts.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
"sub.detections.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
preview: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
quality: {
@@ -193,6 +193,25 @@ export function CameraInputsField(props: FieldProps) {
}
}, [fieldPathId.path, inputs, onChange]);
const getRolesUsedByOtherInputs = useCallback(
(index: number): string[] => {
const used = new Set<string>();
inputs.forEach((input, currentIndex) => {
if (currentIndex === index || !Array.isArray(input.roles)) {
return;
}
input.roles.forEach((role) => {
if (typeof role === "string") {
used.add(role);
}
});
});
return [...used];
},
[inputs],
);
const handleFieldValueChange = useCallback(
(index: number, fieldName: string, nextValue: unknown) => {
const nextInputs = cloneDeep(inputs);
@@ -466,7 +485,16 @@ export function CameraInputsField(props: FieldProps) {
/>
</div>
<div className="w-full">{renderField(index, "roles")}</div>
<div className="w-full">
{renderField(index, "roles", {
extraUiSchema: {
"ui:options": {
rolesUsedByOtherInputs:
getRolesUsedByOtherInputs(index),
},
},
})}
</div>
{renderField(index, "input_args")}
@@ -30,6 +30,7 @@ type PresetField =
| "hwaccel_args"
| "input_args"
| "output_args.record"
| "output_args.record_sub"
| "output_args.detect";
const getPresetOptions = (
@@ -49,7 +50,10 @@ const getPresetOptions = (
}
if (field.startsWith("output_args.")) {
const key = field.split(".")[1] as "record" | "detect";
const key =
field === "output_args.record_sub"
? "record"
: (field.split(".")[1] as "record" | "detect");
return data.output_args?.[key] ?? [];
}
@@ -127,6 +131,7 @@ export function FfmpegArgsWidget(props: WidgetProps) {
const globalFieldPath =
(options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField;
const allowInherit = options?.allowInherit === true;
const unsetLabelKey = options?.unsetLabelKey as string | undefined;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
@@ -287,6 +292,12 @@ export function FfmpegArgsWidget(props: WidgetProps) {
: "ffmpeg.output_args.record.description";
}
if (presetField === "output_args.record_sub") {
return isInputScoped
? "ffmpeg.inputs.output_args.record_sub.description"
: "ffmpeg.output_args.record_sub.description";
}
if (presetField === "output_args.detect") {
return isInputScoped
? "ffmpeg.inputs.output_args.detect.description"
@@ -345,7 +356,9 @@ export function FfmpegArgsWidget(props: WidgetProps) {
}
/>
<label htmlFor={`${id}-inherit`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.inherit", { ns: "views/settings" })}
{t(unsetLabelKey ?? "configForm.ffmpegArgs.inherit", {
ns: "views/settings",
})}
</label>
</div>
) : (
@@ -361,7 +374,9 @@ export function FfmpegArgsWidget(props: WidgetProps) {
}
/>
<label htmlFor={`${id}-none`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.none", { ns: "views/settings" })}
{t(unsetLabelKey ?? "configForm.ffmpegArgs.none", {
ns: "views/settings",
})}
</label>
</div>
)}
@@ -3,7 +3,14 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
const INPUT_ROLES = ["detect", "record", "audio"] as const;
const INPUT_ROLES = ["detect", "record", "record_sub", "audio"] as const;
// Recording the sub stream from the same input as record would just
// re-record the main stream, so the two roles are mutually exclusive.
const CONFLICTING_ROLES: Partial<Record<string, string>> = {
record: "record_sub",
record_sub: "record",
};
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
@@ -18,11 +25,18 @@ function normalizeValue(value: unknown): string[] {
}
export function InputRolesWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
const { id, value, disabled, readonly, onChange, options } = props;
const { t } = useTranslation(["views/settings"]);
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
// Each role may only be assigned to a single input, so roles already
// used by sibling inputs are locked.
const rolesUsedByOtherInputs = useMemo(
() => normalizeValue(options?.rolesUsedByOtherInputs),
[options],
);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
if (!selectedRoles.includes(role)) {
@@ -39,6 +53,20 @@ export function InputRolesWidget(props: WidgetProps) {
<div className="grid gap-2">
{INPUT_ROLES.map((role) => {
const checked = selectedRoles.includes(role);
const usedByOtherInput =
!checked && rolesUsedByOtherInputs.includes(role);
const conflictingRole = CONFLICTING_ROLES[role];
const hasConflict =
!checked &&
conflictingRole !== undefined &&
selectedRoles.includes(conflictingRole);
const hint = usedByOtherInput
? t("configForm.inputRoles.roleInUse", { ns: "views/settings" })
: hasConflict
? t("configForm.inputRoles.recordSubConflict", {
ns: "views/settings",
})
: undefined;
const label = t(`configForm.inputRoles.options.${role}`, {
ns: "views/settings",
defaultValue: role,
@@ -49,13 +77,20 @@ export function InputRolesWidget(props: WidgetProps) {
key={role}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
<div className="flex flex-col">
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
{hint ? (
<span className="text-xs text-muted-foreground">{hint}</span>
) : null}
</div>
<Switch
id={`${id}-${role}`}
checked={checked}
disabled={disabled || readonly}
disabled={
disabled || readonly || usedByOtherInput || hasConflict
}
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
/>
</div>
@@ -14,12 +14,10 @@ import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa";
import { isDesktop, isMobile } from "react-device-detect";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import MobileReviewSettingsDrawer, {
DrawerFeatures,
} from "../overlay/MobileReviewSettingsDrawer";
import MobileReviewSettingsDrawer from "../overlay/MobileReviewSettingsDrawer";
import useOptimisticState from "@/hooks/use-optimistic-state";
import FilterSwitch from "./FilterSwitch";
import { FilterList, GeneralFilter } from "@/types/filter";
import { DrawerFeatures, FilterList, GeneralFilter } from "@/types/filter";
import CalendarFilterButton from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
@@ -10,7 +10,12 @@ import {
DebugReplayContent,
SaveDebugReplayOverlay,
} from "./DebugReplayDialog";
import { ExportMode, GeneralFilter } from "@/types/filter";
import {
DEFAULT_DRAWER_FEATURES,
DrawerFeatures,
ExportMode,
GeneralFilter,
} from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
import {
@@ -31,6 +36,14 @@ import { StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import { FaTriangleExclamation } from "react-icons/fa6";
import { MdHighQuality } from "react-icons/md";
import { QualitySelectorContent } from "../player/QualitySelector";
import {
AutoQualityReason,
PlaybackQuality,
RecordingCoverage,
} from "@/types/record";
type DrawerMode =
| "none"
@@ -39,25 +52,8 @@ type DrawerMode =
| "calendar"
| "filter"
| "debug-replay"
| "share-timestamp";
const DRAWER_FEATURES = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
| "share-timestamp"
| "quality";
type MobileReviewSettingsDrawerProps = {
features?: DrawerFeatures[];
@@ -84,6 +80,12 @@ type MobileReviewSettingsDrawerProps = {
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
setShowExportPreview: (showPreview: boolean) => void;
quality?: PlaybackQuality;
onSetQuality?: (quality: PlaybackQuality) => void;
qualityStreams?: RecordingCoverage["streams"];
qualityAutoLow?: boolean;
qualityAutoLowReason?: AutoQualityReason;
qualityMainUnsupported?: boolean;
};
export default function MobileReviewSettingsDrawer({
features = DEFAULT_DRAWER_FEATURES,
@@ -110,12 +112,19 @@ export default function MobileReviewSettingsDrawer({
setRange,
setMode,
setShowExportPreview,
quality,
onSetQuality,
qualityStreams,
qualityAutoLow,
qualityAutoLowReason,
qualityMainUnsupported,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation([
"views/recording",
"components/dialog",
"views/replay",
"views/events",
"components/player",
"common",
]);
const isAdmin = useIsAdmin();
@@ -395,6 +404,21 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("quality") && onSetQuality && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("quality.label", { ns: "components/player" })}
onClick={() => setDrawerMode("quality")}
>
<div className="relative">
<MdHighQuality className="size-5 rounded-md bg-secondary-foreground fill-secondary p-1" />
{qualityAutoLow && (
<FaTriangleExclamation className="absolute -bottom-1 -right-1 size-2.5 text-danger" />
)}
</div>
{t("quality.label", { ns: "components/player" })}
</Button>
)}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
@@ -623,6 +647,33 @@ export default function MobileReviewSettingsDrawer({
}}
/>
);
} else if (drawerMode == "quality") {
content = (
<div className="flex w-full flex-col">
<div className="relative mb-2 h-8 w-full">
<div
className="absolute left-0 text-selected"
onClick={() => setDrawerMode("select")}
>
{t("button.back", { ns: "common" })}
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
{t("quality.label", { ns: "components/player" })}
</div>
</div>
<QualitySelectorContent
quality={quality ?? "auto"}
onSetQuality={(newQuality) => {
onSetQuality?.(newQuality);
setDrawerMode("none");
}}
streams={qualityStreams}
autoLow={qualityAutoLow}
autoLowReason={qualityAutoLowReason}
mainUnsupported={qualityMainUnsupported}
/>
</div>
);
} else if (drawerMode == "share-timestamp") {
content = (
<div className="w-full">
+195 -40
View File
@@ -26,7 +26,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
// Android native hls does not seek correctly
const USE_NATIVE_HLS = false;
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
const unsupportedErrorCodes = [
const unsupportedErrorCodes: number[] = [
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
MediaError.MEDIA_ERR_DECODE,
];
@@ -58,6 +58,14 @@ type HlsVideoPlayerProps = {
onSnapshot?: (playTime: number) => Promise<void> | void;
toggleFullscreen?: () => void;
onError?: (error: RecordingPlayerError) => void;
onStallStart?: () => void;
onStallEnd?: () => void;
onSeekStart?: () => void;
onBandwidthSample?: (estimateBps: number, levelBitrateBps?: number) => void;
onFatalNetworkError?: () => boolean;
onFatalCodecError?: () => boolean;
initialBandwidthEstimate?: number;
bufferLength?: number;
isDetailMode?: boolean;
camera?: string;
currentTimeOverride?: number;
@@ -86,6 +94,14 @@ export default function HlsVideoPlayer({
onSnapshot,
toggleFullscreen,
onError,
onStallStart,
onStallEnd,
onSeekStart,
onBandwidthSample,
onFatalNetworkError,
onFatalCodecError,
initialBandwidthEstimate,
bufferLength,
isDetailMode = false,
camera,
currentTimeOverride,
@@ -101,9 +117,37 @@ export default function HlsVideoPlayer({
// playback
const hlsRef = useRef<Hls>(undefined);
const [useHlsCompat, setUseHlsCompat] = useState(false);
// kept in a ref so changing callback identities do not recreate the
// Hls instance; the setup effect must only re-run on source changes
const qualitySignalsRef = useRef({
onStallStart,
onStallEnd,
onSeekStart,
onBandwidthSample,
onFatalNetworkError,
onFatalCodecError,
initialBandwidthEstimate,
});
// must resolve before the first render: a mount-effect flip would run
// the first source effect in native mode, briefly handing iOS a native
// HLS src that hls.js then tears away mid-load
const [useHlsCompat, setUseHlsCompat] = useState(() => {
if (
USE_NATIVE_HLS &&
document.createElement("video").canPlayType(HLS_MIME_TYPE)
) {
return false;
}
return Hls.isSupported();
});
const [loadedMetadata, setLoadedMetadata] = useState(false);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
// native HLS playback has no MSE, so it recovers from pipeline errors
// by reloading the source; one attempt per source
const nativeRetryRef = useRef(0);
// a ref rather than an effect-scoped counter so the element error
// handler can hold its toast while a recovery is still possible
const mediaRecoveryBudgetRef = useRef(0);
const applyVideoDimensions = useCallback(
(width: number, height: number) => {
@@ -153,27 +197,38 @@ export default function HlsVideoPlayer({
}, [videoRef, applyVideoDimensions]);
useEffect(() => {
if (!videoRef.current) {
return;
}
if (USE_NATIVE_HLS && videoRef.current.canPlayType(HLS_MIME_TYPE)) {
return;
} else if (Hls.isSupported()) {
setUseHlsCompat(true);
}
}, [videoRef]);
qualitySignalsRef.current = {
onStallStart,
onStallEnd,
onSeekStart,
onBandwidthSample,
onFatalNetworkError,
onFatalCodecError,
initialBandwidthEstimate,
};
}, [
onStallStart,
onStallEnd,
onSeekStart,
onBandwidthSample,
onFatalNetworkError,
onFatalCodecError,
initialBandwidthEstimate,
]);
useEffect(() => {
if (!videoRef.current) {
return;
}
setLoadedMetadata(false);
// loadedMetadata is intentionally NOT reset here: on a source swap
// the element already holds a decoded frame, and keeping it visible
// bridges the gap while the new source loads
const currentPlaybackRate = videoRef.current.playbackRate;
if (!useHlsCompat) {
nativeRetryRef.current = 0;
mediaRecoveryBudgetRef.current = 0;
videoRef.current.src = currentSource.playlist;
videoRef.current.load();
return;
@@ -181,14 +236,68 @@ export default function HlsVideoPlayer({
// Base HLS configuration
const hlsConfig: Partial<HlsConfig> = {
maxBufferLength: 10,
maxBufferLength: bufferLength ?? 10,
maxBufferSize: 20 * 1000 * 1000,
startPosition: currentSource.startPosition,
};
hlsRef.current = new Hls(hlsConfig);
hlsRef.current.attachMedia(videoRef.current);
hlsRef.current.loadSource(currentSource.playlist);
// every quality switch and chunk change recreates the instance, so
// seed it to keep measured throughput across source swaps
const seedEstimate = qualitySignalsRef.current.initialBandwidthEstimate;
if (seedEstimate !== undefined && seedEstimate > 0) {
hlsConfig.abrEwmaDefaultEstimate = seedEstimate;
}
const hls = new Hls(hlsConfig);
hlsRef.current = hls;
let networkRecoveryAttempts = 0;
mediaRecoveryBudgetRef.current = 1;
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// prefer a quality downswitch; fall back to restarting loading
const handled =
qualitySignalsRef.current.onFatalNetworkError?.() ?? false;
if (!handled && networkRecoveryAttempts < 2) {
networkRecoveryAttempts += 1;
hls.startLoad();
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// retrying the same codec cannot succeed, so a codec error
// prefers a quality downswitch over recovery
const isCodecError =
data.details ===
Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR ||
data.details === Hls.ErrorDetails.BUFFER_ADD_CODEC_ERROR;
if (isCodecError && qualitySignalsRef.current.onFatalCodecError?.()) {
return;
}
if (!isCodecError && mediaRecoveryBudgetRef.current > 0) {
mediaRecoveryBudgetRef.current -= 1;
hls.recoverMediaError();
}
}
return;
}
// hls.js reports each stall episode only once, so STALL_RESOLVED
// below is what closes it
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
qualitySignalsRef.current.onStallStart?.();
}
});
hls.on(Hls.Events.STALL_RESOLVED, () => {
qualitySignalsRef.current.onStallEnd?.();
});
hls.on(Hls.Events.FRAG_LOADED, () => {
// manifests are single-variant, so the bitrate is always level 0
qualitySignalsRef.current.onBandwidthSample?.(
hls.bandwidthEstimate,
hls.levels?.[0]?.bitrate || undefined,
);
});
hls.attachMedia(videoRef.current);
hls.loadSource(currentSource.playlist);
videoRef.current.playbackRate = currentPlaybackRate;
return () => {
@@ -199,7 +308,7 @@ export default function HlsVideoPlayer({
hlsRef.current.destroy();
}
};
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
}, [videoRef, hlsRef, useHlsCompat, currentSource, bufferLength]);
// state handling
@@ -481,21 +590,38 @@ export default function HlsVideoPlayer({
);
}
}}
onPlaying={onPlaying}
onPlaying={() => {
qualitySignalsRef.current.onStallEnd?.();
onPlaying?.();
}}
onPause={() => {
setIsPlaying(false);
clearTimeout(bufferTimeout);
// paused time must never count as stall time
qualitySignalsRef.current.onStallEnd?.();
if (isMobile && mobileCtrlTimeout) {
clearTimeout(mobileCtrlTimeout);
}
}}
onSeeking={() => {
// iOS ManagedMediaSource gates hls.js fragment loading off
// while paused and never resumes it on seek, so a seek
// into unbuffered media would never complete
hlsRef.current?.resumeBuffering();
qualitySignalsRef.current.onSeekStart?.();
}}
onWaiting={() => {
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
if (videoRef.current?.paused) {
return;
}
// the only stall signal under native HLS playback, which
// emits no hls.js events
qualitySignalsRef.current.onStallStart?.();
if (onError != undefined) {
setBufferTimeout(
setTimeout(() => {
if (
@@ -551,23 +677,52 @@ export default function HlsVideoPlayer({
}
}}
onError={(e) => {
if (
!hlsRef.current &&
// @ts-expect-error code does exist
unsupportedErrorCodes.includes(e.target.error.code) &&
videoRef.current
) {
setLoadedMetadata(false);
setUseHlsCompat(true);
} else {
toast.error(
// @ts-expect-error code does exist
`Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`,
{
position: "top-center",
},
);
const mediaError = (e.target as HTMLVideoElement).error;
if (!mediaError) {
return;
}
// an intentional source swap aborts the in-flight load;
// that abort is not an error the user can act on
if (mediaError.code === MediaError.MEDIA_ERR_ABORTED) {
return;
}
// hold the toast while the fatal handler still has a retry
// left; a failed recovery raises a second element error
if (hlsRef.current && mediaRecoveryBudgetRef.current > 0) {
return;
}
if (!hlsRef.current && videoRef.current) {
if (
unsupportedErrorCodes.includes(mediaError.code) &&
Hls.isSupported()
) {
setLoadedMetadata(false);
setUseHlsCompat(true);
return;
}
// native pipeline errors around source swaps are usually
// transient, and hls.js is no fallback without MSE
if (nativeRetryRef.current < 1) {
nativeRetryRef.current += 1;
videoRef.current.load();
return;
}
}
toast.error(
t("toast.error.playRecordingsFailed", {
code: mediaError.code,
message: mediaError.message,
}),
{
position: "top-center",
},
);
}}
/>
</div>
@@ -0,0 +1,245 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { isDesktop } from "react-device-detect";
import { FaTriangleExclamation } from "react-icons/fa6";
import { MdHighQuality } from "react-icons/md";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AutoQualityReason,
PLAYBACK_QUALITIES,
PlaybackQuality,
RecordingCoverage,
StreamMediaSummary,
} from "@/types/record";
const CODEC_DISPLAY_NAMES: Record<string, string> = {
h264: "H.264",
hevc: "H.265",
h265: "H.265",
av1: "AV1",
};
const AUDIO_CODEC_DISPLAY_NAMES: Record<string, string> = {
aac: "AAC",
pcm_alaw: "PCM-A",
pcm_mulaw: "PCM-U",
opus: "Opus",
mp3: "MP3",
};
type QualitySubtitleProps = {
streams?: RecordingCoverage["streams"];
autoLow?: boolean;
autoLowReason?: AutoQualityReason;
mainUnsupported?: boolean;
};
type QualitySelectorProps = QualitySubtitleProps & {
quality: PlaybackQuality;
onSetQuality: (quality: PlaybackQuality) => void;
setControlsOpen?: (open: boolean) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
};
function useQualitySubtitles({
streams,
autoLow,
autoLowReason,
mainUnsupported,
}: QualitySubtitleProps) {
const { t } = useTranslation(["components/player"]);
const streamSubtitle = useCallback(
(summary?: StreamMediaSummary) => {
if (!summary) {
return undefined;
}
const parts: string[] = [];
if (summary.video_codec != null) {
parts.push(
CODEC_DISPLAY_NAMES[summary.video_codec] ??
summary.video_codec.toUpperCase(),
);
}
// the codec decides whether the browser plays sound at all (AAC
// decodes, G.711 does not), so lead with it when known
const audioCodec =
summary.audio_codec != null
? (AUDIO_CODEC_DISPLAY_NAMES[summary.audio_codec] ??
summary.audio_codec.toUpperCase())
: null;
if (summary.has_audio === false) {
parts.push(t("quality.noAudio"));
} else if (audioCodec != null && summary.audio_rate != null) {
parts.push(
t("quality.audioCodecRate", {
codec: audioCodec,
rate: summary.audio_rate / 1000,
}),
);
} else if (audioCodec != null) {
parts.push(audioCodec);
} else if (summary.audio_rate != null) {
parts.push(t("quality.audioRate", { rate: summary.audio_rate / 1000 }));
}
return parts.length ? parts.join(" · ") : undefined;
},
[t],
);
const subtitles = useMemo<Partial<Record<PlaybackQuality, string>>>(
() => ({
auto: autoLow
? t(
autoLowReason === "codec"
? "quality.autoLowCodec"
: autoLowReason === "saveData"
? "quality.autoLowSaveData"
: "quality.autoLow",
)
: undefined,
// a stream absent from the summary has no footage in this range,
// and a pin is never silently substituted
main:
streams && !streams.main
? t("quality.noRecordings")
: mainUnsupported
? t("quality.notSupportedBrowser")
: streamSubtitle(streams?.main),
sub:
streams && !streams.sub
? t("quality.noRecordings")
: streamSubtitle(streams?.sub),
}),
[autoLow, autoLowReason, mainUnsupported, streamSubtitle, streams, t],
);
return subtitles;
}
export default function QualitySelector({
quality,
onSetQuality,
setControlsOpen,
containerRef,
streams,
autoLow,
autoLowReason,
mainUnsupported,
}: QualitySelectorProps) {
const { t } = useTranslation(["components/player"]);
const subtitles = useQualitySubtitles({
streams,
autoLow,
autoLowReason,
mainUnsupported,
});
const itemContent = useCallback(
(q: PlaybackQuality) => (
<div className="flex flex-col">
<span>{t(`quality.${q}`)}</span>
{subtitles[q] && (
<span className="text-xs text-muted-foreground">{subtitles[q]}</span>
)}
</div>
),
[subtitles, t],
);
const trigger = (
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("quality.label")}
size="sm"
>
<div className="relative">
<MdHighQuality className="size-5 text-secondary-foreground" />
{autoLow && (
<FaTriangleExclamation className="absolute -bottom-0.5 -right-1 size-3 text-danger" />
)}
</div>
{isDesktop && <div className="text-primary">{t("quality.label")}</div>}
</Button>
);
return (
<DropdownMenu
onOpenChange={(open) => {
if (setControlsOpen) {
setControlsOpen(open);
}
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent
portalProps={{
container: containerRef?.current,
}}
>
<DropdownMenuRadioGroup
value={quality}
onValueChange={(value) => onSetQuality(value as PlaybackQuality)}
>
{PLAYBACK_QUALITIES.map((q) => (
<DropdownMenuRadioItem key={q} value={q} className="cursor-pointer">
{itemContent(q)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
type QualitySelectorContentProps = QualitySubtitleProps & {
quality: PlaybackQuality;
onSetQuality: (quality: PlaybackQuality) => void;
};
// drawer-friendly variant of the selector for the mobile settings drawer
export function QualitySelectorContent({
quality,
onSetQuality,
streams,
autoLow,
autoLowReason,
mainUnsupported,
}: QualitySelectorContentProps) {
const { t } = useTranslation(["components/player"]);
const subtitles = useQualitySubtitles({
streams,
autoLow,
autoLowReason,
mainUnsupported,
});
return (
<div className="flex w-full flex-col items-center gap-2">
{PLAYBACK_QUALITIES.map((q) => (
<div
key={q}
className={`w-full cursor-pointer rounded-lg py-2 text-center ${quality == q ? "bg-secondary" : ""}`}
onClick={() => onSetQuality(q)}
>
<div className="smart-capitalize">{t(`quality.${q}`)}</div>
{subtitles[q] && (
<div className="text-xs text-muted-foreground">{subtitles[q]}</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,372 @@
/**
* Policy engine for auto playback quality downswitching.
*
* Stall time is measured rather than counted: hls.js reports
* BUFFER_STALLED_ERROR only once per episode (the flag resets only when
* playback resumes), so counting events makes the worst networks, where
* one freeze never resolves, the least likely to ever downswitch.
*/
export type DownswitchReason =
| "stall"
| "bandwidth"
| "fatal-error"
| "startup"
| "codec";
// stalls just after a seek are expected on any network (the target
// position is rarely buffered), so they get a longer budget and are
// kept out of the cumulative window
const SEEK_GRACE_MS = 2000;
// a single unresolved stall episode this long triggers a downswitch
const SINGLE_STALL_DOWNSWITCH_MS = 4000;
// seek-adjacent episodes only trigger once clearly beyond load latency
const GRACED_STALL_DOWNSWITCH_MS = 10000;
// total (non-graced) stall time within the rolling window that triggers
const CUMULATIVE_STALL_DOWNSWITCH_MS = 7000;
// rolling window for cumulative stall accounting; long enough to catch
// chronic short stalls, short enough that ancient history ages out
const STALL_WINDOW_MS = 60000;
// a throughput sample below bitrate * margin counts as evidence the
// connection cannot sustain the stream
const PREDICTIVE_BANDWIDTH_MARGIN = 1.1;
// consecutive low samples required for a predictive (pre-stall) downswitch
const PREDICTIVE_SAMPLE_COUNT = 3;
// measured throughput must clear the original stream's bitrate by this
// margin before a downswitched player retries full quality
const RETRY_BANDWIDTH_MARGIN = 1.5;
// the stall clock is blind before playback starts (the player is still
// paused), so the initial load needs its own budget
const STARTUP_DOWNSWITCH_MS = 10000;
// no realistic original recording stream plays comfortably below this,
// so a camera whose bitrate is not yet known starts low
const KNOWN_SLOW_START_FLOOR_BPS = 3_000_000;
// the first sample is biased toward the seeded default estimate
const PROBE_MIN_SUB_SAMPLES = 2;
type StallEpisode = {
start: number;
end: number;
};
export class AutoQualityGovernor {
// returns false when quality is pinned, sub is unavailable, or the
// player is already low
private requestDownswitch: (reason: DownswitchReason) => boolean;
private requestUpswitch: (() => void) | undefined;
private episodes: StallEpisode[] = [];
private openEpisode: { start: number; graced: boolean } | null = null;
private stallTimer: ReturnType<typeof setTimeout> | undefined;
private startupTimer: ReturnType<typeof setTimeout> | undefined;
private lastSeekTs = 0;
private consecutiveLowSamples = 0;
private upswitchProbeArmed = false;
private probeSampleCount = 0;
private mainUnplayable = false;
private holdLow = false;
// network facts survive stall-history resets: a manual pin or camera
// switch does not change what the connection can carry
private bandwidthEstimateBps: number | undefined;
private mainBitrateBps: number | undefined;
constructor(
requestDownswitch: (reason: DownswitchReason) => boolean,
requestUpswitch?: () => void,
) {
this.requestDownswitch = requestDownswitch;
this.requestUpswitch = requestUpswitch;
}
get bandwidthEstimate(): number | undefined {
return this.bandwidthEstimateBps;
}
/** Seed the connection estimate persisted from earlier sessions. */
seed(bandwidthEstimateBps: number | undefined) {
if (this.bandwidthEstimateBps === undefined) {
this.bandwidthEstimateBps = bandwidthEstimateBps;
}
}
/**
* Suppresses every path that would route playback back onto the
* original stream.
*/
markMainUnplayable() {
this.mainUnplayable = true;
}
get isMainUnplayable(): boolean {
return this.mainUnplayable;
}
/**
* Hold playback on the low stream regardless of measured headroom
* (user preference such as data saver, not a bandwidth fact).
*/
setHoldLow(hold: boolean) {
this.holdLow = hold;
}
/**
* Record the original stream's advertised bitrate learned outside of
* playback (e.g. parsed from its master playlist). Live measurements
* take precedence.
*/
learnMainBitrate(bitrateBps: number) {
if (this.mainBitrateBps === undefined && bitrateBps > 0) {
this.mainBitrateBps = bitrateBps;
}
}
/**
* Starts the time-to-first-frame budget: no stall episode can exist
* before playback starts, so a first segment too large for the
* connection would otherwise spin forever.
*/
sourceLoadStarted() {
clearTimeout(this.startupTimer);
this.startupTimer = setTimeout(
() => this.triggerDownswitch("startup"),
STARTUP_DOWNSWITCH_MS,
);
}
sourceLoadEnded() {
clearTimeout(this.startupTimer);
this.startupTimer = undefined;
}
/**
* Arm the one-shot upswitch probe after a conservative low start.
* Stays armed until it fires or a manual pin resets it, so a
* connection that improves later still recovers mid-chunk.
*/
armUpswitchProbe() {
this.upswitchProbeArmed = true;
this.probeSampleCount = 0;
}
noteSeek() {
this.lastSeekTs = Date.now();
}
/**
* A stall episode began (hls.js BUFFER_STALLED_ERROR or a video
* element waiting event). Idempotent while an episode is open, so the
* two signal sources need no cross-coordination.
*/
stallStarted() {
if (this.openEpisode) {
return;
}
const now = Date.now();
const graced = now - this.lastSeekTs < SEEK_GRACE_MS;
this.openEpisode = { start: now, graced };
// fire mid-stall: either this episode alone exceeds its budget, or
// it pushes the window's cumulative stall time over the threshold
const singleBudget = graced
? GRACED_STALL_DOWNSWITCH_MS
: SINGLE_STALL_DOWNSWITCH_MS;
const cumulativeBudget = graced
? Number.POSITIVE_INFINITY
: Math.max(0, CUMULATIVE_STALL_DOWNSWITCH_MS - this.windowStallMs(now));
this.stallTimer = setTimeout(
() => this.triggerDownswitch("stall"),
Math.min(singleBudget, cumulativeBudget),
);
}
/**
* Playback resumed (STALL_RESOLVED, playing, timeupdate) or paused.
* Closes any open episode; graced episodes never enter the window.
*/
stallEnded() {
if (!this.openEpisode) {
return;
}
clearTimeout(this.stallTimer);
this.stallTimer = undefined;
const now = Date.now();
if (!this.openEpisode.graced && now > this.openEpisode.start) {
this.episodes.push({ start: this.openEpisode.start, end: now });
}
this.openEpisode = null;
this.pruneEpisodes(now);
}
/**
* A segment finished loading. Records throughput, refreshes the
* original stream's bitrate while playing it, and downswitches
* predictively when sustained throughput cannot carry the stream.
*/
bandwidthSample(
estimateBps: number,
levelBitrateBps: number | undefined,
playingMain: boolean,
) {
if (!Number.isFinite(estimateBps) || estimateBps <= 0) {
return;
}
this.bandwidthEstimateBps = estimateBps;
if (!playingMain) {
this.consecutiveLowSamples = 0;
this.probeSampleCount += 1;
if (
this.upswitchProbeArmed &&
!this.mainUnplayable &&
!this.holdLow &&
this.probeSampleCount >= PROBE_MIN_SUB_SAMPLES &&
this.mainBitrateBps !== undefined &&
estimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN
) {
this.upswitchProbeArmed = false;
this.requestUpswitch?.();
}
return;
}
if (levelBitrateBps === undefined || levelBitrateBps <= 0) {
return;
}
this.mainBitrateBps = levelBitrateBps;
if (estimateBps < levelBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN) {
this.consecutiveLowSamples += 1;
if (this.consecutiveLowSamples >= PREDICTIVE_SAMPLE_COUNT) {
this.consecutiveLowSamples = 0;
this.triggerDownswitch("bandwidth");
}
} else {
this.consecutiveLowSamples = 0;
}
}
/**
* hls.js gave up loading (retries exhausted). Returns whether a
* downswitch happened so the player knows to attempt recovery instead.
*/
fatalNetworkError(): boolean {
return this.triggerDownswitch("fatal-error");
}
/**
* Unlike bandwidth signals a codec failure is proof, so the original
* stream is marked unplayable before the downswitch. Returns whether
* a downswitch happened.
*/
fatalCodecError(): boolean {
this.mainUnplayable = true;
return this.triggerDownswitch("codec");
}
/**
* Whether a downswitched player should retry full quality at the next
* chunk boundary. Native HLS playback reports no segment stats, so
* without bandwidth evidence this falls back to a clean stall window.
*/
shouldRetryMain(): boolean {
if (this.mainUnplayable || this.holdLow) {
return false;
}
if (
this.bandwidthEstimateBps !== undefined &&
this.mainBitrateBps !== undefined
) {
return (
this.bandwidthEstimateBps > this.mainBitrateBps * RETRY_BANDWIDTH_MARGIN
);
}
return this.windowStallMs(Date.now()) === 0;
}
/**
* Whether playback should begin on the low quality stream based on
* persisted network knowledge. A fully-cold device returns false; the
* owner handles that case with a conservative start plus the probe.
*/
shouldStartLow(): boolean {
if (this.bandwidthEstimateBps === undefined) {
return false;
}
if (this.mainBitrateBps !== undefined) {
return (
this.bandwidthEstimateBps <
this.mainBitrateBps * PREDICTIVE_BANDWIDTH_MARGIN
);
}
// unknown camera bitrate: above the floor, start on the original
// and let the startup budget correct a wrong guess
return this.bandwidthEstimateBps < KNOWN_SLOW_START_FLOOR_BPS;
}
/** A manual pin invalidates stall history but not network facts. */
resetStallHistory() {
clearTimeout(this.stallTimer);
this.stallTimer = undefined;
clearTimeout(this.startupTimer);
this.startupTimer = undefined;
this.openEpisode = null;
this.episodes = [];
this.consecutiveLowSamples = 0;
this.upswitchProbeArmed = false;
this.probeSampleCount = 0;
}
/**
* A camera switch additionally invalidates the per-camera facts: the
* stream bitrate and codec playability. The holdLow preference is
* device-level and survives.
*/
resetForCamera() {
this.resetStallHistory();
this.mainBitrateBps = undefined;
this.mainUnplayable = false;
}
destroy() {
this.resetStallHistory();
}
private triggerDownswitch(reason: DownswitchReason): boolean {
const handled = this.requestDownswitch(reason);
if (handled) {
// the low stream starts with a clean record
this.resetStallHistory();
}
return handled;
}
private windowStallMs(now: number): number {
this.pruneEpisodes(now);
const windowStart = now - STALL_WINDOW_MS;
let total = 0;
for (const episode of this.episodes) {
total += episode.end - Math.max(episode.start, windowStart);
}
if (this.openEpisode && !this.openEpisode.graced) {
total += now - Math.max(this.openEpisode.start, windowStart);
}
return total;
}
private pruneEpisodes(now: number) {
const windowStart = now - STALL_WINDOW_MS;
this.episodes = this.episodes.filter(
(episode) => episode.end > windowStart,
);
}
}
@@ -10,6 +10,10 @@ import { playWithTemporaryMuteFallback } from "@/utils/videoUtil.ts";
type PlayerMode = "playback" | "scrubbing";
// how long a seek may wait for its `seeked` event before playback starts
// anyway; long enough that a normally completing seek always wins
const SEEK_PLAY_FALLBACK_MS = 1000;
export class DynamicVideoController {
// main state
public camera = "";
@@ -24,7 +28,6 @@ export class DynamicVideoController {
private timeRange: TimeRange = { after: 0, before: 0 };
private inpointOffset: number = 0;
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
constructor(
camera: string,
@@ -51,11 +54,6 @@ export class DynamicVideoController {
this.timeRange.after,
this.recordings[0],
);
if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart);
this.timeToStart = undefined;
}
}
play() {
@@ -71,8 +69,11 @@ export class DynamicVideoController {
}
seekToTimestamp(time: number, play: boolean = false) {
// a seek outside the current playback window is a no-op: the view
// moves its anchor and chunk on such seeks, and the rebuilt source
// resumes at the anchor (startPosition plus the post-load seek).
// Seeking here would only reposition the outgoing source's media
if (time < this.timeRange.after || time > this.timeRange.before) {
this.timeToStart = time;
return;
}
@@ -91,22 +92,33 @@ export class DynamicVideoController {
return;
}
if (seekSeconds != 0) {
this.playerController.currentTime = seekSeconds;
if (this.playerController.currentTime === seekSeconds) {
// seeking to the current position fires no seeked event, so apply
// the play intent directly (this includes position 0, which the
// player sits at before its first seek)
if (play) {
this.waitAndPlay();
playWithTemporaryMuteFallback(this.playerController);
} else {
this.playerController.pause();
}
return;
}
this.playerController.currentTime = seekSeconds;
if (play) {
this.waitAndPlay();
} else {
// no op
this.playerController.pause();
}
}
waitAndPlay() {
return new Promise((resolve) => {
let fallback: NodeJS.Timeout | undefined;
const onSeekedHandler = () => {
clearTimeout(fallback);
this.playerController.removeEventListener("seeked", onSeekedHandler);
playWithTemporaryMuteFallback(this.playerController);
resolve(undefined);
@@ -115,6 +127,12 @@ export class DynamicVideoController {
this.playerController.addEventListener("seeked", onSeekedHandler, {
once: true,
});
// iOS ManagedMediaSource pauses hls.js buffering, so `seeked` may
// never fire; playing is what prompts WebKit to resume streaming
if ("ManagedMediaSource" in window) {
fallback = setTimeout(onSeekedHandler, SEEK_PLAY_FALLBACK_MS);
}
});
}
@@ -126,20 +144,26 @@ export class DynamicVideoController {
getProgress(playerTime: number): number {
// take a player time in seconds and convert to timestamp in timeline
let timestamp = 0;
const recordings = this.recordings || [];
let totalTime = 0;
(this.recordings || []).every((segment) => {
for (const segment of recordings) {
if (totalTime + segment.duration > playerTime) {
// segment is here
timestamp = segment.start_time + (playerTime - totalTime);
return false;
} else {
totalTime += segment.duration;
return true;
// playlist media from before the span's wall start (keyframe
// back-snap lead-in) clamps to the span start
const wallLength = segment.end_time - segment.start_time;
const leadIn = Math.max(0, segment.duration - wallLength);
return (
segment.start_time + Math.max(0, playerTime - totalTime - leadIn)
);
}
});
totalTime += segment.duration;
}
return timestamp;
// past the modeled total: clamp to the covered end rather than
// reporting wall-clock zero
return recordings.length > 0
? recordings[recordings.length - 1].end_time
: 0;
}
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
@@ -149,7 +173,10 @@ export class DynamicVideoController {
this.previewController.setNewPreviewStartTime(time);
}
if (scrubResult && this.playerMode != "scrubbing") {
// pause even when no preview can render this range: a hidden player
// left running reports stale times once the drag releases, bouncing
// the handlebar back and sometimes swallowing the release seek
if (this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";
this.playerController.pause();
}
@@ -9,7 +9,12 @@ import {
import { useApiHost } from "@/api";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Recording } from "@/types/record";
import {
AutoQualityReason,
PlaybackQuality,
Recording,
RecordingCoverage,
} from "@/types/record";
import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
@@ -32,6 +37,13 @@ import {
grabVideoSnapshot,
} from "@/utils/snapshotUtil";
import { isFirefox } from "react-device-detect";
import { AutoQualityGovernor } from "./AutoQualityGovernor";
import { isCodecFamilySupported } from "@/utils/codecSupport";
import { useUserPersistence } from "@/hooks/use-user-persistence";
// forward buffer while playing the low quality stream; low bitrate makes
// a longer buffer cheap and it rides out connection variance better
const SUB_STREAM_BUFFER_LENGTH_S = 30;
/**
* Dynamically switches between video playback and scrubbing preview player.
@@ -55,6 +67,11 @@ type DynamicVideoPlayerProps = {
toggleFullscreen: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
transformedOverlay?: ReactNode;
quality?: PlaybackQuality;
onAutoQualityChange?: (
lowQuality: boolean,
reason: AutoQualityReason | undefined,
) => void;
};
export default function DynamicVideoPlayer({
className,
@@ -75,6 +92,8 @@ export default function DynamicVideoPlayer({
toggleFullscreen,
containerRef,
transformedOverlay,
quality,
onAutoQualityChange,
}: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const apiHost = useApiHost();
@@ -128,7 +147,7 @@ export default function DynamicVideoPlayer({
const [isLoading, setIsLoading] = useState(false);
const [isBuffering, setIsBuffering] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
const loadingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Don't set source until recordings load - we need accurate startPosition
// to avoid hls.js clamping to video end when startPosition exceeds duration
@@ -138,32 +157,80 @@ export default function DynamicVideoPlayer({
useEffect(() => {
if (!isScrubbing) {
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000);
}
return () => {
if (loadingTimeout) {
clearTimeout(loadingTimeout);
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
};
// we only want trigger when scrubbing state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, isScrubbing]);
// wall-clock position to resume from once the current source finishes
// loading. A seek landing mid-load must win over the position the
// source was built around, or the post-load seek drags playback back
const sourceAnchorRef = useRef<number | undefined>(undefined);
useEffect(() => {
sourceAnchorRef.current = startTimestamp;
}, [startTimestamp]);
// a recordings change refined the seek model without changing the
// playlist, so the playback effect skips its loading indicator
const modelOnlyUpdateRef = useRef(false);
const onPlayerLoaded = useCallback(() => {
if (!controller || !startTimestamp) {
sourceLoadedRef.current = true;
governorRef.current?.sourceLoadEnded();
const anchor = sourceAnchorRef.current;
if (!controller || !anchor) {
return;
}
controller.seekToTimestamp(startTimestamp, true);
}, [startTimestamp, controller]);
// an anchor outside this chunk is stale (e.g. a natural clip
// advance); the playlist already starts where playback should
if (anchor < timeRange.after || anchor > timeRange.before) {
return;
}
// while the handlebar is down only position the hidden player, never
// start it: a mid-drag chunk prefetch can audibly blip before
// onPlaying pauses it. The release seek starts playback
controller.seekToTimestamp(anchor, !isScrubbing);
}, [controller, timeRange, isScrubbing]);
// used to re-anchor the source when an auto quality switch rebuilds
// the playlist mid-playback
const lastPlayedTimestampRef = useRef<number | undefined>(undefined);
// the range the controller's playback model was last built for; while
// a chunk change awaits its coverage, the outgoing source reports
// times that would map through the stale model
const modelTimeRangeRef = useRef<TimeRange | undefined>(undefined);
const onTimeUpdate = useCallback(
(time: number) => {
// safety net for stall or startup signals the player missed
governorRef.current?.stallEnded();
if (!sourceLoadedRef.current) {
sourceLoadedRef.current = true;
governorRef.current?.sourceLoadEnded();
}
if (isScrubbing || !controller || !onTimestampUpdate || time == 0) {
return;
}
// drop reports until the controller's model matches this chunk
if (
modelTimeRangeRef.current?.after !== timeRange.after ||
modelTimeRangeRef.current?.before !== timeRange.before
) {
return;
}
if (isLoading) {
setIsLoading(false);
}
@@ -172,9 +239,18 @@ export default function DynamicVideoPlayer({
setIsBuffering(false);
}
onTimestampUpdate(controller.getProgress(time));
const progress = controller.getProgress(time);
lastPlayedTimestampRef.current = progress;
onTimestampUpdate(progress);
},
[controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing],
[
controller,
onTimestampUpdate,
isBuffering,
isLoading,
isScrubbing,
timeRange,
],
);
const onUploadFrameToPlus = useCallback(
@@ -238,45 +314,350 @@ export default function DynamicVideoPlayer({
() => ({
before: timeRange.before,
after: timeRange.after,
timelines: true,
}),
[timeRange],
);
const { data: recordings } = useSWR<Recording[]>(
[`${camera}/recordings`, recordingParams],
const { data: coverage } = useSWR<RecordingCoverage>(
[`${camera}/recordings/coverage`, recordingParams],
{ revalidateOnFocus: false },
);
// auto quality plays the default route until the governor downswitches
// to the pinned sub route; manual pins bypass this entirely
const [autoLowQuality, setAutoLowQuality] = useState(false);
const [autoLowReason, setAutoLowReason] = useState<
AutoQualityReason | undefined
>(undefined);
const autoLowQualityRef = useRef(false);
const subAvailable = useMemo(
() =>
coverage?.spans?.some((span) => span.streams.includes("sub")) ?? false,
[coverage],
);
const resolvedQuality = quality ?? "auto";
// the ref indirection keeps these reading fresh state while the
// governor stays a single instance for the component's lifetime
const tryDownswitchRef = useRef<(reason: string) => boolean>(() => false);
const tryUpswitchRef = useRef<() => void>(() => {});
const governorRef = useRef<AutoQualityGovernor | null>(null);
if (governorRef.current === null) {
governorRef.current = new AutoQualityGovernor(
(reason) => tryDownswitchRef.current(reason),
() => tryUpswitchRef.current(),
);
}
const governor = governorRef.current;
// callers pass an inline callback, so keeping it out of the notify
// effect's deps stops the notification's re-render from re-firing it
const onAutoQualityChangeRef = useRef(onAutoQualityChange);
useEffect(() => {
onAutoQualityChangeRef.current = onAutoQualityChange;
}, [onAutoQualityChange]);
useEffect(() => {
autoLowQualityRef.current = autoLowQuality;
onAutoQualityChangeRef.current?.(
autoLowQuality,
autoLowQuality ? autoLowReason : undefined,
);
}, [autoLowQuality, autoLowReason]);
useEffect(() => {
tryDownswitchRef.current = (reason: string) => {
if (
resolvedQuality !== "auto" ||
!subAvailable ||
autoLowQualityRef.current
) {
return false;
}
setAutoLowQuality(true);
setAutoLowReason(reason === "codec" ? "codec" : "bandwidth");
// so a recovered connection (or a wrong downswitch) returns to
// full quality mid-chunk rather than at the next boundary
governor.armUpswitchProbe();
return true;
};
tryUpswitchRef.current = () => {
if (resolvedQuality === "auto" && autoLowQualityRef.current) {
setAutoLowQuality(false);
setAutoLowReason(undefined);
}
};
}, [resolvedQuality, subAvailable, governor]);
// persisted across sessions so a device on a known-slow connection
// starts low instead of paying the first stall to find out
const [persistedEstimate, setPersistedEstimate, estimateLoaded] =
useUserPersistence<number>("playbackBandwidthEstimate");
const persistGovernor = useCallback(() => {
const estimate = governor.bandwidthEstimate;
if (estimate !== undefined) {
setPersistedEstimate(Math.round(estimate));
}
}, [governor, setPersistedEstimate]);
const persistGovernorRef = useRef(persistGovernor);
useEffect(() => {
persistGovernorRef.current = persistGovernor;
}, [persistGovernor]);
useEffect(() => {
// returning to auto starts fresh on the default route, except when
// this browser already proved it cannot decode the original stream
governor.resetStallHistory();
setAutoLowQuality(governor.isMainUnplayable);
setAutoLowReason(governor.isMainUnplayable ? "codec" : undefined);
// we only want to reset when the pinned quality changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quality]);
useEffect(() => {
// measured connection throughput carries over across cameras
governor.resetForCamera();
setAutoLowQuality(false);
setAutoLowReason(undefined);
// we only want to reset when the camera changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera]);
// seed the governor once per camera, then decide the starting quality
const seededCameraRef = useRef<string | null>(null);
useEffect(() => {
if (seededCameraRef.current === camera || !estimateLoaded || !coverage) {
return;
}
seededCameraRef.current = camera;
const mainSummary = coverage.streams?.main;
if (mainSummary?.bitrate) {
governor.learnMainBitrate(mainSummary.bitrate);
}
governor.seed(persistedEstimate);
if (resolvedQuality !== "auto" || !subAvailable) {
return;
}
// data saver is a user preference, not a bandwidth fact: hold the
// low stream and never auto-upswitch against it (a manual pin to
// Original still wins as an explicit action)
const saveData =
(navigator as Navigator & { connection?: { saveData?: boolean } })
.connection?.saveData === true;
if (saveData) {
governor.setHoldLow(true);
}
// a browser that cannot decode the original codec can never play
// the merged route. This probe fails open (unknown codecs count as
// supported); the reactive fatal-codec path is the real authority
const mainSupported = isCodecFamilySupported(mainSummary?.video_codec);
const subSupported = isCodecFamilySupported(
coverage.streams?.sub?.video_codec,
);
if (!mainSupported && subSupported) {
governor.markMainUnplayable();
setAutoLowQuality(true);
setAutoLowReason("codec");
return;
}
if (saveData) {
setAutoLowQuality(true);
setAutoLowReason("saveData");
return;
}
// a fully cold device also starts low: the conservative start shows
// a first frame in seconds and the armed probe recovers full
// quality within a few segment loads on connections that allow it
const coldStart = governor.bandwidthEstimate === undefined;
if (!coldStart && !governor.shouldStartLow()) {
return;
}
setAutoLowQuality(true);
setAutoLowReason("bandwidth");
governor.armUpswitchProbe();
}, [
camera,
coverage,
estimateLoaded,
persistedEstimate,
resolvedQuality,
subAvailable,
governor,
]);
// time-to-first-frame budget; the stall clock is blind before
// playback starts, so an oversized first segment would spin forever
const sourceLoadedRef = useRef(false);
useEffect(() => {
sourceLoadedRef.current = false;
}, [source]);
useEffect(() => {
if (!source || isScrubbing || sourceLoadedRef.current) {
governor.sourceLoadEnded();
return;
}
governor.sourceLoadStarted();
}, [source, isScrubbing, governor]);
useEffect(() => {
// a chunk boundary is where full quality may be retried, and a
// natural point to persist what the governor has learned
setAutoLowQuality((prev) => prev && !governor.shouldRetryMain());
persistGovernorRef.current();
// we only want to re-evaluate when the playback chunk changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
useEffect(() => {
return () => {
persistGovernorRef.current();
governor.destroy();
};
// governor is a stable per-mount instance
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const effectiveQuality: PlaybackQuality =
resolvedQuality === "auto" && autoLowQuality ? "sub" : resolvedQuality;
const onStallStart = useCallback(() => governor.stallStarted(), [governor]);
const onStallEnd = useCallback(() => governor.stallEnded(), [governor]);
const onSeekStart = useCallback(() => governor.noteSeek(), [governor]);
const onFatalNetworkError = useCallback(
() => governor.fatalNetworkError(),
[governor],
);
const onFatalCodecError = useCallback(
() => governor.fatalCodecError(),
[governor],
);
const onBandwidthSample = useCallback(
(estimateBps: number, levelBitrateBps?: number) =>
governor.bandwidthSample(
estimateBps,
levelBitrateBps,
// the merged default route leads with the original stream, so
// its samples measure original-quality sustainability
effectiveQuality !== "sub",
),
[governor, effectiveQuality],
);
// the realized timelines mirror the vod manifests exactly, including
// keyframe back-snap lead-in at cross-stream hand-offs. Walking wall
// lengths instead drifts ~0.5s per hand-off, since the playlist
// contains lead-in media the model never knew about
const recordings = useMemo<Recording[] | undefined>(() => {
const timeline =
coverage?.timelines?.[
effectiveQuality === "main" || effectiveQuality === "sub"
? effectiveQuality
: "auto"
];
if (!timeline) {
return undefined;
}
return timeline.map((span) => ({
start_time: span.start_time,
end_time: span.end_time,
duration: span.duration / 1000,
})) as Recording[];
}, [coverage, effectiveQuality]);
// lets the effect below tell quality rebuilds apart from chunk changes
const prevEffectiveQualityRef = useRef(effectiveQuality);
useEffect(() => {
const qualityChanged = prevEffectiveQualityRef.current !== effectiveQuality;
prevEffectiveQualityRef.current = effectiveQuality;
if (!recordings?.length) {
if (recordings?.length == 0) {
// drop any stale source so the previous playlist unmounts
// instead of playing under the no-recording state
setSource(undefined);
setNoRecording(true);
// with no source nothing will play to clear a pending
// camera-switch load, hiding the message behind a preview frame
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
setIsLoading(false);
}
return;
}
// an identical playlist means coverage only refined the seek model;
// skip the rebuild so the player is not torn down
const streamPath =
effectiveQuality === "main" || effectiveQuality === "sub"
? `/${effectiveQuality}`
: "";
const playlist = `${apiHost}vod/${camera}${streamPath}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`;
if (!qualityChanged && source?.playlist === playlist) {
modelOnlyUpdateRef.current = true;
return;
}
// a quality switch rebuilds mid-playback, so anchor to the live
// playhead rather than the chunk-stale startTimestamp prop. The
// controller still holds the OUTGOING timeline here (newPlayback
// runs in a later effect), and the timeupdate-throttled lastPlayed
// ref lags the frame on screen by up to ~250ms
const liveTime = playerRef.current?.currentTime;
const livePlayed =
qualityChanged && controller && liveTime !== undefined && liveTime > 0
? controller.getProgress(liveTime)
: undefined;
const lastPlayed = livePlayed ?? lastPlayedTimestampRef.current;
const anchorTimestamp =
qualityChanged &&
lastPlayed !== undefined &&
lastPlayed >= timeRange.after &&
lastPlayed <= timeRange.before
? lastPlayed
: startTimestamp;
sourceAnchorRef.current = anchorTimestamp;
let startPosition = undefined;
if (startTimestamp) {
if (anchorTimestamp) {
const inpointOffset = calculateInpointOffset(
recordingParams.after,
(recordings || [])[0],
);
startPosition = calculateSeekPosition(
startTimestamp,
anchorTimestamp,
recordings,
inpointOffset,
);
}
setSource({
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
playlist,
startPosition,
});
// we only want to rebuild the source when the playlist itself changes;
// startTimestamp, timeRange, and the anchor refs are read as-of-rebuild
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordings]);
}, [recordings, effectiveQuality]);
useEffect(() => {
if (!controller || !recordings?.length) {
@@ -287,12 +668,28 @@ export default function DynamicVideoPlayer({
playerRef.current.autoplay = !isScrubbing;
}
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
const modelOnlyUpdate = modelOnlyUpdateRef.current;
modelOnlyUpdateRef.current = false;
// on a source swap the element already has a decoded frame; keep it
// visible under the buffering indicator rather than hiding it
// behind the preview player like the initial load does
const hasDecodedFrame =
(playerRef.current?.readyState ?? 0) >=
HTMLMediaElement.HAVE_CURRENT_DATA;
if (!modelOnlyUpdate) {
loadingTimeoutRef.current = setTimeout(
() => (hasDecodedFrame ? setIsBuffering(true) : setIsLoading(true)),
1000,
);
}
controller.newPlayback({
recordings: recordings ?? [],
timeRange,
});
modelTimeRangeRef.current = timeRange;
// we only want this to change when controller or recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -356,8 +753,8 @@ export default function DynamicVideoPlayer({
playerRef.current?.pause();
}
if (loadingTimeout) {
clearTimeout(loadingTimeout);
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
}
setNoRecording(false);
@@ -372,6 +769,16 @@ export default function DynamicVideoPlayer({
setIsBuffering(true);
}
}}
onStallStart={onStallStart}
onStallEnd={onStallEnd}
onSeekStart={onSeekStart}
onBandwidthSample={onBandwidthSample}
onFatalNetworkError={onFatalNetworkError}
onFatalCodecError={onFatalCodecError}
initialBandwidthEstimate={governor.bandwidthEstimate}
bufferLength={
effectiveQuality === "sub" ? SUB_STREAM_BUFFER_LENGTH_S : undefined
}
isDetailMode={isDetailMode}
camera={contextCamera || camera}
currentTimeOverride={currentTime}
@@ -45,6 +45,13 @@ import {
CommandList,
} from "@/components/ui/command";
// Recording the sub stream from the same stream as record would just
// re-record the main stream, so the two roles are mutually exclusive.
const CONFLICTING_ROLES: Partial<Record<StreamRole, StreamRole>> = {
record: "record_sub",
record_sub: "record",
};
type Step3StreamConfigProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
@@ -163,9 +170,12 @@ export default function Step3StreamConfig({
const newRoles = stream.roles.filter((r) => r !== role);
updateStream(streamId, { roles: newRoles });
} else {
// Check if role is already used in another stream
const usedRoles = getUsedRolesExcludingStream(streamId);
if (!usedRoles.has(role)) {
const conflictingRole = CONFLICTING_ROLES[role];
const hasConflict = conflictingRole
? stream.roles.includes(conflictingRole)
: false;
if (!usedRoles.has(role) && !hasConflict) {
// Allow adding the role
const newRoles = [...stream.roles, role];
updateStream(streamId, { roles: newRoles });
@@ -617,6 +627,10 @@ export default function Step3StreamConfig({
<strong>record</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.record")}
</div>
<div>
<strong>record_sub</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.record_sub")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.audio")}
@@ -639,25 +653,35 @@ export default function Step3StreamConfig({
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
{(["detect", "record", "record_sub", "audio"] as const).map(
(role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const conflictingRole = CONFLICTING_ROLES[role];
const hasConflict = conflictingRole
? stream.roles.includes(conflictingRole)
: false;
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() =>
toggleRole(stream.id, role)
}
disabled={
!isChecked && (isUsedElsewhere || hasConflict)
}
/>
</div>
);
},
)}
</div>
</div>
</div>
@@ -42,6 +42,7 @@ export type MotionReviewTimelineProps = {
events: ReviewSegment[];
motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[];
subOnlyRanges?: Pick<RecordingSegment, "start_time" | "end_time">[];
contentRef: RefObject<HTMLDivElement | null>;
timelineRef?: RefObject<HTMLDivElement | null>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
@@ -76,6 +77,7 @@ export function MotionReviewTimeline({
events,
motion_events,
noRecordingRanges,
subOnlyRanges,
contentRef,
timelineRef,
onHandlebarDraggingChange,
@@ -122,6 +124,17 @@ export function MotionReviewTimeline({
[noRecordingRanges],
);
const getIsSubOnly = useCallback(
(time: number): boolean => {
if (subOnlyRanges == undefined) return false;
return subOnlyRanges.some(
(range) => time >= range.start_time && time < range.end_time,
);
},
[subOnlyRanges],
);
const segmentTimes = useMemo(() => {
const segments = [];
let segmentTime = timelineStartAligned;
@@ -245,6 +258,7 @@ export function MotionReviewTimeline({
motionOnly={motionOnly}
getMotionSegmentValue={getMotionSegmentValue}
getRecordingAvailability={getRecordingAvailability}
getIsSubOnly={getIsSubOnly}
alwaysShowMotionLine={alwaysShowMotionLine}
/>
</ReviewTimeline>
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import useTapUtils from "@/hooks/use-tap-utils";
import { cn } from "@/lib/utils";
@@ -16,6 +17,7 @@ type MotionSegmentProps = {
firstHalfMotionValue: number;
secondHalfMotionValue: number;
hasRecording?: boolean;
isSubOnly?: boolean;
prevIsNoRecording?: boolean;
nextIsNoRecording?: boolean;
motionOnly: boolean;
@@ -36,6 +38,7 @@ export function MotionSegment({
firstHalfMotionValue,
secondHalfMotionValue,
hasRecording,
isSubOnly,
prevIsNoRecording,
nextIsNoRecording,
motionOnly,
@@ -47,6 +50,7 @@ export function MotionSegment({
dense,
alwaysShowMotionLine = false,
}: MotionSegmentProps) {
const { t } = useTranslation("views/events");
const severityType = "all";
const { getSeverity, getReviewed, displaySeverityType } =
useEventSegmentUtils(segmentDuration, events, severityType);
@@ -194,8 +198,10 @@ export function MotionSegment({
segmentClasses,
severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]],
isSubOnly && "bg-background/50",
hasRecording == false && "bg-background",
)}
title={isSubOnly ? t("subOnlyQuality") : undefined}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
@@ -25,6 +25,7 @@ type VirtualizedMotionSegmentsProps = {
motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number;
getRecordingAvailability: (timestamp: number) => boolean | undefined;
getIsSubOnly: (timestamp: number) => boolean;
alwaysShowMotionLine: boolean;
};
@@ -58,6 +59,7 @@ export const VirtualizedMotionSegments = forwardRef<
motionOnly,
getMotionSegmentValue,
getRecordingAvailability,
getIsSubOnly,
alwaysShowMotionLine,
},
ref,
@@ -161,6 +163,7 @@ export const VirtualizedMotionSegments = forwardRef<
);
const hasRecording = getRecordingAvailability(segmentTime);
const isSubOnly = getIsSubOnly(segmentTime);
// Check if previous and next segments have recordings
// This is important because in motionOnly mode, the segments array is filtered
@@ -195,6 +198,7 @@ export const VirtualizedMotionSegments = forwardRef<
firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue}
hasRecording={hasRecording}
isSubOnly={isSubOnly}
prevIsNoRecording={prevIsNoRecording}
nextIsNoRecording={nextIsNoRecording}
segmentDuration={segmentDuration}
@@ -216,6 +220,7 @@ export const VirtualizedMotionSegments = forwardRef<
events,
getMotionSegmentValue,
getRecordingAvailability,
getIsSubOnly,
motionOnly,
segmentDuration,
showMinimap,
+26 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isIOS } from "react-device-detect";
import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@@ -10,6 +11,11 @@ import useUserInteraction from "./use-user-interaction";
const DRAG_STATE_COMMIT_MS = 100;
// iOS Safari synthesizes a click shortly after a drag's touchend even
// though the touchend handler calls preventDefault; clicks observed in
// traces arrive ~50ms after release
const GHOST_CLICK_WINDOW_MS = 400;
type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement | null>;
timelineRef: React.RefObject<HTMLDivElement | null>;
@@ -164,9 +170,28 @@ function useDraggableElement({
setDraggableElementTime(pendingDragTimeRef.current);
pendingDragTimeRef.current = null;
}
// iOS Safari synthesizes a click after touchend despite the
// preventDefault, hit-tested at the drag origin where a segment
// now sits; its onClick would yank the handlebar back
if (isIOS && "TouchEvent" in window && e instanceof TouchEvent) {
const swallow = (clickEvent: MouseEvent) => {
cleanup();
if (timelineRef.current?.contains(clickEvent.target as Node)) {
clickEvent.preventDefault();
clickEvent.stopPropagation();
}
};
const cleanup = () => {
document.removeEventListener("click", swallow, true);
window.clearTimeout(timer);
};
const timer = window.setTimeout(cleanup, GHOST_CLICK_WINDOW_MS);
document.addEventListener("click", swallow, true);
}
}
},
[isDragging, setIsDragging, setDraggableElementTime],
[isDragging, setIsDragging, setDraggableElementTime, timelineRef],
);
const timestampToPixels = useCallback(
+1 -1
View File
@@ -75,7 +75,7 @@ export const CAMERA_BRAND_VALUES = CAMERA_BRANDS.map(
export type CameraBrand = (typeof CAMERA_BRANDS)[number]["value"];
export type StreamRole = "detect" | "record" | "audio";
export type StreamRole = "detect" | "record" | "record_sub" | "audio";
export type StreamConfig = {
id: string;
+19
View File
@@ -11,6 +11,25 @@ export type FilterList = {
export const LAST_24_HOURS_KEY = "last24Hours";
const DRAWER_FEATURES = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
"quality",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
export const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"export",
"calendar",
"filter",
"debug-replay",
"share-timestamp",
"motion-search",
];
export type GeneralFilter = {
showAll?: boolean;
labels?: string[];
+5
View File
@@ -96,6 +96,7 @@ export interface CameraConfig {
output_args: {
detect: string[];
record: string;
record_sub: string | string[];
rtmp: string;
};
retry_interval: number;
@@ -235,6 +236,9 @@ export interface CameraConfig {
days: number;
mode: string;
};
sub: {
enabled: boolean;
};
};
review: {
alerts: {
@@ -492,6 +496,7 @@ export interface FrigateConfig {
output_args: {
detect: string[];
record: string;
record_sub: string | string[];
rtmp: string;
};
retry_interval: number;
+42
View File
@@ -45,6 +45,48 @@ export type RecordingStartingPoint = {
export type RecordingPlayerError = "stalled" | "startup";
export type RecordingCoverageSpan = {
start_time: number;
end_time: number;
streams: ("main" | "sub")[];
};
export type StreamMediaSummary = {
video_codec: string | null;
audio_rate: number | null;
audio_codec: string | null;
has_audio: boolean | null;
bitrate: number | null;
};
// why auto quality is currently resolved to the low stream
export type AutoQualityReason = "bandwidth" | "codec" | "saveData";
// one span of a vod route's realized playlist. duration is in ms and
// exceeds the wall length when the clip carries keyframe back-snap
// lead-in; 0 means the clip is omitted from the playlist entirely
export type PlaybackTimelineSpan = {
start_time: number;
end_time: number;
duration: number;
};
export type RecordingCoverage = {
spans: RecordingCoverageSpan[];
// informational, kept for API consumers; the UI no longer gates on it
codecs_compatible: boolean;
streams: { main?: StreamMediaSummary; sub?: StreamMediaSummary };
// opt-in (?timelines=true); absent on requests that skip them
timelines?: {
auto: PlaybackTimelineSpan[];
main: PlaybackTimelineSpan[];
sub: PlaybackTimelineSpan[];
};
};
export type PlaybackQuality = "auto" | "main" | "sub";
export const PLAYBACK_QUALITIES: PlaybackQuality[] = ["auto", "main", "sub"];
export const ASPECT_VERTICAL_LAYOUT = 1.5;
export const ASPECT_PORTRAIT_LAYOUT = 1.333;
export const ASPECT_WIDE_LAYOUT = 2;
+73
View File
@@ -0,0 +1,73 @@
/**
* Best-effort probe for whether this browser can decode a video codec
* family.
*
* Frigate only stores the codec family from ffprobe (no profile or
* level), so the probe tests representative MIME samples per family.
* The result is a hint, not proof: a 10-bit stream can fail on a
* browser that passes the Main-profile sample, so callers must keep a
* reactive fallback for fatal codec errors. Unknown or NULL codecs
* (legacy rows) always report supported - a wrong "unsupported" answer
* silently degrades quality, which is worse than a failed attempt the
* reactive path recovers from.
*/
declare global {
interface Window {
ManagedMediaSource?: typeof MediaSource;
}
}
// any one supported sample marks the family playable
const CODEC_MIME_SAMPLES: Record<string, string[]> = {
h264: ['video/mp4; codecs="avc1.42E01E"', 'video/mp4; codecs="avc1.64001F"'],
hevc: [
'video/mp4; codecs="hvc1.1.6.L120.90"',
'video/mp4; codecs="hev1.1.6.L120.90"',
],
av1: ['video/mp4; codecs="av01.0.05M.08"'],
};
const FAMILY_ALIASES: Record<string, string> = {
avc: "h264",
avc1: "h264",
h265: "hevc",
hev1: "hevc",
hvc1: "hevc",
av01: "av1",
};
function canPlayMimeType(mimeType: string): boolean {
if (window.ManagedMediaSource?.isTypeSupported(mimeType)) {
return true;
}
if (window.MediaSource?.isTypeSupported(mimeType)) {
return true;
}
// native playback fallback for browsers without MSE
return document.createElement("video").canPlayType(mimeType) !== "";
}
/**
* Fails open: unknown codecs and NULL (legacy) codecs report supported,
* since wrongly downgrading quality is worse than a recoverable failure.
*/
export function isCodecFamilySupported(
codecName: string | null | undefined,
): boolean {
if (!codecName) {
return true;
}
const normalized = codecName.toLowerCase().trim();
const family = FAMILY_ALIASES[normalized] ?? normalized;
const samples = CODEC_MIME_SAMPLES[family];
if (!samples) {
return true;
}
return samples.some(canPlayMimeType);
}
+7 -4
View File
@@ -61,15 +61,18 @@ export function calculateSeekPosition(
return false;
}
// playlist duration exceeds wall length when the clip carries
// keyframe back-snap lead-in
const wallLength = segment.end_time - segment.start_time;
const leadIn = Math.max(0, segment.duration - wallLength);
if (segment.end_time < timestamp) {
// Add the full duration of this segment
seekSeconds += segment.end_time - segment.start_time;
seekSeconds += segment.duration;
return true;
}
// We're in this segment - calculate position within it
seekSeconds +=
segment.end_time - segment.start_time - (segment.end_time - timestamp);
seekSeconds += leadIn + (timestamp - segment.start_time);
return true;
});
@@ -615,16 +615,16 @@ export default function MotionSearchView({
}, [selectedRangeIdx, chunkedTimeRange]);
const updateSelectedSegment = useCallback(
(nextTime: number, updateStartTime: boolean) => {
(nextTime: number) => {
const index = chunkedTimeRange.findIndex(
(segment) => segment.after <= nextTime && segment.before >= nextTime,
);
if (index != -1) {
if (updateStartTime) {
setPlaybackStart(nextTime);
}
setPlaybackStart(nextTime);
// the outgoing chunk's player runs until the new source replaces
// it, reporting old positions while the new chunk loads
mainControllerRef.current?.pause();
setSelectedRangeIdx(index);
}
},
@@ -638,7 +638,10 @@ export default function MotionSearchView({
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
) {
updateSelectedSegment(currentTime, false);
// the player rebuilds its source against playbackStart, and a
// stale anchor resolves to no startPosition, dropping playback
// at the start of the hour instead of the drag target
updateSelectedSegment(currentTime);
return;
}
@@ -678,9 +681,12 @@ export default function MotionSearchView({
currentTimeRange.after <= currentTime &&
currentTimeRange.before >= currentTime
) {
// a source reload mid-seek resumes from playbackStart, so the
// anchor has to follow explicit seeks
setPlaybackStart(currentTime);
mainControllerRef.current?.seekToTimestamp(currentTime, true);
} else {
updateSelectedSegment(currentTime, true);
updateSelectedSegment(currentTime);
}
} else if (playerTime != currentTime) {
mainControllerRef.current?.play();
@@ -700,9 +706,12 @@ export default function MotionSearchView({
setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
// a source reload mid-seek resumes from playbackStart, so the
// anchor has to follow explicit seeks
setPlaybackStart(time);
mainControllerRef.current?.seekToTimestamp(time, play);
} else {
updateSelectedSegment(time, true);
updateSelectedSegment(time);
}
},
[currentTimeRange, updateSelectedSegment],
+130 -9
View File
@@ -8,13 +8,15 @@ import PreviewPlayer, {
} from "@/components/player/PreviewPlayer";
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import QualitySelector from "@/components/player/QualitySelector";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import DetailStream from "@/components/timeline/DetailStream";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence";
import { useResizeObserver } from "@/hooks/resize-observer";
import { ExportMode } from "@/types/filter";
import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import {
@@ -57,9 +59,13 @@ import { VideoResolutionType } from "@/types/live";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
AutoQualityReason,
PlaybackQuality,
RecordingCoverage,
RecordingSegment,
RecordingStartingPoint,
} from "@/types/record";
import { isCodecFamilySupported } from "@/utils/codecSupport";
import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimezone } from "@/hooks/use-date-utils";
@@ -141,6 +147,15 @@ export function RecordingView({
},
]);
// feeds the quality selector's per-stream subtitles
const { data: coverage } = useSWR<RecordingCoverage>([
`${mainCamera}/recordings/coverage`,
{
before: timeRange.before,
after: timeRange.after,
},
]);
// controller state
const mainControllerRef = useRef<DynamicVideoController | null>(null);
@@ -280,14 +295,14 @@ export function RecordingView({
const [playerTime, setPlayerTime] = useState(startTime);
const updateSelectedSegment = useCallback(
(currentTime: number, updateStartTime: boolean) => {
(currentTime: number) => {
const index = findChunkIndex(chunkedTimeRange, currentTime);
if (index != -1) {
if (updateStartTime) {
setPlaybackStart(currentTime);
}
setPlaybackStart(currentTime);
// the outgoing chunk's player runs until the new source replaces
// it, reporting old positions while the new chunk loads
mainControllerRef.current?.pause();
setSelectedRangeIdx(index);
}
},
@@ -300,7 +315,10 @@ export function RecordingView({
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
) {
updateSelectedSegment(currentTime, false);
// the player rebuilds its source against playbackStart, and a
// stale anchor resolves to no startPosition, dropping playback
// at the start of the hour instead of the drag target
updateSelectedSegment(currentTime);
return;
}
@@ -328,9 +346,12 @@ export function RecordingView({
setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
// a source reload mid-seek resumes from playbackStart, so the
// anchor has to follow explicit seeks
setPlaybackStart(time);
mainControllerRef.current?.seekToTimestamp(time, play);
} else {
updateSelectedSegment(time, true);
updateSelectedSegment(time);
}
},
[currentTimeRange, updateSelectedSegment],
@@ -388,13 +409,15 @@ export function RecordingView({
shouldPlayback = mainControllerRef.current.isPlaying();
}
// see manuallySetCurrentTime
setPlaybackStart(currentTime);
mainControllerRef.current.seekToTimestamp(
currentTime,
shouldPlayback,
);
}
} else {
updateSelectedSegment(currentTime, true);
updateSelectedSegment(currentTime);
}
} else if (playerTime != currentTime && timelineType != "detail") {
mainControllerRef.current?.play();
@@ -409,6 +432,58 @@ export function RecordingView({
height: 0,
});
// playback quality
const [quality, setQuality] = usePersistence<PlaybackQuality>(
"recordingQuality",
"auto",
);
// lets the selector surface a downswitch instead of a mysterious drop
const [autoQualityLow, setAutoQualityLow] = useState<{
low: boolean;
reason?: AutoQualityReason;
}>({ low: false });
// the player re-notifies on every mount and quality reset, so keep the
// same state object when nothing changed
const onAutoQualityChange = useCallback(
(low: boolean, reason: AutoQualityReason | undefined) =>
setAutoQualityLow((prev) =>
prev.low === low && prev.reason === reason ? prev : { low, reason },
),
[],
);
// shown on the Original pin so a doomed selection is labeled
const mainCodecUnsupported = useMemo(
() =>
coverage?.streams?.main
? !isCodecFamilySupported(coverage.streams.main.video_codec)
: false,
[coverage],
);
// the pin is persisted globally, but the selector is hidden on cameras
// without a sub stream, leaving an inherited "sub" pin unable to unpin
const playerQuality = useMemo<PlaybackQuality | undefined>(
() =>
config && !config.cameras[mainCamera]?.record.sub.enabled
? "auto"
: quality,
[config, mainCamera, quality],
);
// a quality change swaps the playlist source, so anchor playback start
// the same way camera switching does to resume in place
const onSetQuality = useCallback(
(newQuality: PlaybackQuality) => {
setPlaybackStart(currentTime);
setQuality(newQuality);
},
[currentTime, setQuality],
);
const onSelectCamera = useCallback(
(newCam: string) => {
if (allowedCameras.includes(newCam)) {
@@ -768,6 +843,18 @@ export function RecordingView({
}}
/>
)}
{!isMobileOnly &&
config?.cameras[mainCamera]?.record.enabled &&
config?.cameras[mainCamera]?.record.sub.enabled && (
<QualitySelector
quality={quality ?? "auto"}
onSetQuality={onSetQuality}
streams={coverage?.streams}
autoLow={(quality ?? "auto") === "auto" && autoQualityLow.low}
autoLowReason={autoQualityLow.reason}
mainUnsupported={mainCodecUnsupported}
/>
)}
{isDesktop ? (
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
@@ -807,6 +894,23 @@ export function RecordingView({
/>
)}
<MobileReviewSettingsDrawer
// tablets keep the header selector, so only phones get
// quality in the drawer
features={
isMobileOnly &&
config?.cameras[mainCamera]?.record.enabled &&
config?.cameras[mainCamera]?.record.sub.enabled
? [...DEFAULT_DRAWER_FEATURES, "quality"]
: DEFAULT_DRAWER_FEATURES
}
quality={quality ?? "auto"}
onSetQuality={onSetQuality}
qualityStreams={coverage?.streams}
qualityAutoLow={
(quality ?? "auto") === "auto" && autoQualityLow.low
}
qualityAutoLowReason={autoQualityLow.reason}
qualityMainUnsupported={mainCodecUnsupported}
camera={mainCamera}
filter={filter}
currentTime={currentTime}
@@ -929,6 +1033,8 @@ export function RecordingView({
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
containerRef={mainLayoutRef}
quality={playerQuality}
onAutoQualityChange={onAutoQualityChange}
/>
</div>
{isDesktop && effectiveCameras.length > 1 && (
@@ -1131,6 +1237,20 @@ function Timeline({
},
]);
const { data: coverage } = useSWR<RecordingCoverage>([
`${mainCamera}/recordings/coverage`,
{
before: alignedBefore,
after: alignedAfter,
},
]);
const subOnlyRanges = useMemo(
() =>
coverage?.spans?.filter((span) => !span.streams.includes("main")) ?? [],
[coverage],
);
const [exportStart, setExportStartTime] = useState<number>(0);
const [exportEnd, setExportEndTime] = useState<number>(0);
@@ -1200,6 +1320,7 @@ function Timeline({
events={mainCameraReviewItems}
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
subOnlyRanges={subOnlyRanges}
contentRef={contentRef}
onHandlebarDraggingChange={setScrubbing}
isZooming={isZooming}