Merge branch 'dev' into yaml_loader

This commit is contained in:
ubawurinna 2024-06-29 18:21:15 +02:00
commit 84442f4fc5
23 changed files with 346 additions and 118 deletions

View File

@ -10,9 +10,9 @@
"features": { "features": {
"ghcr.io/devcontainers/features/common-utils:1": {} "ghcr.io/devcontainers/features/common-utils:1": {}
}, },
"forwardPorts": [8080, 5000, 5001, 5173, 8554, 8555], "forwardPorts": [8971, 5000, 5001, 5173, 8554, 8555],
"portsAttributes": { "portsAttributes": {
"8080": { "8971": {
"label": "External NGINX", "label": "External NGINX",
"onAutoForward": "silent" "onAutoForward": "silent"
}, },

View File

@ -78,7 +78,7 @@ COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \ RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
# Get Audio Model and labels # Get Audio Model and labels
RUN wget -qO cpu_audio_model.tflite https://tfhub.dev/google/lite-model/yamnet/classification/tflite/1?lite-format=tflite RUN wget -qO cpu_audio_model.tflite https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download
COPY audio-labelmap.txt . COPY audio-labelmap.txt .

View File

@ -34,7 +34,7 @@ do
;; ;;
esac esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:8080 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'` liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:8971 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
case "$liveprint" in case "$liveprint" in
*Fingerprint*) *Fingerprint*)

View File

