+ {renderField(index, "roles", {
+ extraUiSchema: {
+ "ui:options": {
+ rolesUsedByOtherInputs:
+ getRolesUsedByOtherInputs(index),
+ },
+ },
+ })}
+
{renderField(index, "input_args")}
diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx
index 527789c814..e519d373dd 100644
--- a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx
+++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx
@@ -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) {
}
/>
> = {
+ 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) {
{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"
>
-
+
+
+ {hint ? (
+ {hint}
+ ) : null}
+
toggleRole(role, !!enabled)}
/>
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx
index c5c1a1c5b0..bebfec8ac3 100644
--- a/web/src/components/filter/ReviewFilterGroup.tsx
+++ b/web/src/components/filter/ReviewFilterGroup.tsx
@@ -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";
diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
index 47a5236618..96eba6be6e 100644
--- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
+++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
@@ -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")}
)}
+ {features.includes("quality") && onSetQuality && (
+
+ )}
{features.includes("share-timestamp") && (
);
+ } else if (drawerMode == "quality") {
+ content = (
+
+
+
setDrawerMode("select")}
+ >
+ {t("button.back", { ns: "common" })}
+
+
+ {t("quality.label", { ns: "components/player" })}
+
+
+
{
+ onSetQuality?.(newQuality);
+ setDrawerMode("none");
+ }}
+ streams={qualityStreams}
+ autoLow={qualityAutoLow}
+ autoLowReason={qualityAutoLowReason}
+ mainUnsupported={qualityMainUnsupported}
+ />
+
+ );
} else if (drawerMode == "share-timestamp") {
content = (
diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx
index c1aaa09d68..e0d606cffb 100644
--- a/web/src/components/player/HlsVideoPlayer.tsx
+++ b/web/src/components/player/HlsVideoPlayer.tsx
@@ -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;
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(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();
+ // 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 = {
- 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",
+ },
+ );
}}
/>
diff --git a/web/src/components/player/QualitySelector.tsx b/web/src/components/player/QualitySelector.tsx
new file mode 100644
index 0000000000..9457a22d73
--- /dev/null
+++ b/web/src/components/player/QualitySelector.tsx
@@ -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 = {
+ h264: "H.264",
+ hevc: "H.265",
+ h265: "H.265",
+ av1: "AV1",
+};
+
+const AUDIO_CODEC_DISPLAY_NAMES: Record = {
+ 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;
+};
+
+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>>(
+ () => ({
+ 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) => (
+
+ {t(`quality.${q}`)}
+ {subtitles[q] && (
+ {subtitles[q]}
+ )}
+
+ ),
+ [subtitles, t],
+ );
+
+ const trigger = (
+
+ );
+
+ return (
+ {
+ if (setControlsOpen) {
+ setControlsOpen(open);
+ }
+ }}
+ >
+ {trigger}
+
+ onSetQuality(value as PlaybackQuality)}
+ >
+ {PLAYBACK_QUALITIES.map((q) => (
+
+ {itemContent(q)}
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+ {PLAYBACK_QUALITIES.map((q) => (
+
onSetQuality(q)}
+ >
+
{t(`quality.${q}`)}
+ {subtitles[q] && (
+
{subtitles[q]}
+ )}
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/player/dynamic/AutoQualityGovernor.ts b/web/src/components/player/dynamic/AutoQualityGovernor.ts
new file mode 100644
index 0000000000..81cf9edc71
--- /dev/null
+++ b/web/src/components/player/dynamic/AutoQualityGovernor.ts
@@ -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 | undefined;
+ private startupTimer: ReturnType | 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,
+ );
+ }
+}
diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts
index 151ea4022f..3e48e008e3 100644
--- a/web/src/components/player/dynamic/DynamicVideoController.ts
+++ b/web/src/components/player/dynamic/DynamicVideoController.ts
@@ -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();
}
diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
index 8998be7a60..a046235b47 100644
--- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
+++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
@@ -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;
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();
+ const loadingTimeoutRef = useRef(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(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(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(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(
- [`${camera}/recordings`, recordingParams],
+ const { data: coverage } = useSWR(
+ [`${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(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("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(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(() => {
+ 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}
diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx
index 7b5c558a8f..7ba5089aa5 100644
--- a/web/src/components/settings/wizard/Step3StreamConfig.tsx
+++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx
@@ -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: "record_sub",
+ record_sub: "record",
+};
+
type Step3StreamConfigProps = {
wizardData: Partial;
onUpdate: (data: Partial) => 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({
record -{" "}
{t("cameraWizard.step3.rolesPopover.record")}
+
+ record_sub -{" "}
+ {t("cameraWizard.step3.rolesPopover.record_sub")}
+
audio -{" "}
{t("cameraWizard.step3.rolesPopover.audio")}
@@ -639,25 +653,35 @@ export default function Step3StreamConfig({
- {(["detect", "record", "audio"] as const).map((role) => {
- const isUsedElsewhere = getUsedRolesExcludingStream(
- stream.id,
- ).has(role);
- const isChecked = stream.roles.includes(role);
- return (
-
- {role}
- toggleRole(stream.id, role)}
- disabled={!isChecked && isUsedElsewhere}
- />
-
- );
- })}
+ {(["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 (
+
+ {role}
+
+ toggleRole(stream.id, role)
+ }
+ disabled={
+ !isChecked && (isUsedElsewhere || hasConflict)
+ }
+ />
+
+ );
+ },
+ )}
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx
index 2796bc968a..382ad03171 100644
--- a/web/src/components/timeline/MotionReviewTimeline.tsx
+++ b/web/src/components/timeline/MotionReviewTimeline.tsx
@@ -42,6 +42,7 @@ export type MotionReviewTimelineProps = {
events: ReviewSegment[];
motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[];
+ subOnlyRanges?: Pick[];
contentRef: RefObject;
timelineRef?: RefObject;
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}
/>
diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx
index 90ce5e1a56..3eef9db1b5 100644
--- a/web/src/components/timeline/MotionSegment.tsx
+++ b/web/src/components/timeline/MotionSegment.tsx
@@ -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)}
>
diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx
index a98593d893..cd29fce066 100644
--- a/web/src/components/timeline/VirtualizedMotionSegments.tsx
+++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx
@@ -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,
diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts
index 1a64419bf3..66747ab8d6 100644
--- a/web/src/hooks/use-draggable-element.ts
+++ b/web/src/hooks/use-draggable-element.ts
@@ -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;
timelineRef: React.RefObject;
@@ -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(
diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts
index 20e8436359..6a7a9fc90a 100644
--- a/web/src/types/cameraWizard.ts
+++ b/web/src/types/cameraWizard.ts
@@ -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;
diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts
index c38e823deb..28ddb0f9d0 100644
--- a/web/src/types/filter.ts
+++ b/web/src/types/filter.ts
@@ -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[];
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index 6308266859..e554d10e6f 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -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;
diff --git a/web/src/types/record.ts b/web/src/types/record.ts
index d8fd163bf9..00fff522ed 100644
--- a/web/src/types/record.ts
+++ b/web/src/types/record.ts
@@ -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;
diff --git a/web/src/utils/codecSupport.ts b/web/src/utils/codecSupport.ts
new file mode 100644
index 0000000000..913f8ed3c7
--- /dev/null
+++ b/web/src/utils/codecSupport.ts
@@ -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 = {
+ 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 = {
+ 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);
+}
diff --git a/web/src/utils/videoUtil.ts b/web/src/utils/videoUtil.ts
index d6ab203e93..6b471665fe 100644
--- a/web/src/utils/videoUtil.ts
+++ b/web/src/utils/videoUtil.ts
@@ -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;
});
diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx
index 5b3b9283e0..2a4a8dafd2 100644
--- a/web/src/views/motion-search/MotionSearchView.tsx
+++ b/web/src/views/motion-search/MotionSearchView.tsx
@@ -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],
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx
index 1595e315a9..2172489cc5 100644
--- a/web/src/views/recording/RecordingView.tsx
+++ b/web/src/views/recording/RecordingView.tsx
@@ -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([
+ `${mainCamera}/recordings/coverage`,
+ {
+ before: timeRange.before,
+ after: timeRange.after,
+ },
+ ]);
+
// controller state
const mainControllerRef = useRef(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(
+ "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(
+ () =>
+ 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 && (
+
+ )}
{isDesktop ? (
)}
{isDesktop && effectiveCameras.length > 1 && (
@@ -1131,6 +1237,20 @@ function Timeline({
},
]);
+ const { data: coverage } = useSWR([
+ `${mainCamera}/recordings/coverage`,
+ {
+ before: alignedBefore,
+ after: alignedAfter,
+ },
+ ]);
+
+ const subOnlyRanges = useMemo(
+ () =>
+ coverage?.spans?.filter((span) => !span.streams.includes("main")) ?? [],
+ [coverage],
+ );
+
const [exportStart, setExportStartTime] = useState(0);
const [exportEnd, setExportEndTime] = useState(0);
@@ -1200,6 +1320,7 @@ function Timeline({
events={mainCameraReviewItems}
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
+ subOnlyRanges={subOnlyRanges}
contentRef={contentRef}
onHandlebarDraggingChange={setScrubbing}
isZooming={isZooming}