2026-03-05 17:53:48 -06:00
|
|
|
import {
|
|
|
|
|
ReactNode,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
2024-03-13 14:24:24 -06:00
|
|
|
import { useApiHost } from "@/api";
|
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
2026-08-16 13:59:03 -05:00
|
|
|
import {
|
|
|
|
|
AutoQualityReason,
|
|
|
|
|
PlaybackQuality,
|
|
|
|
|
Recording,
|
|
|
|
|
RecordingCoverage,
|
|
|
|
|
} from "@/types/record";
|
2024-03-13 14:24:24 -06:00
|
|
|
import { Preview } from "@/types/preview";
|
|
|
|
|
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
|
|
|
|
import { DynamicVideoController } from "./DynamicVideoController";
|
2025-08-16 07:09:15 -06:00
|
|
|
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
|
2025-10-18 13:19:21 -05:00
|
|
|
import { useDetailStream } from "@/context/detail-stream-context";
|
2024-04-14 10:14:10 -06:00
|
|
|
import { TimeRange } from "@/types/timeline";
|
2024-04-14 14:43:43 -06:00
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
2024-04-30 07:52:56 -05:00
|
|
|
import { VideoResolutionType } from "@/types/live";
|
2024-05-03 08:00:19 -06:00
|
|
|
import axios from "axios";
|
2026-06-03 17:17:04 -05:00
|
|
|
import { toast } from "sonner";
|
2024-05-07 09:00:25 -05:00
|
|
|
import { cn } from "@/lib/utils";
|
2025-03-16 23:36:20 +08:00
|
|
|
import { useTranslation } from "react-i18next";
|
2025-11-21 14:40:58 -07:00
|
|
|
import {
|
|
|
|
|
calculateInpointOffset,
|
|
|
|
|
calculateSeekPosition,
|
|
|
|
|
} from "@/utils/videoUtil";
|
2026-06-03 17:17:04 -05:00
|
|
|
import {
|
|
|
|
|
downloadSnapshot,
|
|
|
|
|
generateSnapshotFilename,
|
|
|
|
|
grabVideoSnapshot,
|
|
|
|
|
} from "@/utils/snapshotUtil";
|
2025-08-16 07:09:15 -06:00
|
|
|
import { isFirefox } from "react-device-detect";
|
2026-08-16 13:59:03 -05:00
|
|
|
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;
|
2024-03-13 14:24:24 -06:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Dynamically switches between video playback and scrubbing preview player.
|
|
|
|
|
*/
|
|
|
|
|
type DynamicVideoPlayerProps = {
|
|
|
|
|
className?: string;
|
|
|
|
|
camera: string;
|
2024-03-26 15:03:58 -06:00
|
|
|
timeRange: TimeRange;
|
2024-03-13 14:24:24 -06:00
|
|
|
cameraPreviews: Preview[];
|
|
|
|
|
startTimestamp?: number;
|
2024-03-17 18:53:47 -06:00
|
|
|
isScrubbing: boolean;
|
2024-03-26 15:37:45 -06:00
|
|
|
hotKeys: boolean;
|
2024-08-19 15:01:21 -06:00
|
|
|
supportsFullscreen: boolean;
|
2024-05-09 15:06:29 -06:00
|
|
|
fullscreen: boolean;
|
2024-03-13 14:24:24 -06:00
|
|
|
onControllerReady: (controller: DynamicVideoController) => void;
|
|
|
|
|
onTimestampUpdate?: (timestamp: number) => void;
|
|
|
|
|
onClipEnded?: () => void;
|
2026-07-01 15:03:34 -05:00
|
|
|
onClipPrevious?: (diff: number) => void;
|
2025-10-16 15:15:23 -05:00
|
|
|
onSeekToTime?: (timestamp: number, play?: boolean) => void;
|
2024-04-30 07:52:56 -05:00
|
|
|
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
2024-05-31 07:58:33 -05:00
|
|
|
toggleFullscreen: () => void;
|
2024-07-08 08:14:10 -05:00
|
|
|
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
2026-03-05 17:53:48 -06:00
|
|
|
transformedOverlay?: ReactNode;
|
2026-08-16 13:59:03 -05:00
|
|
|
quality?: PlaybackQuality;
|
|
|
|
|
onAutoQualityChange?: (
|
|
|
|
|
lowQuality: boolean,
|
|
|
|
|
reason: AutoQualityReason | undefined,
|
|
|
|
|
) => void;
|
2024-03-13 14:24:24 -06:00
|
|
|
};
|
|
|
|
|
export default function DynamicVideoPlayer({
|
|
|
|
|
className,
|
|
|
|
|
camera,
|
|
|
|
|
timeRange,
|
|
|
|
|
cameraPreviews,
|
|
|
|
|
startTimestamp,
|
2024-03-17 18:53:47 -06:00
|
|
|
isScrubbing,
|
2024-03-26 15:37:45 -06:00
|
|
|
hotKeys,
|
2024-08-19 15:01:21 -06:00
|
|
|
supportsFullscreen,
|
2024-05-09 15:06:29 -06:00
|
|
|
fullscreen,
|
2024-03-13 14:24:24 -06:00
|
|
|
onControllerReady,
|
|
|
|
|
onTimestampUpdate,
|
|
|
|
|
onClipEnded,
|
2026-07-01 15:03:34 -05:00
|
|
|
onClipPrevious,
|
2025-10-16 08:24:14 -05:00
|
|
|
onSeekToTime,
|
2024-04-30 07:52:56 -05:00
|
|
|
setFullResolution,
|
2024-05-31 07:58:33 -05:00
|
|
|
toggleFullscreen,
|
2024-07-08 08:14:10 -05:00
|
|
|
containerRef,
|
2026-03-05 17:53:48 -06:00
|
|
|
transformedOverlay,
|
2026-08-16 13:59:03 -05:00
|
|
|
quality,
|
|
|
|
|
onAutoQualityChange,
|
2024-03-13 14:24:24 -06:00
|
|
|
}: DynamicVideoPlayerProps) {
|
2026-06-03 17:17:04 -05:00
|
|
|
const { t } = useTranslation(["components/player", "views/live"]);
|
2024-03-13 14:24:24 -06:00
|
|
|
const apiHost = useApiHost();
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
|
2025-10-18 13:19:21 -05:00
|
|
|
// for detail stream context in History
|
2025-10-25 17:15:36 -05:00
|
|
|
const {
|
|
|
|
|
isDetailMode,
|
|
|
|
|
camera: contextCamera,
|
|
|
|
|
currentTime,
|
|
|
|
|
} = useDetailStream();
|
2025-10-18 13:19:21 -05:00
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
// controlling playback
|
|
|
|
|
|
|
|
|
|
const playerRef = useRef<HTMLVideoElement | null>(null);
|
|
|
|
|
const [previewController, setPreviewController] =
|
|
|
|
|
useState<PreviewController | null>(null);
|
2024-04-19 16:12:03 -06:00
|
|
|
const [noRecording, setNoRecording] = useState(false);
|
2024-03-13 14:24:24 -06:00
|
|
|
const controller = useMemo(() => {
|
|
|
|
|
if (!config || !playerRef.current || !previewController) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new DynamicVideoController(
|
|
|
|
|
camera,
|
|
|
|
|
playerRef.current,
|
|
|
|
|
previewController,
|
|
|
|
|
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
|
2024-03-14 09:28:06 -05:00
|
|
|
isScrubbing ? "scrubbing" : "playback",
|
2024-04-19 16:12:03 -06:00
|
|
|
setNoRecording,
|
2024-04-14 10:14:10 -06:00
|
|
|
() => {},
|
2024-03-13 14:24:24 -06:00
|
|
|
);
|
|
|
|
|
// we only want to fire once when players are ready
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [camera, config, playerRef.current, previewController]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!controller) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (controller) {
|
|
|
|
|
onControllerReady(controller);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// we only want to fire once when players are ready
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [controller]);
|
|
|
|
|
|
|
|
|
|
// initial state
|
|
|
|
|
|
2024-03-15 06:52:38 -06:00
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
2024-07-17 07:39:37 -06:00
|
|
|
const [isBuffering, setIsBuffering] = useState(false);
|
2026-08-16 13:59:03 -05:00
|
|
|
const loadingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
2025-11-21 14:40:58 -07:00
|
|
|
|
|
|
|
|
// Don't set source until recordings load - we need accurate startPosition
|
|
|
|
|
// to avoid hls.js clamping to video end when startPosition exceeds duration
|
|
|
|
|
const [source, setSource] = useState<HlsSource | undefined>(undefined);
|
2024-03-13 14:24:24 -06:00
|
|
|
|
|
|
|
|
// start at correct time
|
|
|
|
|
|
2024-03-15 06:52:38 -06:00
|
|
|
useEffect(() => {
|
2024-04-14 14:43:43 -06:00
|
|
|
if (!isScrubbing) {
|
2026-08-16 13:59:03 -05:00
|
|
|
loadingTimeoutRef.current = setTimeout(() => setIsLoading(true), 1000);
|
2024-03-15 06:52:38 -06:00
|
|
|
}
|
2024-04-16 14:21:52 -06:00
|
|
|
|
|
|
|
|
return () => {
|
2026-08-16 13:59:03 -05:00
|
|
|
if (loadingTimeoutRef.current) {
|
|
|
|
|
clearTimeout(loadingTimeoutRef.current);
|
2024-04-16 14:21:52 -06:00
|
|
|
}
|
2024-04-19 16:12:03 -06:00
|
|
|
};
|
2024-04-16 14:21:52 -06:00
|
|
|
}, [camera, isScrubbing]);
|
2024-03-15 06:52:38 -06:00
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
const onPlayerLoaded = useCallback(() => {
|
2026-08-16 13:59:03 -05:00
|
|
|
sourceLoadedRef.current = true;
|
|
|
|
|
governorRef.current?.sourceLoadEnded();
|
|
|
|
|
|
|
|
|
|
const anchor = sourceAnchorRef.current;
|
|
|
|
|
|
|
|
|
|
if (!controller || !anchor) {
|
2024-03-13 14:24:24 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// 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);
|
2024-03-13 14:24:24 -06:00
|
|
|
|
|
|
|
|
const onTimeUpdate = useCallback(
|
|
|
|
|
(time: number) => {
|
2026-08-16 13:59:03 -05:00
|
|
|
// safety net for stall or startup signals the player missed
|
|
|
|
|
governorRef.current?.stallEnded();
|
|
|
|
|
if (!sourceLoadedRef.current) {
|
|
|
|
|
sourceLoadedRef.current = true;
|
|
|
|
|
governorRef.current?.sourceLoadEnded();
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-19 07:34:49 -06:00
|
|
|
if (isScrubbing || !controller || !onTimestampUpdate || time == 0) {
|
2024-03-13 14:24:24 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// drop reports until the controller's model matches this chunk
|
|
|
|
|
if (
|
|
|
|
|
modelTimeRangeRef.current?.after !== timeRange.after ||
|
|
|
|
|
modelTimeRangeRef.current?.before !== timeRange.before
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-16 14:55:24 -06:00
|
|
|
if (isLoading) {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-17 07:39:37 -06:00
|
|
|
if (isBuffering) {
|
|
|
|
|
setIsBuffering(false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
const progress = controller.getProgress(time);
|
|
|
|
|
lastPlayedTimestampRef.current = progress;
|
|
|
|
|
onTimestampUpdate(progress);
|
2024-03-13 14:24:24 -06:00
|
|
|
},
|
2026-08-16 13:59:03 -05:00
|
|
|
[
|
|
|
|
|
controller,
|
|
|
|
|
onTimestampUpdate,
|
|
|
|
|
isBuffering,
|
|
|
|
|
isLoading,
|
|
|
|
|
isScrubbing,
|
|
|
|
|
timeRange,
|
|
|
|
|
],
|
2024-03-13 14:24:24 -06:00
|
|
|
);
|
|
|
|
|
|
2024-05-03 08:00:19 -06:00
|
|
|
const onUploadFrameToPlus = useCallback(
|
|
|
|
|
(playTime: number) => {
|
|
|
|
|
if (!controller) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const time = controller.getProgress(playTime);
|
|
|
|
|
return axios.post(`/${camera}/plus/${time}`);
|
|
|
|
|
},
|
|
|
|
|
[camera, controller],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-20 08:19:09 -05:00
|
|
|
const getSnapshotUrlForPlus = useCallback(
|
|
|
|
|
(playTime: number) => {
|
|
|
|
|
if (!controller) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const time = controller.getProgress(playTime);
|
|
|
|
|
if (!time) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return `${apiHost}api/${camera}/recordings/${time}/snapshot.jpg?height=500`;
|
|
|
|
|
},
|
|
|
|
|
[apiHost, camera, controller],
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-03 17:17:04 -05:00
|
|
|
const onDownloadSnapshot = useCallback(
|
|
|
|
|
async (playTime: number) => {
|
|
|
|
|
if (!controller || !playerRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// map the player time back to the timeline timestamp so the filename
|
|
|
|
|
// reflects the moment being viewed rather than the current time
|
|
|
|
|
const frameTime = controller.getProgress(playTime);
|
|
|
|
|
const result = await grabVideoSnapshot(playerRef.current);
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
downloadSnapshot(
|
|
|
|
|
result.data.dataUrl,
|
|
|
|
|
generateSnapshotFilename(camera, frameTime),
|
|
|
|
|
);
|
|
|
|
|
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[camera, controller, t],
|
|
|
|
|
);
|
|
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
// state of playback player
|
|
|
|
|
|
2024-05-20 08:44:39 -06:00
|
|
|
const recordingParams = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
before: timeRange.before,
|
|
|
|
|
after: timeRange.after,
|
2026-08-16 13:59:03 -05:00
|
|
|
timelines: true,
|
2024-05-20 08:44:39 -06:00
|
|
|
}),
|
|
|
|
|
[timeRange],
|
|
|
|
|
);
|
2026-08-16 13:59:03 -05:00
|
|
|
const { data: coverage } = useSWR<RecordingCoverage>(
|
|
|
|
|
[`${camera}/recordings/coverage`, recordingParams],
|
2024-03-13 14:24:24 -06:00
|
|
|
{ revalidateOnFocus: false },
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
useEffect(() => {
|
2026-08-16 13:59:03 -05:00
|
|
|
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;
|
|
|
|
|
|
2025-11-21 14:40:58 -07:00
|
|
|
if (!recordings?.length) {
|
2024-08-12 14:30:16 -06:00
|
|
|
if (recordings?.length == 0) {
|
2026-08-16 13:59:03 -05:00
|
|
|
// drop any stale source so the previous playlist unmounts
|
|
|
|
|
// instead of playing under the no-recording state
|
|
|
|
|
setSource(undefined);
|
2024-08-12 14:30:16 -06:00
|
|
|
setNoRecording(true);
|
2026-08-16 13:59:03 -05:00
|
|
|
// 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);
|
2024-08-12 14:30:16 -06:00
|
|
|
}
|
|
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// 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;
|
|
|
|
|
|
2025-11-20 15:58:58 -07:00
|
|
|
let startPosition = undefined;
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
if (anchorTimestamp) {
|
2025-11-20 15:58:58 -07:00
|
|
|
const inpointOffset = calculateInpointOffset(
|
|
|
|
|
recordingParams.after,
|
|
|
|
|
(recordings || [])[0],
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-21 14:40:58 -07:00
|
|
|
startPosition = calculateSeekPosition(
|
2026-08-16 13:59:03 -05:00
|
|
|
anchorTimestamp,
|
2025-11-21 14:40:58 -07:00
|
|
|
recordings,
|
|
|
|
|
inpointOffset,
|
|
|
|
|
);
|
2025-11-20 15:58:58 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-16 07:09:15 -06:00
|
|
|
setSource({
|
2026-08-16 13:59:03 -05:00
|
|
|
playlist,
|
2025-11-20 15:58:58 -07:00
|
|
|
startPosition,
|
2025-08-16 07:09:15 -06:00
|
|
|
});
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
// we only want to rebuild the source when the playlist itself changes;
|
|
|
|
|
// startTimestamp, timeRange, and the anchor refs are read as-of-rebuild
|
2025-11-21 14:40:58 -07:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-08-16 13:59:03 -05:00
|
|
|
}, [recordings, effectiveQuality]);
|
2025-11-21 14:40:58 -07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!controller || !recordings?.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (playerRef.current) {
|
|
|
|
|
playerRef.current.autoplay = !isScrubbing;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-03-13 14:24:24 -06:00
|
|
|
|
|
|
|
|
controller.newPlayback({
|
|
|
|
|
recordings: recordings ?? [],
|
2024-04-19 16:12:03 -06:00
|
|
|
timeRange,
|
2024-03-13 14:24:24 -06:00
|
|
|
});
|
2026-08-16 13:59:03 -05:00
|
|
|
modelTimeRangeRef.current = timeRange;
|
2024-03-13 14:24:24 -06:00
|
|
|
|
2025-11-21 14:40:58 -07:00
|
|
|
// we only want this to change when controller or recordings update
|
2024-03-13 14:24:24 -06:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [controller, recordings]);
|
|
|
|
|
|
2025-05-19 15:43:22 -05:00
|
|
|
const inpointOffset = useMemo(
|
|
|
|
|
() => calculateInpointOffset(recordingParams.after, (recordings || [])[0]),
|
|
|
|
|
[recordingParams, recordings],
|
|
|
|
|
);
|
2025-05-18 17:23:01 -06:00
|
|
|
|
2025-08-16 07:09:15 -06:00
|
|
|
const onValidateClipEnd = useCallback(
|
|
|
|
|
(currentTime: number) => {
|
|
|
|
|
if (!onClipEnded || !controller || !recordings) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFirefox) {
|
|
|
|
|
onClipEnded();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Firefox has a bug where clipEnded can be called prematurely due to buffering
|
|
|
|
|
// we need to validate if the current play-point is truly at the end of available recordings
|
|
|
|
|
|
|
|
|
|
const lastRecordingTime = recordings.at(-1)?.start_time;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!lastRecordingTime ||
|
|
|
|
|
controller.getProgress(currentTime) < lastRecordingTime
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onClipEnded();
|
|
|
|
|
},
|
|
|
|
|
[onClipEnded, controller, recordings],
|
|
|
|
|
);
|
|
|
|
|
|
2024-03-13 14:24:24 -06:00
|
|
|
return (
|
2024-03-30 12:44:12 -06:00
|
|
|
<>
|
2025-11-21 14:40:58 -07:00
|
|
|
{source && (
|
|
|
|
|
<HlsVideoPlayer
|
|
|
|
|
videoRef={playerRef}
|
|
|
|
|
containerRef={containerRef}
|
|
|
|
|
visible={!(isScrubbing || isLoading)}
|
|
|
|
|
currentSource={source}
|
|
|
|
|
hotKeys={hotKeys}
|
|
|
|
|
supportsFullscreen={supportsFullscreen}
|
|
|
|
|
fullscreen={fullscreen}
|
|
|
|
|
inpointOffset={inpointOffset}
|
|
|
|
|
onTimeUpdate={onTimeUpdate}
|
|
|
|
|
onPlayerLoaded={onPlayerLoaded}
|
|
|
|
|
onClipEnded={onValidateClipEnd}
|
2026-07-01 15:03:34 -05:00
|
|
|
onClipPrevious={onClipPrevious}
|
2025-11-21 14:40:58 -07:00
|
|
|
onSeekToTime={(timestamp, play) => {
|
|
|
|
|
if (onSeekToTime) {
|
|
|
|
|
onSeekToTime(timestamp, play);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onPlaying={() => {
|
|
|
|
|
if (isScrubbing) {
|
|
|
|
|
playerRef.current?.pause();
|
|
|
|
|
}
|
2024-03-19 07:34:49 -06:00
|
|
|
|
2026-08-16 13:59:03 -05:00
|
|
|
if (loadingTimeoutRef.current) {
|
|
|
|
|
clearTimeout(loadingTimeoutRef.current);
|
2025-11-21 14:40:58 -07:00
|
|
|
}
|
2024-04-14 14:43:43 -06:00
|
|
|
|
2025-11-21 14:40:58 -07:00
|
|
|
setNoRecording(false);
|
|
|
|
|
}}
|
|
|
|
|
setFullResolution={setFullResolution}
|
|
|
|
|
onUploadFrame={onUploadFrameToPlus}
|
2026-04-20 08:19:09 -05:00
|
|
|
getSnapshotUrl={getSnapshotUrlForPlus}
|
2026-06-03 17:17:04 -05:00
|
|
|
onSnapshot={onDownloadSnapshot}
|
2025-11-21 14:40:58 -07:00
|
|
|
toggleFullscreen={toggleFullscreen}
|
|
|
|
|
onError={(error) => {
|
|
|
|
|
if (error == "stalled" && !isScrubbing) {
|
|
|
|
|
setIsBuffering(true);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-08-16 13:59:03 -05:00
|
|
|
onStallStart={onStallStart}
|
|
|
|
|
onStallEnd={onStallEnd}
|
|
|
|
|
onSeekStart={onSeekStart}
|
|
|
|
|
onBandwidthSample={onBandwidthSample}
|
|
|
|
|
onFatalNetworkError={onFatalNetworkError}
|
|
|
|
|
onFatalCodecError={onFatalCodecError}
|
|
|
|
|
initialBandwidthEstimate={governor.bandwidthEstimate}
|
|
|
|
|
bufferLength={
|
|
|
|
|
effectiveQuality === "sub" ? SUB_STREAM_BUFFER_LENGTH_S : undefined
|
|
|
|
|
}
|
2025-11-21 14:40:58 -07:00
|
|
|
isDetailMode={isDetailMode}
|
|
|
|
|
camera={contextCamera || camera}
|
|
|
|
|
currentTimeOverride={currentTime}
|
2026-03-05 17:53:48 -06:00
|
|
|
transformedOverlay={transformedOverlay}
|
2025-11-21 14:40:58 -07:00
|
|
|
/>
|
|
|
|
|
)}
|
2024-03-13 14:24:24 -06:00
|
|
|
<PreviewPlayer
|
2024-05-07 09:00:25 -05:00
|
|
|
className={cn(
|
|
|
|
|
className,
|
2024-05-09 07:20:33 -06:00
|
|
|
isScrubbing || isLoading ? "visible" : "hidden",
|
2024-05-07 09:00:25 -05:00
|
|
|
)}
|
2024-03-13 14:24:24 -06:00
|
|
|
camera={camera}
|
|
|
|
|
timeRange={timeRange}
|
|
|
|
|
cameraPreviews={cameraPreviews}
|
2024-03-15 06:52:38 -06:00
|
|
|
startTime={startTimestamp}
|
2024-03-17 18:53:47 -06:00
|
|
|
isScrubbing={isScrubbing}
|
2024-07-17 07:39:37 -06:00
|
|
|
onControllerReady={(previewController) =>
|
|
|
|
|
setPreviewController(previewController)
|
|
|
|
|
}
|
2024-03-13 14:24:24 -06:00
|
|
|
/>
|
2024-07-17 07:39:37 -06:00
|
|
|
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
|
2024-04-16 14:21:52 -06:00
|
|
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
2024-04-14 14:43:43 -06:00
|
|
|
)}
|
2024-07-21 13:14:59 -06:00
|
|
|
{!isScrubbing && !isLoading && noRecording && (
|
2024-04-19 16:12:03 -06:00
|
|
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
2025-03-16 23:36:20 +08:00
|
|
|
{t("noRecordingsFoundForThisTime")}
|
2024-04-19 16:12:03 -06:00
|
|
|
</div>
|
|
|
|
|
)}
|
2024-03-30 12:44:12 -06:00
|
|
|
</>
|
2024-03-13 14:24:24 -06:00
|
|
|
);
|
|
|
|
|
}
|