@ -59,9 +59,6 @@ http {
include go2rtc_upstream.conf; include go2rtc_upstream.conf;
server { server {
# intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off;
include listen.conf; include listen.conf;
# vod settings # vod settings

View File

@ -1,9 +1,12 @@
{{ if not .enabled }} {{ if not .enabled }}
# intended for external traffic, protected by auth # intended for external traffic, protected by auth
listen [::]:8080 ipv6only=off; listen 8971;
{{ else }} {{ else }}
# intended for external traffic, protected by auth # intended for external traffic, protected by auth
listen [::]:8080 ipv6only=off ssl; listen 8971 ssl;
# intended for internal traffic, not protected by auth
listen 5000;
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;

View File

@ -106,7 +106,53 @@ Some labels have special handling and modifications can disable functionality.
::: :::
## Custom ffmpeg build ## Network Configuration
Changes to Frigate's internal network configuration can be made by bind mounting nginx.conf into the container. For example:
```yaml
services:
frigate:
container_name: frigate
...
volumes:
...
- /path/to/your/nginx.conf:/usr/local/nginx/conf/nginx.conf
```
### Enabling IPv6
IPv6 is disabled by default, to enable IPv6 listen.gotmpl needs to be bind mounted with IPv6 enabled. For example:
```
{{ if not .enabled }}
# intended for external traffic, protected by auth
listen 8971;
{{ else }}
# intended for external traffic, protected by auth
listen 8971 ssl;
# intended for internal traffic, not protected by auth
listen 5000;
```
becomes
```
{{ if not .enabled }}
# intended for external traffic, protected by auth
listen [::]:8971 ipv6only=off;
{{ else }}
# intended for external traffic, protected by auth
listen [::]:8971 ipv6only=off ssl;
# intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off;
```
## Custom Dependencies
### Custom ffmpeg build
Included with Frigate is a build of ffmpeg that works for the vast majority of users. However, there exists some hardware setups which have incompatibilities with the included build. In this case, a docker volume mapping can be used to overwrite the included ffmpeg build with an ffmpeg build that works for your specific hardware setup. Included with Frigate is a build of ffmpeg that works for the vast majority of users. However, there exists some hardware setups which have incompatibilities with the included build. In this case, a docker volume mapping can be used to overwrite the included ffmpeg build with an ffmpeg build that works for your specific hardware setup.
@ -118,7 +164,7 @@ To do this:
NOTE: The folder that is mapped from the host needs to be the folder that contains `/bin`. So if the full structure is `/home/appdata/frigate/custom-ffmpeg/bin/ffmpeg` then `/home/appdata/frigate/custom-ffmpeg` needs to be mapped to `/usr/lib/btbn-ffmpeg`. NOTE: The folder that is mapped from the host needs to be the folder that contains `/bin`. So if the full structure is `/home/appdata/frigate/custom-ffmpeg/bin/ffmpeg` then `/home/appdata/frigate/custom-ffmpeg` needs to be mapped to `/usr/lib/btbn-ffmpeg`.
## Custom go2rtc version ### Custom go2rtc version
Frigate currently includes go2rtc v1.9.4, there may be certain cases where you want to run a different version of go2rtc. Frigate currently includes go2rtc v1.9.4, there may be certain cases where you want to run a different version of go2rtc.

View File

@ -13,7 +13,7 @@ The following ports are available to access the Frigate web UI.
| Port | Description | | Port | Description |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `8080` | Authenticated UI and API. Reverse proxies should use this port. | | `8971` | Authenticated UI and API. Reverse proxies should use this port. |
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate and do not support authentication. | | `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate and do not support authentication. |
## Onboarding ## Onboarding

View File

@ -7,13 +7,15 @@ Frigate intelligently displays your camera streams on the Live view dashboard. Y
## Live View technologies ## Live View technologies
Frigate intelligently uses three different streaming technologies to display your camera streams. The highest quality and fluency of the Live view requires the bundled `go2rtc` to be configured as shown in the [step by step guide](/guides/configuring_go2rtc). Frigate intelligently uses three different streaming technologies to display your camera streams on the dashboard and the single camera view, switching between available modes based on network bandwidth, player errors, or required features like two-way talk. The highest quality and fluency of the Live view requires the bundled `go2rtc` to be configured as shown in the [step by step guide](/guides/configuring_go2rtc).
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations | The jsmpeg live view will use more browser and client GPU resources. Using go2rtc is highly recommended and will provide a superior experience.
| ------ | ------- | ------------------------------------- | -------------- | ---------------------------- | --------------- | ------------------------------------------------ |
| jsmpeg | low | same as `detect -> fps`, capped at 10 | same as detect | no | no | none | | Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
| mse | low | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only | | ------ | ------- | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------ |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 | | jsmpeg | low | same as `detect -> fps`, capped at 10 | 720p | no | no | resolution is configurable, but go2rtc is recommended if you want higher resolutions |
| mse | low | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
### Audio Support ### Audio Support

View File

@ -65,7 +65,7 @@ database:
# Optional: TLS configuration # Optional: TLS configuration
tls: tls:
# Optional: Enable TLS for port 8080 (default: shown below) # Optional: Enable TLS for port 8971 (default: shown below)
enabled: True enabled: True
# Optional: Proxy configuration # Optional: Proxy configuration

View File

@ -5,7 +5,7 @@ title: TLS
# TLS # TLS
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8080. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates. Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. You will likely need to set your reverse proxy to allow self signed certificates or you can disable TLS in Frigate's config. However, if you are running on a dedicated device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS with valid certificates. Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. You will likely need to set your reverse proxy to allow self signed certificates or you can disable TLS in Frigate's config. However, if you are running on a dedicated device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS with valid certificates.
@ -44,13 +44,13 @@ frigate:
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate. Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8080 to 443. If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
```yaml ```yaml
frigate: frigate:
... ...
ports: ports:
- "443:8080" - "443:8971"
... ...
``` ```

View File

@ -34,7 +34,7 @@ The following ports are used by Frigate and can be mapped via docker as required
| Port | Description | | Port | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `8080` | Authenticated UI and API access without TLS. Reverse proxies should use this port. | | `8971` | Authenticated UI and API access without TLS. Reverse proxies should use this port. |
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. | | `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. |
| `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. | | `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. |
| `8555` | WebRTC connections for low latency live views. | | `8555` | WebRTC connections for low latency live views. |
@ -171,7 +171,7 @@ services:
tmpfs: tmpfs:
size: 1000000000 size: 1000000000
ports: ports:
- "8080:8080" - "8971:8971"
# - "5000:5000" # Internal unauthenticated access. Expose carefully. # - "5000:5000" # Internal unauthenticated access. Expose carefully.
- "8554:8554" # RTSP feeds - "8554:8554" # RTSP feeds
- "8555:8555/tcp" # WebRTC over tcp - "8555:8555/tcp" # WebRTC over tcp
@ -194,7 +194,7 @@ docker run -d \
-v /path/to/your/config:/config \ -v /path/to/your/config:/config \
-v /etc/localtime:/etc/localtime:ro \ -v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \ -e FRIGATE_RTSP_PASSWORD='password' \
-p 8080:8080 \ -p 8971:8971 \
-p 8554:8554 \ -p 8554:8554 \
-p 8555:8555/tcp \ -p 8555:8555/tcp \
-p 8555:8555/udp \ -p 8555:8555/udp \
@ -370,7 +370,7 @@ docker run \
--network=bridge \ --network=bridge \
--privileged \ --privileged \
--workdir=/opt/frigate \ --workdir=/opt/frigate \
-p 8080:8080 \ -p 8971:8971 \
-p 8554:8554 \ -p 8554:8554 \
-p 8555:8555 \ -p 8555:8555 \
-p 8555:8555/udp \ -p 8555:8555/udp \

View File

@ -117,7 +117,7 @@ services:
tmpfs: tmpfs:
size: 1000000000 size: 1000000000
ports: ports:
- "8080:8080" - "8971:8971"
- "8554:8554" # RTSP feeds - "8554:8554" # RTSP feeds
``` ```
@ -137,7 +137,7 @@ cameras:
- detect - detect
``` ```
Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. On startup, an admin user and password will be created and outputted in the logs. You can see this by running `docker logs frigate`. Frigate should now be accessible at `https://server_ip:8080` where you can login with the `admin` user and finish the configuration using the built-in configuration editor. Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. On startup, an admin user and password will be created and outputted in the logs. You can see this by running `docker logs frigate`. Frigate should now be accessible at `https://server_ip:8971` where you can login with the `admin` user and finish the configuration using the built-in configuration editor.
## Configuring Frigate ## Configuring Frigate

View File

@ -38,20 +38,20 @@ Here we access Frigate via https://cctv.mydomain.co.uk
ServerName cctv.mydomain.co.uk ServerName cctv.mydomain.co.uk
ProxyPreserveHost On ProxyPreserveHost On
ProxyPass "/" "http://frigatepi.local:8080/" ProxyPass "/" "http://frigatepi.local:8971/"
ProxyPassReverse "/" "http://frigatepi.local:8080/" ProxyPassReverse "/" "http://frigatepi.local:8971/"
ProxyPass /ws ws://frigatepi.local:8080/ws ProxyPass /ws ws://frigatepi.local:8971/ws
ProxyPassReverse /ws ws://frigatepi.local:8080/ws ProxyPassReverse /ws ws://frigatepi.local:8971/ws
ProxyPass /live/ ws://frigatepi.local:8080/live/ ProxyPass /live/ ws://frigatepi.local:8971/live/
ProxyPassReverse /live/ ws://frigatepi.local:8080/live/ ProxyPassReverse /live/ ws://frigatepi.local:8971/live/
RewriteEngine on RewriteEngine on
RewriteCond %{HTTP:Upgrade} =websocket [NC] RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://frigatepi.local:8080/$1 [P,L] RewriteRule /(.*) ws://frigatepi.local:8971/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC] RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://frigatepi.local:8080/$1 [P,L] RewriteRule /(.*) http://frigatepi.local:8971/$1 [P,L]
</VirtualHost> </VirtualHost>
``` ```
@ -101,7 +101,7 @@ This is set in `$server` and `$port` this should match your ports you have expos
server { server {
set $forward_scheme http; set $forward_scheme http;
set $server "192.168.100.2"; # FRIGATE SERVER LOCATION set $server "192.168.100.2"; # FRIGATE SERVER LOCATION
set $port 8080; set $port 8971;
listen 80; listen 80;
listen 443 ssl http2; listen 443 ssl http2;

View File

@ -637,7 +637,7 @@ def vod_event(id):
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false # If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
if ( if (
event.start_time < datetime.now().timestamp() - 300 event.start_time < datetime.now().timestamp() - 300
and type(vod_response) == tuple and type(vod_response) is tuple
and len(vod_response) == 2 and len(vod_response) == 2
and vod_response[1] == 404 and vod_response[1] == 404
): ):

View File

@ -116,7 +116,7 @@ class UIConfig(FrigateBaseModel):
class TlsConfig(FrigateBaseModel): class TlsConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Enable TLS for port 8080") enabled: bool = Field(default=True, title="Enable TLS for port 8971")
class HeaderMappingConfig(FrigateBaseModel): class HeaderMappingConfig(FrigateBaseModel):

View File

@ -26,13 +26,12 @@ export default function CameraImage({
const { name } = config ? config.cameras[camera] : ""; const { name } = config ? config.cameras[camera] : "";
const enabled = config ? config.cameras[camera].enabled : "True"; const enabled = config ? config.cameras[camera].enabled : "True";
const [isPortraitImage, setIsPortraitImage] = useState(false);
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef); useResizeObserver(containerRef);
const requestHeight = useMemo(() => { const requestHeight = useMemo(() => {
if (!config || containerHeight == 0) { if (!config || containerHeight == 0 || !hasLoaded) {
return 360; return 360;
} }
@ -40,7 +39,14 @@ export default function CameraImage({
config.cameras[camera].detect.height, config.cameras[camera].detect.height,
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)), Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
); );
}, [config, camera, containerHeight]); }, [config, camera, containerHeight, hasLoaded]);
const isPortraitImage = useMemo(() => {
if (imgRef.current && containerWidth && containerHeight && hasLoaded) {
const { naturalHeight, naturalWidth } = imgRef.current;
return naturalWidth / naturalHeight < containerWidth / containerHeight;
}
}, [containerWidth, containerHeight, hasLoaded]);
useEffect(() => { useEffect(() => {
if (!config || !imgRef.current) { if (!config || !imgRef.current) {
@ -61,13 +67,6 @@ export default function CameraImage({
onLoad={() => { onLoad={() => {
setHasLoaded(true); setHasLoaded(true);
if (imgRef.current) {
const { naturalHeight, naturalWidth } = imgRef.current;
setIsPortraitImage(
naturalWidth / naturalHeight < containerWidth / containerHeight,
);
}
if (onload) { if (onload) {
onload(); onload();
} }

View File

@ -12,7 +12,7 @@ type LivePlayerProps = {
birdseyeConfig: BirdseyeConfig; birdseyeConfig: BirdseyeConfig;
liveMode: LivePlayerMode; liveMode: LivePlayerMode;
onClick?: () => void; onClick?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef: React.MutableRefObject<HTMLDivElement | null>;
}; };
export default function BirdseyeLivePlayer({ export default function BirdseyeLivePlayer({
@ -54,6 +54,7 @@ export default function BirdseyeLivePlayer({
width={birdseyeConfig.width} width={birdseyeConfig.width}
height={birdseyeConfig.height} height={birdseyeConfig.height}
containerRef={containerRef} containerRef={containerRef}
playbackEnabled={true}
/> />
); );
} else { } else {
@ -62,6 +63,7 @@ export default function BirdseyeLivePlayer({
return ( return (
<div <div
ref={containerRef}
className={cn( className={cn(
"relative flex w-full cursor-pointer justify-center", "relative flex w-full cursor-pointer justify-center",
className, className,

View File

@ -3,14 +3,15 @@ import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// @ts-expect-error we know this doesn't have types // @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player"; import JSMpeg from "@cycjimmy/jsmpeg-player";
import React, { useEffect, useMemo, useRef, useId, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
type JSMpegPlayerProps = { type JSMpegPlayerProps = {
className?: string; className?: string;
camera: string; camera: string;
width: number; width: number;
height: number; height: number;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef: React.MutableRefObject<HTMLDivElement | null>;
playbackEnabled: boolean;
onPlaying?: () => void; onPlaying?: () => void;
}; };
@ -20,18 +21,21 @@ export default function JSMpegPlayer({
height, height,
className, className,
containerRef, containerRef,
playbackEnabled,
onPlaying, onPlaying,
}: JSMpegPlayerProps) { }: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const playerRef = useRef<HTMLDivElement | null>(null); const videoRef = useRef<HTMLDivElement>(null);
const videoRef = useRef(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const internalContainerRef = useRef<HTMLDivElement | null>(null); const internalContainerRef = useRef<HTMLDivElement | null>(null);
const onPlayingRef = useRef(onPlaying); const onPlayingRef = useRef(onPlaying);
const [showCanvas, setShowCanvas] = useState(false); const [showCanvas, setShowCanvas] = useState(false);
const selectedContainerRef = useMemo( const selectedContainerRef = useMemo(
() => containerRef ?? internalContainerRef, () => (containerRef.current ? containerRef : internalContainerRef),
[containerRef, internalContainerRef], // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerRef, containerRef.current, internalContainerRef],
); );
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
@ -83,39 +87,64 @@ export default function JSMpegPlayer({
} }
}, [scaledHeight, aspectRatio]); }, [scaledHeight, aspectRatio]);
const uniqueId = useId();
useEffect(() => { useEffect(() => {
onPlayingRef.current = onPlaying; onPlayingRef.current = onPlaying;
}, [onPlaying]); }, [onPlaying]);
useEffect(() => { useEffect(() => {
if (!playerRef.current || videoRef.current) { if (!selectedContainerRef?.current || !url) {
return; return;
} }
videoRef.current = new JSMpeg.VideoElement( const videoWrapper = videoRef.current;
playerRef.current, const canvas = canvasRef.current;
url, let hasData = false;
{ canvas: `#${CSS.escape(uniqueId)}` }, let videoElement: JSMpeg.VideoElement | null = null;
{
protocols: [], if (videoWrapper && playbackEnabled) {
audio: false, // Delayed init to avoid issues with react strict mode
videoBufferSize: 1024 * 1024 * 4, const initPlayer = setTimeout(() => {
onPlay: () => { videoElement = new JSMpeg.VideoElement(
setShowCanvas(true); videoWrapper,
onPlayingRef.current?.(); url,
}, { canvas: canvas },
}, {
); protocols: [],
}, [url, uniqueId]); audio: false,
videoBufferSize: 1024 * 1024 * 4,
onVideoDecode: () => {
if (!hasData) {
hasData = true;
setShowCanvas(true);
onPlayingRef.current?.();
}
},
},
);
}, 0);
return () => {
clearTimeout(initPlayer);
if (videoElement) {
try {
// this causes issues in react strict mode
// https://stackoverflow.com/questions/76822128/issue-with-cycjimmy-jsmpeg-player-in-react-18-cannot-read-properties-of-null-o
videoElement.destroy();
// eslint-disable-next-line no-empty
} catch (e) {}
}
};
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled, url]);
return ( return (
<div className={className}> <div className={cn(className, !containerRef.current && "size-full")}>
<div className="size-full" ref={internalContainerRef}> <div className="internal-jsmpeg-container" ref={internalContainerRef}>
<div ref={playerRef} className={cn("jsmpeg", !showCanvas && "hidden")}> <div ref={videoRef} className={cn("jsmpeg", !showCanvas && "hidden")}>
<canvas <canvas
id={uniqueId} ref={canvasRef}
style={{ style={{
width: scaledWidth ?? width, width: scaledWidth ?? width,
height: scaledHeight ?? height, height: scaledHeight ?? height,

View File

@ -2,7 +2,7 @@ import WebRtcPlayer from "./WebRTCPlayer";
import { CameraConfig } from "@/types/frigateConfig"; import { CameraConfig } from "@/types/frigateConfig";
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage"; import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import MSEPlayer from "./MsePlayer"; import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer"; import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
@ -55,6 +55,7 @@ export default function LivePlayer({
setFullResolution, setFullResolution,
onError, onError,
}: LivePlayerProps) { }: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null);
// camera activity // camera activity
const { activeMotion, activeTracking, objects, offline } = const { activeMotion, activeTracking, objects, offline } =
@ -73,20 +74,12 @@ export default function LivePlayer({
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
useEffect(() => { useEffect(() => {
if (!autoLive) { if (!autoLive || !liveReady) {
return;
}
if (!liveReady) {
if (cameraActive && liveMode == "jsmpeg") {
setLiveReady(true);
}
return; return;
} }
if (!cameraActive) { if (!cameraActive) {
setTimeout(() => setLiveReady(false), 500); setLiveReady(false);
} }
// live mode won't change // live mode won't change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -181,7 +174,8 @@ export default function LivePlayer({
camera={cameraConfig.live.stream_name} camera={cameraConfig.live.stream_name}
width={cameraConfig.detect.width} width={cameraConfig.detect.width}
height={cameraConfig.detect.height} height={cameraConfig.detect.height}
containerRef={containerRef} playbackEnabled={cameraActive || !showStillWithoutActivity}
containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
/> />
); );
@ -194,7 +188,7 @@ export default function LivePlayer({
return ( return (
<div <div
ref={cameraRef} ref={cameraRef ?? internalContainerRef}
data-camera={cameraConfig.name} data-camera={cameraConfig.name}
className={cn( className={cn(
"relative flex w-full cursor-pointer justify-center outline", "relative flex w-full cursor-pointer justify-center outline",

View File

@ -31,7 +31,7 @@ function MSEPlayer({
setFullResolution, setFullResolution,
onError, onError,
}: MSEPlayerProps) { }: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 30000; const RECONNECT_TIMEOUT: number = 10000;
const CODECS: string[] = [ const CODECS: string[] = [
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
@ -45,10 +45,12 @@ function MSEPlayer({
]; ];
const visibilityCheck: boolean = !pip; const visibilityCheck: boolean = !pip;
const [safariPlaying, setSafariPlaying] = useState(false);
const [wsState, setWsState] = useState<number>(WebSocket.CLOSED); const [wsState, setWsState] = useState<number>(WebSocket.CLOSED);
const [connectTS, setConnectTS] = useState<number>(0); const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@ -117,12 +119,21 @@ function MSEPlayer({
}, [wsURL]); }, [wsURL]);
const onDisconnect = useCallback(() => { const onDisconnect = useCallback(() => {
if (wsRef.current && wsState == WebSocket.OPEN) { if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
if ((isSafari || isIOS) && safariPlaying) {
setSafariPlaying(false);
}
if (wsRef.current && wsState != WebSocket.CLOSED) {
setWsState(WebSocket.CLOSED); setWsState(WebSocket.CLOSED);
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
} }
}, [wsState]); }, [wsState, bufferTimeout, safariPlaying]);
const onOpen = () => { const onOpen = () => {
setWsState(WebSocket.OPEN); setWsState(WebSocket.OPEN);
@ -162,6 +173,26 @@ function MSEPlayer({
reconnect(); reconnect();
}; };
const sendWithTimeout = (value: object, timeout: number) => {
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("Timeout waiting for response"));
}, timeout);
send(value);
// Override the onmessageRef handler for mse type to resolve the promise on response
const originalHandler = onmessageRef.current["mse"];
onmessageRef.current["mse"] = (msg) => {
if (msg.type === "mse") {
clearTimeout(timeoutId);
if (originalHandler) originalHandler(msg);
resolve();
}
};
});
};
const onMse = () => { const onMse = () => {
if ("ManagedMediaSource" in window) { if ("ManagedMediaSource" in window) {
const MediaSource = window.ManagedMediaSource; const MediaSource = window.ManagedMediaSource;
@ -169,10 +200,22 @@ function MSEPlayer({
msRef.current?.addEventListener( msRef.current?.addEventListener(
"sourceopen", "sourceopen",
() => { () => {
send({ sendWithTimeout(
type: "mse", {
// @ts-expect-error for typing type: "mse",
value: codecs(MediaSource.isTypeSupported), // @ts-expect-error for typing
value: codecs(MediaSource.isTypeSupported),
},
3000,
).catch(() => {
if (wsRef.current) {
onDisconnect();
}
if (isIOS || isSafari) {
onError?.("mse-decode");
} else {
onError?.("startup");
}
}); });
}, },
{ once: true }, { once: true },
@ -187,9 +230,21 @@ function MSEPlayer({
"sourceopen", "sourceopen",
() => { () => {
URL.revokeObjectURL(videoRef.current?.src || ""); URL.revokeObjectURL(videoRef.current?.src || "");
send({ sendWithTimeout(
type: "mse", {
value: codecs(MediaSource.isTypeSupported), type: "mse",
value: codecs(MediaSource.isTypeSupported),
},
3000,
).catch(() => {
if (wsRef.current) {
onDisconnect();
}
if (isIOS || isSafari) {
onError?.("mse-decode");
} else {
onError?.("startup");
}
}); });
}, },
{ once: true }, { once: true },
@ -260,10 +315,6 @@ function MSEPlayer({
return () => { return () => {
onDisconnect(); onDisconnect();
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
}; };
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -305,6 +356,23 @@ function MSEPlayer({
videoRef.current.requestPictureInPicture(); videoRef.current.requestPictureInPicture();
}, [pip, videoRef]); }, [pip, videoRef]);
// ensure we disconnect for slower connections
useEffect(() => {
if (wsState === WebSocket.OPEN && !playbackEnabled) {
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setTimeout(() => {
if (!playbackEnabled) onDisconnect();
}, 10000);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
return ( return (
<video <video
ref={videoRef} ref={videoRef}
@ -317,7 +385,8 @@ function MSEPlayer({
}} }}
muted={!audioEnabled} muted={!audioEnabled}
onProgress={() => { onProgress={() => {
if (isSafari || isIOS) { if ((isSafari || isIOS) && !safariPlaying) {
setSafariPlaying(true);
onPlaying?.(); onPlaying?.();
} }
if (onError != undefined) { if (onError != undefined) {
@ -334,8 +403,10 @@ function MSEPlayer({
setTimeout(() => { setTimeout(() => {
if ( if (
document.visibilityState === "visible" && document.visibilityState === "visible" &&
wsRef.current != null wsRef.current != null &&
videoRef.current
) { ) {
onDisconnect();
onError("stalled"); onError("stalled");
} }
}, 3000), }, 3000),
@ -347,6 +418,9 @@ function MSEPlayer({
// @ts-expect-error code does exist // @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) { ) {
if (wsRef.current) {
onDisconnect();
}
onError?.("startup"); onError?.("startup");
} }
@ -355,15 +429,22 @@ function MSEPlayer({
e.target.error.code == MediaError.MEDIA_ERR_DECODE && e.target.error.code == MediaError.MEDIA_ERR_DECODE &&
(isSafari || isIOS) (isSafari || isIOS)
) { ) {
if (wsRef.current) {
onDisconnect();
}
onError?.("mse-decode"); onError?.("mse-decode");
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
} }
setErrorCount((prevCount) => prevCount + 1);
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); onDisconnect();
wsRef.current = null; if (errorCount >= 3) {
reconnect(5000); // too many mse errors, try jsmpeg
onError?.("startup");
} else {
reconnect(5000);
}
} }
}} }}
/> />

View File

@ -130,6 +130,7 @@ const generateRandomEvent = (): ReviewSegment => {
function UIPlayground() { function UIPlayground() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const reviewTimelineRef = useRef<HTMLDivElement>(null); const reviewTimelineRef = useRef<HTMLDivElement>(null);
const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]); const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]); const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
@ -344,11 +345,12 @@ function UIPlayground() {
Zoom In Zoom In
</Button> </Button>
</p> </p>
<div className=""> <div ref={containerRef} className="">
{birdseyeConfig && ( {birdseyeConfig && (
<BirdseyeLivePlayer <BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig} birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"} liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
containerRef={containerRef}
/> />
)} )}
</div> </div>

View File

@ -20,13 +20,13 @@ import {
} from "react-grid-layout"; } from "react-grid-layout";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
import { LivePlayerMode } from "@/types/live"; import { LivePlayerError, LivePlayerMode } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import useSWR from "swr"; import useSWR from "swr";
import { isDesktop, isMobile, isSafari } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
@ -73,6 +73,31 @@ export default function DraggableGridLayout({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
// preferred live modes per camera
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
useEffect(() => {
if (!cameras) return;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config]);
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
@ -429,10 +454,21 @@ export default function DraggableGridLayout({
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
onClick={() => { onClick={() => {
!isEditMode && onSelectCamera(camera.name); !isEditMode && onSelectCamera(camera.name);
}} }}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
> >
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</LivePlayerGridItem> </LivePlayerGridItem>
@ -590,6 +626,7 @@ type LivePlayerGridItemProps = {
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
preferredLiveMode: LivePlayerMode; preferredLiveMode: LivePlayerMode;
onClick: () => void; onClick: () => void;
onError: (e: LivePlayerError) => void;
}; };
const LivePlayerGridItem = React.forwardRef< const LivePlayerGridItem = React.forwardRef<
@ -609,6 +646,7 @@ const LivePlayerGridItem = React.forwardRef<
cameraConfig, cameraConfig,
preferredLiveMode, preferredLiveMode,
onClick, onClick,
onError,
...props ...props
}, },
ref, ref,
@ -629,6 +667,7 @@ const LivePlayerGridItem = React.forwardRef<
cameraConfig={cameraConfig} cameraConfig={cameraConfig}
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
onClick={onClick} onClick={onClick}
onError={onError}
containerRef={ref as React.RefObject<HTMLDivElement>} containerRef={ref as React.RefObject<HTMLDivElement>}
/> />
{children} {children}

View File

@ -16,7 +16,6 @@ import {
isDesktop, isDesktop,
isMobile, isMobile,
isMobileOnly, isMobileOnly,
isSafari,
isTablet, isTablet,
} from "react-device-detect"; } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
@ -24,6 +23,7 @@ import DraggableGridLayout from "./DraggableGridLayout";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard } from "react-icons/lu"; import { LuLayoutDashboard } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LivePlayerMode } from "@/types/live";
type LiveDashboardViewProps = { type LiveDashboardViewProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -99,6 +99,29 @@ export default function LiveDashboardView({
// camera live views // camera live views
const [autoLiveView] = usePersistence("autoLiveView", true); const [autoLiveView] = usePersistence("autoLiveView", true);
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
useEffect(() => {
if (!cameras) return;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config]);
const [windowVisible, setWindowVisible] = useState(true); const [windowVisible, setWindowVisible] = useState(true);
const visibilityListener = useCallback(() => { const visibilityListener = useCallback(() => {
setWindowVisible(document.visibilityState == "visible"); setWindowVisible(document.visibilityState == "visible");
@ -289,9 +312,20 @@ export default function LiveDashboardView({
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={isSafari ? "webrtc" : "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
autoLive={autoLiveView} autoLive={autoLiveView}
onClick={() => onSelectCamera(camera.name)} onClick={() => onSelectCamera(camera.name)}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
/> />
); );
})} })}