From f9ed082e35380afed9815580d6444fcb5616f40e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 13 Mar 2024 14:24:24 -0600 Subject: [PATCH] Use react lazy to break js files into smaller chunks & remove videojs in favor of hls.js (#10431) * Use dynamic imports to reduce initial load times Remove videojs * Convert to using hls.js instead of videojs * Improve mobile controls experience * Cleanup * Ensure playback rate stays teh same when source changes * Use webp for latest camera image * Switch to hls.js on error * Don't rerun error if hls already tried * Fix error checking * also check for media decode error to fallback to HLS --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- frigate/api/media.py | 17 +- web/package-lock.json | 279 +-------- web/package.json | 3 +- web/src/App.tsx | 53 +- .../components/player/DynamicVideoPlayer.tsx | 584 ------------------ web/src/components/player/HlsVideoPlayer.tsx | 321 ++++++++++ web/src/components/player/VideoPlayer.tsx | 95 --- .../player/dynamic/DynamicVideoController.ts | 146 +++++ .../player/dynamic/DynamicVideoPlayer.tsx | 178 ++++++ web/src/pages/UIPlayground.tsx | 11 + web/src/types/playback.ts | 1 - web/src/views/events/RecordingView.tsx | 78 ++- 12 files changed, 737 insertions(+), 1029 deletions(-) delete mode 100644 web/src/components/player/DynamicVideoPlayer.tsx create mode 100644 web/src/components/player/HlsVideoPlayer.tsx delete mode 100644 web/src/components/player/VideoPlayer.tsx create mode 100644 web/src/components/player/dynamic/DynamicVideoController.ts create mode 100644 web/src/components/player/dynamic/DynamicVideoPlayer.tsx diff --git a/frigate/api/media.py b/frigate/api/media.py index 2c8f37dc8..87c2aa1a7 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -105,6 +105,7 @@ def camera_ptz_info(camera_name): @MediaBp.route("//latest.jpg") +@MediaBp.route("//latest.webp") def latest_frame(camera_name): draw_options = { "bounding_boxes": request.args.get("bbox", type=int), @@ -156,11 +157,11 @@ def latest_frame(camera_name): frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, jpg = cv2.imencode( - ".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality] + ret, img = cv2.imencode( + ".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality] ) - response = make_response(jpg.tobytes()) - response.headers["Content-Type"] = "image/jpeg" + response = make_response(img.tobytes()) + response.headers["Content-Type"] = "image/webp" response.headers["Cache-Control"] = "no-store" return response elif camera_name == "birdseye" and current_app.frigate_config.birdseye.restream: @@ -174,11 +175,11 @@ def latest_frame(camera_name): frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) - ret, jpg = cv2.imencode( - ".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality] + ret, img = cv2.imencode( + ".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality] ) - response = make_response(jpg.tobytes()) - response.headers["Content-Type"] = "image/jpeg" + response = make_response(img.tobytes()) + response.headers["Content-Type"] = "image/webp" response.headers["Cache-Control"] = "no-store" return response else: diff --git a/web/package-lock.json b/web/package-lock.json index dc218efd7..35a75663a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -34,6 +34,7 @@ "clsx": "^2.1.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^2.30.0", + "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.3", "lucide-react": "^0.294.0", @@ -60,8 +61,6 @@ "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.8.0", - "video.js": "^8.6.1", - "videojs-playlist": "^5.1.0", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.22.4" }, @@ -2940,52 +2939,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "node_modules/@videojs/http-streaming": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.10.0.tgz", - "integrity": "sha512-Lf1rmhTalV4Gw0bJqHmH4lfk/FlepUDs9smuMtorblAYnqDlE2tbUOb7sBXVYoXGdbWbdTW8jH2cnS+6HWYJ4Q==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "4.0.0", - "aes-decrypter": "4.0.1", - "global": "^4.4.0", - "m3u8-parser": "^7.1.0", - "mpd-parser": "^1.3.0", - "mux.js": "7.0.2", - "video.js": "^7 || ^8" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - }, - "peerDependencies": { - "video.js": "^7 || ^8" - } - }, - "node_modules/@videojs/vhs-utils": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.0.0.tgz", - "integrity": "sha512-xJp7Yd4jMLwje2vHCUmi8MOUU76nxiwII3z4Eg3Ucb+6rrkFVGosrXlMgGnaLjq724j3wzNElRZ71D/CKrTtxg==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0", - "url-toolkit": "^2.2.1" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, - "node_modules/@videojs/xhr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz", - "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==", - "dependencies": { - "@babel/runtime": "^7.5.5", - "global": "~4.4.0", - "is-function": "^1.0.1" - } - }, "node_modules/@vitejs/plugin-react-swc": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.6.0.tgz", @@ -3121,14 +3074,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", @@ -3164,31 +3109,6 @@ "node": ">=0.4.0" } }, - "node_modules/aes-decrypter": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.1.tgz", - "integrity": "sha512-H1nh/P9VZXUf17AA5NQfJML88CFjVBDuGkp5zDHa7oEhYN9TTpNLJknRY1ie0iSKWlDf6JRnJKaZVDSQdPy6Cg==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.5", - "global": "^4.4.0", - "pkcs7": "^1.0.4" - } - }, - "node_modules/aes-decrypter/node_modules/@videojs/vhs-utils": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", - "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0", - "url-toolkit": "^2.2.1" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -4110,11 +4030,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dom-walk": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" - }, "node_modules/electron-to-chromium": { "version": "1.4.692", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz", @@ -4877,15 +4792,6 @@ "node": ">=10.13.0" } }, - "node_modules/global": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", - "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "dependencies": { - "min-document": "^2.19.0", - "process": "^0.11.10" - } - }, "node_modules/globals": { "version": "13.23.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", @@ -4973,6 +4879,11 @@ "integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==", "dev": true }, + "node_modules/hls.js": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", + "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -5083,11 +4994,6 @@ "node": ">=8" } }, - "node_modules/individual": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz", - "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5164,11 +5070,6 @@ "node": ">=8" } }, - "node_modules/is-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", - "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5444,11 +5345,6 @@ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, - "node_modules/keycode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", - "integrity": "sha512-ps3I9jAdNtRpJrbBvQjpzyFbss/skHqzS+eu4RxKLaEAtFqkjZaB6TZMSivPbLxf4K7VI4SjR0P5mRCX5+Q25A==" - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5567,30 +5463,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, - "node_modules/m3u8-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.1.0.tgz", - "integrity": "sha512-7N+pk79EH4oLKPEYdgRXgAsKDyA/VCo0qCHlUwacttQA0WqsjZQYmNfywMvjlY9MpEBVZEt0jKFd73Kv15EBYQ==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^3.0.5", - "global": "^4.4.0" - } - }, - "node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", - "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "global": "^4.4.0", - "url-toolkit": "^2.2.1" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/magic-string": { "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", @@ -5692,14 +5564,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dependencies": { - "dom-walk": "^0.1.0" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5835,20 +5699,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/mpd-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.0.tgz", - "integrity": "sha512-WgeIwxAqkmb9uTn4ClicXpEQYCEduDqRKfmUdp4X8vmghKfBNXZLYpREn9eqrDx/Tf5LhzRcJLSpi4ohfV742Q==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/vhs-utils": "^4.0.0", - "@xmldom/xmldom": "^0.8.3", - "global": "^4.4.0" - }, - "bin": { - "mpd-to-m3u8-json": "bin/parse.js" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5920,22 +5770,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/mux.js": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.2.tgz", - "integrity": "sha512-CM6+QuyDbc0qW1OfEjkd2+jVKzTXF+z5VOKH0eZxtZtnrG/ilkW/U7l7IXGtBNLASF9sKZMcK1u669cq50Qq0A==", - "dependencies": { - "@babel/runtime": "^7.11.2", - "global": "^4.4.0" - }, - "bin": { - "muxjs-transmux": "bin/transmux.js" - }, - "engines": { - "node": ">=8", - "npm": ">=5" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -6343,17 +6177,6 @@ "node": ">= 6" } }, - "node_modules/pkcs7": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", - "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", - "dependencies": { - "@babel/runtime": "^7.5.5" - }, - "bin": { - "pkcs7": "bin/cli.js" - } - }, "node_modules/pkg-types": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", @@ -6565,14 +6388,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7195,22 +7010,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rust-result": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz", - "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==", - "dependencies": { - "individual": "^2.0.0" - } - }, - "node_modules/safe-json-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz", - "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==", - "dependencies": { - "rust-result": "^1.0.0" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8042,11 +7841,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-toolkit": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", - "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" - }, "node_modules/use-callback-ref": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", @@ -8146,67 +7940,6 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/video.js": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.10.0.tgz", - "integrity": "sha512-7UeG/flj/pp8tNGW8WKPP1VJb3x2FgLoqUWzpZqkoq5YIyf6MNzmIrKtxprl438T5RVkcj+OzV8IX4jYSAn4Sw==", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "3.10.0", - "@videojs/vhs-utils": "^4.0.0", - "@videojs/xhr": "2.6.0", - "aes-decrypter": "^4.0.1", - "global": "4.4.0", - "keycode": "2.2.0", - "m3u8-parser": "^7.1.0", - "mpd-parser": "^1.2.2", - "mux.js": "^7.0.1", - "safe-json-parse": "4.0.0", - "videojs-contrib-quality-levels": "4.0.0", - "videojs-font": "4.1.0", - "videojs-vtt.js": "0.15.5" - } - }, - "node_modules/videojs-contrib-quality-levels": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.0.0.tgz", - "integrity": "sha512-u5rmd8BjLwANp7XwuQ0Q/me34bMe6zg9PQdHfTS7aXgiVRbNTb4djcmfG7aeSrkpZjg+XCLezFNenlJaCjBHKw==", - "dependencies": { - "global": "^4.4.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6" - }, - "peerDependencies": { - "video.js": "^8" - } - }, - "node_modules/videojs-font": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.1.0.tgz", - "integrity": "sha512-X1LuPfLZPisPLrANIAKCknZbZu5obVM/ylfd1CN+SsCmPZQ3UMDPcvLTpPBJxcBuTpHQq2MO1QCFt7p8spnZ/w==" - }, - "node_modules/videojs-playlist": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-5.1.0.tgz", - "integrity": "sha512-p5ohld6Kom9meYCcEVYj0JVS2MBL2XxMiU+IDB/xKpDOspFAHrERHrZEBoiJZc/mCfHixZBNgj1vWRgYsVVsrw==", - "dependencies": { - "global": "^4.3.2", - "video.js": "^6 || ^7 || ^8" - }, - "engines": { - "node": ">=4.4.0" - } - }, - "node_modules/videojs-vtt.js": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", - "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", - "dependencies": { - "global": "^4.3.1" - } - }, "node_modules/vite": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz", diff --git a/web/package.json b/web/package.json index 6c8a698c6..a9ea40891 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,7 @@ "clsx": "^2.1.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^2.30.0", + "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.3", "lucide-react": "^0.294.0", @@ -65,8 +66,6 @@ "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.8.0", - "video.js": "^8.6.1", - "videojs-playlist": "^5.1.0", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.22.4" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 21e579fc2..dc82bbc07 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,20 +2,23 @@ import Providers from "@/context/providers"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import Wrapper from "@/components/Wrapper"; import Sidebar from "@/components/navigation/Sidebar"; -import Live from "@/pages/Live"; -import Export from "@/pages/Export"; -import Storage from "@/pages/Storage"; -import System from "@/pages/System"; -import ConfigEditor from "@/pages/ConfigEditor"; -import Logs from "@/pages/Logs"; -import NoMatch from "@/pages/NoMatch"; -import Settings from "@/pages/Settings"; -import UIPlayground from "./pages/UIPlayground"; -import Events from "./pages/Events"; + import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; import Bottombar from "./components/navigation/Bottombar"; -import SubmitPlus from "./pages/SubmitPlus"; +import { Suspense, lazy } from "react"; + +const Live = lazy(() => import("@/pages/Live")); +const Events = lazy(() => import("@/pages/Events")); +const Export = lazy(() => import("@/pages/Export")); +const Storage = lazy(() => import("@/pages/Storage")); +const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); +const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); +const System = lazy(() => import("@/pages/System")); +const Settings = lazy(() => import("@/pages/Settings")); +const UIPlayground = lazy(() => import("@/pages/UIPlayground")); +const Logs = lazy(() => import("@/pages/Logs")); +const NoMatch = lazy(() => import("@/pages/NoMatch")); function App() { return ( @@ -30,19 +33,21 @@ function App() { id="pageRoot" className={`absolute top-2 right-0 overflow-hidden ${isMobile ? "left-0 bottom-16" : "left-16 bottom-8"}`} > - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx deleted file mode 100644 index 045fd7d63..000000000 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ /dev/null @@ -1,584 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import VideoPlayer from "./VideoPlayer"; -import Player from "video.js/dist/types/player"; -import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; -import { useApiHost } from "@/api"; -import useSWR from "swr"; -import { FrigateConfig } from "@/types/frigateConfig"; -import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { Recording } from "@/types/record"; -import { Preview } from "@/types/preview"; -import { DynamicPlayback } from "@/types/playback"; -import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; -import { isDesktop } from "react-device-detect"; -import { LuPause, LuPlay } from "react-icons/lu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { MdForward10, MdReplay10 } from "react-icons/md"; - -type PlayerMode = "playback" | "scrubbing"; - -/** - * Dynamically switches between video playback and scrubbing preview player. - */ -type DynamicVideoPlayerProps = { - className?: string; - camera: string; - timeRange: { start: number; end: number }; - cameraPreviews: Preview[]; - startTime?: number; - onControllerReady: (controller: DynamicVideoController) => void; -}; -export default function DynamicVideoPlayer({ - className, - camera, - timeRange, - cameraPreviews, - startTime, - onControllerReady, -}: DynamicVideoPlayerProps) { - const apiHost = useApiHost(); - const { data: config } = useSWR("config"); - - // playback behavior - const wideVideo = useMemo(() => { - if (!config) { - return false; - } - - return ( - config.cameras[camera].detect.width / - config.cameras[camera].detect.height > - 1.7 - ); - }, [camera, config]); - - // controlling playback - - const [playerRef, setPlayerRef] = useState(null); - const [previewController, setPreviewController] = - useState(null); - const [controls, setControls] = useState(false); - const [controlsOpen, setControlsOpen] = useState(false); - const [isScrubbing, setIsScrubbing] = useState(false); - const [focusedItem, setFocusedItem] = useState( - undefined, - ); - const controller = useMemo(() => { - if (!config || !playerRef || !previewController) { - return undefined; - } - - return new DynamicVideoController( - camera, - playerRef, - previewController, - (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, - "playback", - setIsScrubbing, - setFocusedItem, - ); - // we only want to fire once when players are ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [camera, config, playerRef, 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]); - - // keyboard control - - const onKeyboardShortcut = useCallback( - (key: string, down: boolean, repeat: boolean) => { - if (!playerRef) { - return; - } - - switch (key) { - case "ArrowLeft": - if (down) { - const currentTime = playerRef.currentTime(); - - if (currentTime) { - playerRef.currentTime(Math.max(0, currentTime - 5)); - } - } - break; - case "ArrowRight": - if (down) { - const currentTime = playerRef.currentTime(); - - if (currentTime) { - playerRef.currentTime(currentTime + 5); - } - } - break; - case "m": - if (down && !repeat && playerRef) { - playerRef.muted(!playerRef.muted()); - } - break; - case " ": - if (down && playerRef) { - if (playerRef.paused()) { - playerRef.play(); - } else { - playerRef.pause(); - } - } - break; - } - }, - // only update when preview only changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [playerRef], - ); - useKeyboardListener( - ["ArrowLeft", "ArrowRight", "m", " "], - onKeyboardShortcut, - ); - - // mobile tap controls - - useEffect(() => { - if (isDesktop || !playerRef) { - return; - } - - const callback = () => setControls(!controls); - playerRef.on("touchstart", callback); - - return () => playerRef.off("touchstart", callback); - }, [controls, playerRef]); - - // initial state - - const initialPlaybackSource = useMemo(() => { - return { - src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, - type: "application/vnd.apple.mpegurl", - }; - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // start at correct time - - useEffect(() => { - const player = playerRef; - - if (!player) { - return; - } - - if (!startTime) { - return; - } - - if (player.isReady_) { - controller?.seekToTimestamp(startTime, true); - return; - } - - const callback = () => { - controller?.seekToTimestamp(startTime, true); - }; - player.on("loadeddata", callback); - return () => { - player.off("loadeddata", callback); - }; - // we only want to calculate this once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startTime, controller]); - - // state of playback player - - const recordingParams = useMemo(() => { - return { - before: timeRange.end, - after: timeRange.start, - }; - }, [timeRange]); - const { data: recordings } = useSWR( - [`${camera}/recordings`, recordingParams], - { revalidateOnFocus: false }, - ); - - useEffect(() => { - if (!controller || !recordings) { - return; - } - - const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`; - - controller.newPlayback({ - recordings: recordings ?? [], - playbackUri, - }); - - // we only want this to change when recordings update - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controller, recordings]); - - return ( -
{ - setControls(true); - } - : undefined - } - onMouseOut={ - isDesktop - ? () => { - setControls(controlsOpen); - } - : undefined - } - > -
- { - setPlayerRef(player); - }} - onDispose={() => { - setPlayerRef(null); - }} - > - {config && focusedItem && ( - - )} - - -
- { - setPreviewController(previewController); - }} - /> -
- ); -} - -export class DynamicVideoController { - // main state - public camera = ""; - private playerController: Player; - private previewController: PreviewController; - private setScrubbing: (isScrubbing: boolean) => void; - private setFocusedItem: (timeline: Timeline) => void; - private playerMode: PlayerMode = "playback"; - - // playback - private recordings: Recording[] = []; - private annotationOffset: number; - private timeToStart: number | undefined = undefined; - - // listeners - private playerProgressListener: (() => void) | null = null; - private playerEndedListener: (() => void) | null = null; - - constructor( - camera: string, - playerController: Player, - previewController: PreviewController, - annotationOffset: number, - defaultMode: PlayerMode, - setScrubbing: (isScrubbing: boolean) => void, - setFocusedItem: (timeline: Timeline) => void, - ) { - this.camera = camera; - this.playerController = playerController; - this.previewController = previewController; - this.annotationOffset = annotationOffset; - this.playerMode = defaultMode; - this.setScrubbing = setScrubbing; - this.setFocusedItem = setFocusedItem; - } - - newPlayback(newPlayback: DynamicPlayback) { - this.recordings = newPlayback.recordings; - this.playerController.src({ - src: newPlayback.playbackUri, - type: "application/vnd.apple.mpegurl", - }); - - if (this.timeToStart) { - this.seekToTimestamp(this.timeToStart); - this.timeToStart = undefined; - } - } - - pause() { - this.playerController.pause(); - } - - seekToTimestamp(time: number, play: boolean = false) { - if (this.playerMode != "playback") { - this.playerMode = "playback"; - this.setScrubbing(false); - } - - if ( - this.recordings.length == 0 || - time < this.recordings[0].start_time || - time > this.recordings[this.recordings.length - 1].end_time - ) { - this.timeToStart = time; - return; - } - - let seekSeconds = 0; - (this.recordings || []).every((segment) => { - // if the next segment is past the desired time, stop calculating - if (segment.start_time > time) { - return false; - } - - if (segment.end_time < time) { - seekSeconds += segment.end_time - segment.start_time; - return true; - } - - seekSeconds += - segment.end_time - segment.start_time - (segment.end_time - time); - return true; - }); - - if (seekSeconds != 0) { - this.playerController.currentTime(seekSeconds); - - if (play) { - this.playerController.play(); - } else { - this.playerController.pause(); - } - } - } - - seekToTimelineItem(timeline: Timeline) { - this.playerController.pause(); - this.seekToTimestamp(timeline.timestamp + this.annotationOffset); - this.setFocusedItem(timeline); - } - - getProgress(playerTime: number): number { - // take a player time in seconds and convert to timestamp in timeline - let timestamp = 0; - let totalTime = 0; - (this.recordings || []).every((segment) => { - if (totalTime + segment.duration > playerTime) { - // segment is here - timestamp = segment.start_time + (playerTime - totalTime); - return false; - } else { - totalTime += segment.duration; - return true; - } - }); - - return timestamp; - } - - onPlayerTimeUpdate(listener: ((timestamp: number) => void) | null) { - if (this.playerProgressListener) { - this.playerController.off("timeupdate", this.playerProgressListener); - this.playerProgressListener = null; - } - - if (listener) { - this.playerProgressListener = () => { - const progress = this.playerController.currentTime() || 0; - - if (progress == 0) { - return; - } - - listener(this.getProgress(progress)); - }; - this.playerController.on("timeupdate", this.playerProgressListener); - } - } - - onClipChangedEvent(listener: ((dir: "forward") => void) | null) { - if (this.playerEndedListener) { - this.playerController.off("ended", this.playerEndedListener); - this.playerEndedListener = null; - } - - if (listener) { - this.playerEndedListener = () => listener("forward"); - this.playerController.on("ended", this.playerEndedListener); - } - } - - scrubToTimestamp(time: number, saveIfNotReady: boolean = false) { - const scrubResult = this.previewController.scrubToTimestamp(time); - - if (!scrubResult && saveIfNotReady) { - this.previewController.setNewPreviewStartTime(time); - } - - if (scrubResult && this.playerMode != "scrubbing") { - this.playerMode = "scrubbing"; - this.playerController.pause(); - this.setScrubbing(true); - } - } - - hasRecordingAtTime(time: number): boolean { - if (!this.recordings || this.recordings.length == 0) { - return false; - } - - return ( - this.recordings.find( - (segment) => segment.start_time <= time && segment.end_time >= time, - ) != undefined - ); - } -} - -type PlayerControlsProps = { - player: Player | null; - show: boolean; - controlsOpen: boolean; - setControlsOpen: (open: boolean) => void; -}; -function PlayerControls({ - player, - show, - controlsOpen, - setControlsOpen, -}: PlayerControlsProps) { - const playbackRates = useMemo(() => { - if (!player) { - return []; - } - - // @ts-expect-error player getter requires undefined - return player.playbackRates(undefined); - }, [player]); - - const onReplay = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - const currentTime = player?.currentTime(); - - if (!player || !currentTime) { - return; - } - - player.currentTime(Math.max(0, currentTime - 10)); - }, - [player], - ); - - const onSkip = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - const currentTime = player?.currentTime(); - - if (!player || !currentTime) { - return; - } - - player.currentTime(currentTime + 10); - }, - [player], - ); - - const onTogglePlay = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - - if (!player) { - return; - } - - if (player.paused()) { - player.play(); - } else { - player.pause(); - } - }, - [player], - ); - - if (!player || !show) { - return; - } - - return ( -
- -
- {player.paused() ? ( - - ) : ( - - )} -
- - { - setControlsOpen(open); - }} - > - {`${player.playbackRate()}x`} - - player.playbackRate(parseInt(rate))} - > - {playbackRates.map((rate) => ( - - {rate}x - - ))} - - - -
- ); -} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx new file mode 100644 index 000000000..cbf5e2b5d --- /dev/null +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -0,0 +1,321 @@ +import { + MutableRefObject, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import Hls from "hls.js"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import { LuPause, LuPlay } from "react-icons/lu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { MdForward10, MdReplay10 } from "react-icons/md"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; + +const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; +const unsupportedErrorCodes = [ + MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, + MediaError.MEDIA_ERR_DECODE, +]; + +type HlsVideoPlayerProps = { + className: string; + children?: ReactNode; + videoRef: MutableRefObject; + currentSource: string; + onClipEnded?: () => void; + onPlayerLoaded?: () => void; + onTimeUpdate?: (time: number) => void; +}; +export default function HlsVideoPlayer({ + className, + children, + videoRef, + currentSource, + onClipEnded, + onPlayerLoaded, + onTimeUpdate, +}: HlsVideoPlayerProps) { + // playback + + const hlsRef = useRef(); + const [useHlsCompat, setUseHlsCompat] = useState(false); + + useEffect(() => { + if (!videoRef.current) { + return; + } + + if (videoRef.current.canPlayType(HLS_MIME_TYPE)) { + return; + } else if (Hls.isSupported()) { + setUseHlsCompat(true); + } + }, [videoRef]); + + useEffect(() => { + if (!videoRef.current) { + return; + } + + const currentPlaybackRate = videoRef.current.playbackRate; + + if (!useHlsCompat) { + videoRef.current.src = currentSource; + videoRef.current.load(); + return; + } + + if (!hlsRef.current) { + hlsRef.current = new Hls(); + hlsRef.current.attachMedia(videoRef.current); + } + + hlsRef.current.loadSource(currentSource); + videoRef.current.playbackRate = currentPlaybackRate; + }, [videoRef, hlsRef, useHlsCompat, currentSource]); + + // controls + + const [isPlaying, setIsPlaying] = useState(true); + const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState(); + const [controls, setControls] = useState(isMobile); + const [controlsOpen, setControlsOpen] = useState(false); + + const onKeyboardShortcut = useCallback( + (key: string, down: boolean, repeat: boolean) => { + if (!videoRef.current) { + return; + } + + switch (key) { + case "ArrowLeft": + if (down) { + const currentTime = videoRef.current.currentTime; + + if (currentTime) { + videoRef.current.currentTime = Math.max(0, currentTime - 5); + } + } + break; + case "ArrowRight": + if (down) { + const currentTime = videoRef.current.currentTime; + + if (currentTime) { + videoRef.current.currentTime = currentTime + 5; + } + } + break; + case "m": + if (down && !repeat && videoRef.current) { + videoRef.current.muted = !videoRef.current.muted; + } + break; + case " ": + if (down && videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + } + break; + } + }, + // only update when preview only changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [videoRef.current], + ); + useKeyboardListener( + ["ArrowLeft", "ArrowRight", "m", " "], + onKeyboardShortcut, + ); + + return ( +
{ + setControls(true); + } + : undefined + } + onMouseOut={ + isDesktop + ? () => { + setControls(controlsOpen); + } + : undefined + } + onClick={isDesktop ? undefined : () => setControls(!controls)} + > +
+ ); +} + +type VideoControlsProps = { + video: HTMLVideoElement | null; + isPlaying: boolean; + show: boolean; + controlsOpen: boolean; + setControlsOpen: (open: boolean) => void; +}; +function VideoControls({ + video, + isPlaying, + show, + controlsOpen, + setControlsOpen, +}: VideoControlsProps) { + const playbackRates = useMemo(() => { + if (isSafari) { + return [0.5, 1, 2]; + } else { + return [0.5, 1, 2, 4, 8, 16]; + } + }, []); + + const onReplay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = video?.currentTime; + + if (!video || !currentTime) { + return; + } + + video.currentTime = Math.max(0, currentTime - 10); + }, + [video], + ); + + const onSkip = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = video?.currentTime; + + if (!video || !currentTime) { + return; + } + + video.currentTime = currentTime + 10; + }, + [video], + ); + + const onTogglePlay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!video) { + return; + } + + if (isPlaying) { + video.pause(); + } else { + video.play(); + } + }, + [isPlaying, video], + ); + + if (!video || !show) { + return; + } + + return ( +
+ +
+ {isPlaying ? ( + + ) : ( + + )} +
+ + { + setControlsOpen(open); + }} + > + {`${video.playbackRate}x`} + + (video.playbackRate = parseInt(rate))} + > + {playbackRates.map((rate) => ( + + {rate}x + + ))} + + + +
+ ); +} diff --git a/web/src/components/player/VideoPlayer.tsx b/web/src/components/player/VideoPlayer.tsx deleted file mode 100644 index 881238bec..000000000 --- a/web/src/components/player/VideoPlayer.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useRef, ReactElement } from "react"; -import videojs from "video.js"; -import "videojs-playlist"; -import "video.js/dist/video-js.css"; -import Player from "video.js/dist/types/player"; - -type VideoPlayerProps = { - children?: ReactElement | ReactElement[]; - options?: { - [key: string]: unknown; - }; - seekOptions?: { - forward?: number; - backward?: number; - }; - remotePlayback?: boolean; - onReady?: (player: Player) => void; - onDispose?: () => void; -}; - -export default function VideoPlayer({ - children, - options, - seekOptions = { forward: 30, backward: 10 }, - remotePlayback = false, - onReady = () => {}, - onDispose = () => {}, -}: VideoPlayerProps) { - const videoRef = useRef(null); - const playerRef = useRef(null); - - useEffect(() => { - const defaultOptions = { - controls: true, - controlBar: { - skipButtons: seekOptions, - }, - playbackRates: [0.5, 1, 2, 4, 8], - fluid: true, - }; - - if (!videojs.browser.IS_FIREFOX) { - defaultOptions.playbackRates.push(16); - } - - // Make sure Video.js player is only initialized once - if (!playerRef.current) { - // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. - const videoElement = document.createElement( - "video-js", - ) as HTMLVideoElement; - videoElement.controls = true; - videoElement.playsInline = true; - videoElement.disableRemotePlayback = !remotePlayback; - videoElement.classList.add("small-player"); - videoElement.classList.add("video-js"); - videoElement.classList.add("vjs-default-skin"); - videoRef.current?.appendChild(videoElement); - - const player = (playerRef.current = videojs( - videoElement, - { ...defaultOptions, ...options }, - () => { - onReady && onReady(player); - }, - )); - } - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, videoRef]); - - // Dispose the Video.js player when the functional component unmounts - useEffect(() => { - const player = playerRef.current; - - return () => { - if (player && !player.isDisposed()) { - player.dispose(); - playerRef.current = null; - onDispose(); - } - }; - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playerRef]); - - return ( -
-
- {children} -
- ); -} diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts new file mode 100644 index 000000000..45101c27f --- /dev/null +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -0,0 +1,146 @@ +import { Recording } from "@/types/record"; +import { DynamicPlayback } from "@/types/playback"; +import { PreviewController } from "../PreviewPlayer"; + +type PlayerMode = "playback" | "scrubbing"; + +export class DynamicVideoController { + // main state + public camera = ""; + private playerController: HTMLVideoElement; + private previewController: PreviewController; + private setScrubbing: (isScrubbing: boolean) => void; + private setFocusedItem: (timeline: Timeline) => void; + private playerMode: PlayerMode = "playback"; + + // playback + private recordings: Recording[] = []; + private annotationOffset: number; + private timeToStart: number | undefined = undefined; + + constructor( + camera: string, + playerController: HTMLVideoElement, + previewController: PreviewController, + annotationOffset: number, + defaultMode: PlayerMode, + setScrubbing: (isScrubbing: boolean) => void, + setFocusedItem: (timeline: Timeline) => void, + ) { + this.camera = camera; + this.playerController = playerController; + this.previewController = previewController; + this.annotationOffset = annotationOffset; + this.playerMode = defaultMode; + this.setScrubbing = setScrubbing; + this.setFocusedItem = setFocusedItem; + } + + newPlayback(newPlayback: DynamicPlayback) { + this.recordings = newPlayback.recordings; + + if (this.timeToStart) { + this.seekToTimestamp(this.timeToStart); + this.timeToStart = undefined; + } + } + + pause() { + this.playerController.pause(); + } + + seekToTimestamp(time: number, play: boolean = false) { + if (this.playerMode != "playback") { + this.playerMode = "playback"; + this.setScrubbing(false); + } + + if ( + this.recordings.length == 0 || + time < this.recordings[0].start_time || + time > this.recordings[this.recordings.length - 1].end_time + ) { + this.timeToStart = time; + return; + } + + let seekSeconds = 0; + (this.recordings || []).every((segment) => { + // if the next segment is past the desired time, stop calculating + if (segment.start_time > time) { + return false; + } + + if (segment.end_time < time) { + seekSeconds += segment.end_time - segment.start_time; + return true; + } + + seekSeconds += + segment.end_time - segment.start_time - (segment.end_time - time); + return true; + }); + + if (seekSeconds != 0) { + this.playerController.currentTime = seekSeconds; + + if (play) { + this.playerController.play(); + } else { + this.playerController.pause(); + } + } + } + + seekToTimelineItem(timeline: Timeline) { + this.playerController.pause(); + this.seekToTimestamp(timeline.timestamp + this.annotationOffset); + this.setFocusedItem(timeline); + } + + getProgress(playerTime: number): number { + // take a player time in seconds and convert to timestamp in timeline + let timestamp = 0; + let totalTime = 0; + (this.recordings || []).every((segment) => { + if (totalTime + segment.duration > playerTime) { + // segment is here + timestamp = segment.start_time + (playerTime - totalTime); + return false; + } else { + totalTime += segment.duration; + return true; + } + }); + + return timestamp; + } + + scrubToTimestamp(time: number, saveIfNotReady: boolean = false) { + const scrubResult = this.previewController.scrubToTimestamp(time); + + if (!scrubResult && saveIfNotReady) { + this.previewController.setNewPreviewStartTime(time); + } + + if (scrubResult && this.playerMode != "scrubbing") { + this.playerMode = "scrubbing"; + this.playerController.pause(); + this.setScrubbing(true); + } + } + + hasRecordingAtTime(time: number): boolean { + if (!this.recordings || this.recordings.length == 0) { + return false; + } + + return ( + this.recordings.find( + (segment) => segment.start_time <= time && segment.end_time >= time, + ) != undefined + ); + } +} + +export default typeof DynamicVideoController; diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx new file mode 100644 index 000000000..96fbc80fa --- /dev/null +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import TimelineEventOverlay from "../../overlay/TimelineDataOverlay"; +import { useApiHost } from "@/api"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { Recording } from "@/types/record"; +import { Preview } from "@/types/preview"; +import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; +import { DynamicVideoController } from "./DynamicVideoController"; +import HlsVideoPlayer from "../HlsVideoPlayer"; + +/** + * Dynamically switches between video playback and scrubbing preview player. + */ +type DynamicVideoPlayerProps = { + className?: string; + camera: string; + timeRange: { start: number; end: number }; + cameraPreviews: Preview[]; + startTimestamp?: number; + onControllerReady: (controller: DynamicVideoController) => void; + onTimestampUpdate?: (timestamp: number) => void; + onClipEnded?: () => void; +}; +export default function DynamicVideoPlayer({ + className, + camera, + timeRange, + cameraPreviews, + startTimestamp, + onControllerReady, + onTimestampUpdate, + onClipEnded, +}: DynamicVideoPlayerProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + + // playback behavior + const wideVideo = useMemo(() => { + if (!config) { + return false; + } + + return ( + config.cameras[camera].detect.width / + config.cameras[camera].detect.height > + 1.7 + ); + }, [camera, config]); + + // controlling playback + + const playerRef = useRef(null); + const [previewController, setPreviewController] = + useState(null); + const [isScrubbing, setIsScrubbing] = useState(false); + const [focusedItem, setFocusedItem] = useState( + undefined, + ); + 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, + "playback", + setIsScrubbing, + setFocusedItem, + ); + // 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 + + const [source, setSource] = useState( + `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + ); + + // start at correct time + + const onPlayerLoaded = useCallback(() => { + if (!controller || !startTimestamp) { + return; + } + + controller.seekToTimestamp(startTimestamp, true); + }, [startTimestamp, controller]); + + const onTimeUpdate = useCallback( + (time: number) => { + if (!controller || !onTimestampUpdate || time == 0) { + return; + } + + onTimestampUpdate(controller.getProgress(time)); + }, + [controller, onTimestampUpdate], + ); + + // state of playback player + + const recordingParams = useMemo(() => { + return { + before: timeRange.end, + after: timeRange.start, + }; + }, [timeRange]); + const { data: recordings } = useSWR( + [`${camera}/recordings`, recordingParams], + { revalidateOnFocus: false }, + ); + + useEffect(() => { + if (!controller || !recordings) { + return; + } + + setSource( + `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`, + ); + + controller.newPlayback({ + recordings: recordings ?? [], + }); + + // we only want this to change when recordings update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller, recordings]); + + return ( +
+
+ + {config && focusedItem && ( + + )} + +
+ { + setPreviewController(previewController); + }} + /> +
+ ); +} diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 76ac6fe95..395378219 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from "@/components/ui/select"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; +import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; // Color data const colors = [ @@ -157,6 +158,8 @@ function UIPlayground() { timestampSpread: 15, }); + const videoRef = useRef(null); + const possibleZoomLevels = [ { segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 30, timestampSpread: 5 }, @@ -290,6 +293,14 @@ function UIPlayground() {
+
+ +
+
{!isEventsReviewTimeline && ( { + + const onClipEnded = useCallback(() => { if (!mainControllerRef.current) { return; } - mainControllerRef.current.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - // we only want to fire once when players are ready - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]); + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } + }, [selectedRangeIdx, timeRange]); // scrubbing and timeline state @@ -101,8 +95,8 @@ export function DesktopRecordingView({ ); if (index != -1) { - setSelectedRangeIdx(index); setPlaybackStart(currentTime); + setSelectedRangeIdx(index); } }, [timeRange], @@ -134,7 +128,14 @@ export function DesktopRecordingView({ useEffect(() => { if (!scrubbing) { - mainControllerRef.current?.seekToTimestamp(currentTime, true); + if ( + currentTimeRange.start <= currentTime && + currentTimeRange.end >= currentTime + ) { + mainControllerRef.current?.seekToTimestamp(currentTime, true); + } else { + updateSelectedSegment(currentTime); + } } // we only want to seek when user stops scrubbing @@ -221,16 +222,17 @@ export function DesktopRecordingView({ camera={mainCamera} timeRange={currentTimeRange} cameraPreviews={allPreviews ?? []} - startTime={playbackStart} + startTimestamp={playbackStart} + onTimestampUpdate={(timestamp) => { + setPlayerTime(timestamp); + setCurrentTime(timestamp); + Object.values(previewRefs.current ?? {}).forEach((prev) => + prev.scrubToTimestamp(Math.floor(timestamp)), + ); + }} + onClipEnded={onClipEnded} onControllerReady={(controller) => { mainControllerRef.current = controller; - controller.onPlayerTimeUpdate((timestamp: number) => { - setPlayerTime(timestamp); - setCurrentTime(timestamp); - Object.values(previewRefs.current ?? {}).forEach((prev) => - prev.scrubToTimestamp(Math.floor(timestamp)), - ); - }); }} />
@@ -318,7 +320,6 @@ export function MobileRecordingView({ // controller state - const [playerReady, setPlayerReady] = useState(false); const controllerRef = useRef(undefined); const [playbackCamera, setPlaybackCamera] = useState(startCamera); const [playbackStart, setPlaybackStart] = useState(startTime); @@ -341,20 +342,17 @@ export function MobileRecordingView({ [reviewItems, playbackCamera], ); - // move to next clip - useEffect(() => { + // handle clip change + + const onClipEnded = useCallback(() => { if (!controllerRef.current) { return; } - controllerRef.current.onClipChangedEvent((dir) => { - if (dir == "forward") { - if (selectedRangeIdx < timeRange.ranges.length - 1) { - setSelectedRangeIdx(selectedRangeIdx + 1); - } - } - }); - }, [playerReady, selectedRangeIdx, timeRange]); + if (selectedRangeIdx < timeRange.ranges.length - 1) { + setSelectedRangeIdx(selectedRangeIdx + 1); + } + }, [selectedRangeIdx, timeRange]); // scrubbing and timeline state @@ -448,16 +446,12 @@ export function MobileRecordingView({ camera={playbackCamera} timeRange={currentTimeRange} cameraPreviews={relevantPreviews || []} - startTime={playbackStart} + startTimestamp={playbackStart} onControllerReady={(controller) => { controllerRef.current = controller; - setPlayerReady(true); - controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { - setCurrentTime(timestamp); - }); - - controllerRef.current?.seekToTimestamp(startTime, true); }} + onTimestampUpdate={setCurrentTime} + onClipEnded={onClipEnded} />