mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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>
This commit is contained in:
parent
0e8350ea7f
commit
f9ed082e35
@ -105,6 +105,7 @@ def camera_ptz_info(camera_name):
|
|||||||
|
|
||||||
|
|
||||||
@MediaBp.route("/<camera_name>/latest.jpg")
|
@MediaBp.route("/<camera_name>/latest.jpg")
|
||||||
|
@MediaBp.route("/<camera_name>/latest.webp")
|
||||||
def latest_frame(camera_name):
|
def latest_frame(camera_name):
|
||||||
draw_options = {
|
draw_options = {
|
||||||
"bounding_boxes": request.args.get("bbox", type=int),
|
"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)
|
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode(
|
ret, img = cv2.imencode(
|
||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(img.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/webp"
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
elif camera_name == "birdseye" and current_app.frigate_config.birdseye.restream:
|
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)
|
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
ret, jpg = cv2.imencode(
|
ret, img = cv2.imencode(
|
||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".webp", frame, [int(cv2.IMWRITE_WEBP_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(img.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/webp"
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
|
279
web/package-lock.json
generated
279
web/package-lock.json
generated
@ -34,6 +34,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"hls.js": "^1.5.7",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.3",
|
"immer": "^10.0.3",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
@ -60,8 +61,6 @@
|
|||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.8.0",
|
"vaul": "^0.8.0",
|
||||||
"video.js": "^8.6.1",
|
|
||||||
"videojs-playlist": "^5.1.0",
|
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -2940,52 +2939,6 @@
|
|||||||
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
|
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@vitejs/plugin-react-swc": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.6.0.tgz",
|
"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"
|
"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": {
|
"node_modules/@yr/monotone-cubic-spline": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
"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": ">=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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
|
||||||
@ -4110,11 +4030,6 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.692",
|
"version": "1.4.692",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz",
|
||||||
@ -4877,15 +4792,6 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/globals": {
|
||||||
"version": "13.23.0",
|
"version": "13.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
|
||||||
@ -4973,6 +4879,11 @@
|
|||||||
"integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==",
|
"integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||||
@ -5083,11 +4994,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@ -5164,11 +5070,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
|
||||||
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"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"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.5",
|
"version": "0.30.5",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
||||||
@ -5692,14 +5564,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"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": "^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": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@ -6343,17 +6177,6 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/pkg-types": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz",
|
"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"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@ -7195,22 +7010,6 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@ -8042,11 +7841,6 @@
|
|||||||
"requires-port": "^1.0.0"
|
"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": {
|
"node_modules/use-callback-ref": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"hls.js": "^1.5.7",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.0.3",
|
"immer": "^10.0.3",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
@ -65,8 +66,6 @@
|
|||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.8.0",
|
"vaul": "^0.8.0",
|
||||||
"video.js": "^8.6.1",
|
|
||||||
"videojs-playlist": "^5.1.0",
|
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
|
@ -2,20 +2,23 @@ import Providers from "@/context/providers";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Wrapper from "@/components/Wrapper";
|
import Wrapper from "@/components/Wrapper";
|
||||||
import Sidebar from "@/components/navigation/Sidebar";
|
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 { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -30,6 +33,7 @@ function App() {
|
|||||||
id="pageRoot"
|
id="pageRoot"
|
||||||
className={`absolute top-2 right-0 overflow-hidden ${isMobile ? "left-0 bottom-16" : "left-16 bottom-8"}`}
|
className={`absolute top-2 right-0 overflow-hidden ${isMobile ? "left-0 bottom-16" : "left-16 bottom-8"}`}
|
||||||
>
|
>
|
||||||
|
<Suspense>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Live />} />
|
<Route path="/" element={<Live />} />
|
||||||
<Route path="/events" element={<Events />} />
|
<Route path="/events" element={<Events />} />
|
||||||
@ -43,6 +47,7 @@ function App() {
|
|||||||
<Route path="/playground" element={<UIPlayground />} />
|
<Route path="/playground" element={<UIPlayground />} />
|
||||||
<Route path="*" element={<NoMatch />} />
|
<Route path="*" element={<NoMatch />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
|
@ -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<FrigateConfig>("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<Player | null>(null);
|
|
||||||
const [previewController, setPreviewController] =
|
|
||||||
useState<PreviewController | null>(null);
|
|
||||||
const [controls, setControls] = useState(false);
|
|
||||||
const [controlsOpen, setControlsOpen] = useState(false);
|
|
||||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
|
||||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
|
||||||
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<Recording[]>(
|
|
||||||
[`${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 (
|
|
||||||
<div
|
|
||||||
className={`relative ${className ?? ""} cursor-pointer`}
|
|
||||||
onMouseOver={
|
|
||||||
isDesktop
|
|
||||||
? () => {
|
|
||||||
setControls(true);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onMouseOut={
|
|
||||||
isDesktop
|
|
||||||
? () => {
|
|
||||||
setControls(controlsOpen);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
|
|
||||||
<VideoPlayer
|
|
||||||
options={{
|
|
||||||
preload: "auto",
|
|
||||||
autoplay: true,
|
|
||||||
sources: [initialPlaybackSource],
|
|
||||||
aspectRatio: wideVideo ? undefined : "16:9",
|
|
||||||
controls: false,
|
|
||||||
nativeControlsForTouch: false,
|
|
||||||
}}
|
|
||||||
onReady={(player) => {
|
|
||||||
setPlayerRef(player);
|
|
||||||
}}
|
|
||||||
onDispose={() => {
|
|
||||||
setPlayerRef(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config && focusedItem && (
|
|
||||||
<TimelineEventOverlay
|
|
||||||
timeline={focusedItem}
|
|
||||||
cameraConfig={config.cameras[camera]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VideoPlayer>
|
|
||||||
<PlayerControls
|
|
||||||
player={playerRef}
|
|
||||||
show={controls}
|
|
||||||
controlsOpen={controlsOpen}
|
|
||||||
setControlsOpen={setControlsOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PreviewPlayer
|
|
||||||
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
|
||||||
camera={camera}
|
|
||||||
timeRange={timeRange}
|
|
||||||
cameraPreviews={cameraPreviews}
|
|
||||||
onControllerReady={(previewController) => {
|
|
||||||
setPreviewController(previewController);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const currentTime = player?.currentTime();
|
|
||||||
|
|
||||||
if (!player || !currentTime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
player.currentTime(Math.max(0, currentTime - 10));
|
|
||||||
},
|
|
||||||
[player],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSkip = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const currentTime = player?.currentTime();
|
|
||||||
|
|
||||||
if (!player || !currentTime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
player.currentTime(currentTime + 10);
|
|
||||||
},
|
|
||||||
[player],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onTogglePlay = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!player) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.paused()) {
|
|
||||||
player.play();
|
|
||||||
} else {
|
|
||||||
player.pause();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[player],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!player || !show) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 flex justify-between items-center gap-8 text-white z-10 bg-black bg-opacity-60 rounded-lg`}
|
|
||||||
>
|
|
||||||
<MdReplay10 className="size-5 cursor-pointer" onClick={onReplay} />
|
|
||||||
<div className="cursor-pointer" onClick={onTogglePlay}>
|
|
||||||
{player.paused() ? (
|
|
||||||
<LuPlay className="size-5 fill-white" />
|
|
||||||
) : (
|
|
||||||
<LuPause className="size-5 fill-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<MdForward10 className="size-5 cursor-pointer" onClick={onSkip} />
|
|
||||||
<DropdownMenu
|
|
||||||
open={controlsOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setControlsOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger>{`${player.playbackRate()}x`}</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
onValueChange={(rate) => player.playbackRate(parseInt(rate))}
|
|
||||||
>
|
|
||||||
{playbackRates.map((rate) => (
|
|
||||||
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
|
|
||||||
{rate}x
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
321
web/src/components/player/HlsVideoPlayer.tsx
Normal file
321
web/src/components/player/HlsVideoPlayer.tsx
Normal file
@ -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<HTMLVideoElement | null>;
|
||||||
|
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<Hls>();
|
||||||
|
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<NodeJS.Timeout>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`relative ${className ?? ""}`}
|
||||||
|
onMouseOver={
|
||||||
|
isDesktop
|
||||||
|
? () => {
|
||||||
|
setControls(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onMouseOut={
|
||||||
|
isDesktop
|
||||||
|
? () => {
|
||||||
|
setControls(controlsOpen);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="size-full rounded-2xl"
|
||||||
|
preload="auto"
|
||||||
|
autoPlay
|
||||||
|
controls={false}
|
||||||
|
playsInline
|
||||||
|
onPlay={() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setControls(true);
|
||||||
|
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
if (isMobile && mobileCtrlTimeout) {
|
||||||
|
clearTimeout(mobileCtrlTimeout);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTimeUpdate={() =>
|
||||||
|
onTimeUpdate && videoRef.current
|
||||||
|
? onTimeUpdate(videoRef.current.currentTime)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onLoadedData={onPlayerLoaded}
|
||||||
|
onEnded={onClipEnded}
|
||||||
|
onError={(e) => {
|
||||||
|
if (
|
||||||
|
!hlsRef.current &&
|
||||||
|
// @ts-expect-error code does exist
|
||||||
|
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||||
|
videoRef.current
|
||||||
|
) {
|
||||||
|
setUseHlsCompat(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VideoControls
|
||||||
|
video={videoRef.current}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
show={controls}
|
||||||
|
controlsOpen={controlsOpen}
|
||||||
|
setControlsOpen={setControlsOpen}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const currentTime = video?.currentTime;
|
||||||
|
|
||||||
|
if (!video || !currentTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.currentTime = Math.max(0, currentTime - 10);
|
||||||
|
},
|
||||||
|
[video],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSkip = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const currentTime = video?.currentTime;
|
||||||
|
|
||||||
|
if (!video || !currentTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.currentTime = currentTime + 10;
|
||||||
|
},
|
||||||
|
[video],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTogglePlay = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
video.pause();
|
||||||
|
} else {
|
||||||
|
video.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, video],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!video || !show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-black bg-opacity-60 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MdReplay10 className="size-5 cursor-pointer" onClick={onReplay} />
|
||||||
|
<div className="cursor-pointer" onClick={onTogglePlay}>
|
||||||
|
{isPlaying ? (
|
||||||
|
<LuPause className="size-5 fill-white" />
|
||||||
|
) : (
|
||||||
|
<LuPlay className="size-5 fill-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<MdForward10 className="size-5 cursor-pointer" onClick={onSkip} />
|
||||||
|
<DropdownMenu
|
||||||
|
open={controlsOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setControlsOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger>{`${video.playbackRate}x`}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
onValueChange={(rate) => (video.playbackRate = parseInt(rate))}
|
||||||
|
>
|
||||||
|
{playbackRates.map((rate) => (
|
||||||
|
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
|
||||||
|
{rate}x
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -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<HTMLDivElement | null>(null);
|
|
||||||
const playerRef = useRef<Player | null>(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 (
|
|
||||||
<div data-vjs-player>
|
|
||||||
<div className="rounded-2xl overflow-hidden" ref={videoRef} />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
146
web/src/components/player/dynamic/DynamicVideoController.ts
Normal file
146
web/src/components/player/dynamic/DynamicVideoController.ts
Normal file
@ -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;
|
178
web/src/components/player/dynamic/DynamicVideoPlayer.tsx
Normal file
178
web/src/components/player/dynamic/DynamicVideoPlayer.tsx
Normal file
@ -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<FrigateConfig>("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<HTMLVideoElement | null>(null);
|
||||||
|
const [previewController, setPreviewController] =
|
||||||
|
useState<PreviewController | null>(null);
|
||||||
|
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||||
|
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||||
|
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<Recording[]>(
|
||||||
|
[`${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 (
|
||||||
|
<div className={`relative ${className ?? ""} cursor-pointer`}>
|
||||||
|
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
|
||||||
|
<HlsVideoPlayer
|
||||||
|
className={` ${wideVideo ? "" : "aspect-video"}`}
|
||||||
|
videoRef={playerRef}
|
||||||
|
currentSource={source}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onPlayerLoaded={onPlayerLoaded}
|
||||||
|
onClipEnded={onClipEnded}
|
||||||
|
>
|
||||||
|
{config && focusedItem && (
|
||||||
|
<TimelineEventOverlay
|
||||||
|
timeline={focusedItem}
|
||||||
|
cameraConfig={config.cameras[camera]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HlsVideoPlayer>
|
||||||
|
</div>
|
||||||
|
<PreviewPlayer
|
||||||
|
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
||||||
|
camera={camera}
|
||||||
|
timeRange={timeRange}
|
||||||
|
cameraPreviews={cameraPreviews}
|
||||||
|
onControllerReady={(previewController) => {
|
||||||
|
setPreviewController(previewController);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -23,6 +23,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||||
|
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
||||||
|
|
||||||
// Color data
|
// Color data
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -157,6 +158,8 @@ function UIPlayground() {
|
|||||||
timestampSpread: 15,
|
timestampSpread: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
const possibleZoomLevels = [
|
const possibleZoomLevels = [
|
||||||
{ segmentDuration: 60, timestampSpread: 15 },
|
{ segmentDuration: 60, timestampSpread: 15 },
|
||||||
{ segmentDuration: 30, timestampSpread: 5 },
|
{ segmentDuration: 30, timestampSpread: 5 },
|
||||||
@ -290,6 +293,14 @@ function UIPlayground() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute left-96 top-96 bottom-96 right-96">
|
||||||
|
<HlsVideoPlayer
|
||||||
|
className="size-full"
|
||||||
|
videoRef={videoRef}
|
||||||
|
currentSource="http://localhost:5173/vod/side_cam/start/1710345600/end/1710349200/master.m3u8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
||||||
{!isEventsReviewTimeline && (
|
{!isEventsReviewTimeline && (
|
||||||
<MotionReviewTimeline
|
<MotionReviewTimeline
|
||||||
|
@ -3,7 +3,6 @@ import { Recording } from "./record";
|
|||||||
|
|
||||||
export type DynamicPlayback = {
|
export type DynamicPlayback = {
|
||||||
recordings: Recording[];
|
recordings: Recording[];
|
||||||
playbackUri: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PreviewPlayback = {
|
export type PreviewPlayback = {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import DynamicVideoPlayer, {
|
|
||||||
DynamicVideoController,
|
|
||||||
} from "@/components/player/DynamicVideoPlayer";
|
|
||||||
import PreviewPlayer, {
|
import PreviewPlayer, {
|
||||||
PreviewController,
|
PreviewController,
|
||||||
} from "@/components/player/PreviewPlayer";
|
} from "@/components/player/PreviewPlayer";
|
||||||
|
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
|
||||||
|
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
||||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -72,21 +71,16 @@ export function DesktopRecordingView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// move to next clip
|
// move to next clip
|
||||||
useEffect(() => {
|
|
||||||
|
const onClipEnded = useCallback(() => {
|
||||||
if (!mainControllerRef.current) {
|
if (!mainControllerRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mainControllerRef.current.onClipChangedEvent((dir) => {
|
|
||||||
if (dir == "forward") {
|
|
||||||
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
||||||
setSelectedRangeIdx(selectedRangeIdx + 1);
|
setSelectedRangeIdx(selectedRangeIdx + 1);
|
||||||
}
|
}
|
||||||
}
|
}, [selectedRangeIdx, timeRange]);
|
||||||
});
|
|
||||||
// we only want to fire once when players are ready
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]);
|
|
||||||
|
|
||||||
// scrubbing and timeline state
|
// scrubbing and timeline state
|
||||||
|
|
||||||
@ -101,8 +95,8 @@ export function DesktopRecordingView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
setSelectedRangeIdx(index);
|
|
||||||
setPlaybackStart(currentTime);
|
setPlaybackStart(currentTime);
|
||||||
|
setSelectedRangeIdx(index);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[timeRange],
|
[timeRange],
|
||||||
@ -134,7 +128,14 @@ export function DesktopRecordingView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrubbing) {
|
if (!scrubbing) {
|
||||||
|
if (
|
||||||
|
currentTimeRange.start <= currentTime &&
|
||||||
|
currentTimeRange.end >= currentTime
|
||||||
|
) {
|
||||||
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
||||||
|
} else {
|
||||||
|
updateSelectedSegment(currentTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only want to seek when user stops scrubbing
|
// we only want to seek when user stops scrubbing
|
||||||
@ -221,16 +222,17 @@ export function DesktopRecordingView({
|
|||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
startTime={playbackStart}
|
startTimestamp={playbackStart}
|
||||||
onControllerReady={(controller) => {
|
onTimestampUpdate={(timestamp) => {
|
||||||
mainControllerRef.current = controller;
|
|
||||||
controller.onPlayerTimeUpdate((timestamp: number) => {
|
|
||||||
setPlayerTime(timestamp);
|
setPlayerTime(timestamp);
|
||||||
setCurrentTime(timestamp);
|
setCurrentTime(timestamp);
|
||||||
Object.values(previewRefs.current ?? {}).forEach((prev) =>
|
Object.values(previewRefs.current ?? {}).forEach((prev) =>
|
||||||
prev.scrubToTimestamp(Math.floor(timestamp)),
|
prev.scrubToTimestamp(Math.floor(timestamp)),
|
||||||
);
|
);
|
||||||
});
|
}}
|
||||||
|
onClipEnded={onClipEnded}
|
||||||
|
onControllerReady={(controller) => {
|
||||||
|
mainControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -318,7 +320,6 @@ export function MobileRecordingView({
|
|||||||
|
|
||||||
// controller state
|
// controller state
|
||||||
|
|
||||||
const [playerReady, setPlayerReady] = useState(false);
|
|
||||||
const controllerRef = useRef<DynamicVideoController | undefined>(undefined);
|
const controllerRef = useRef<DynamicVideoController | undefined>(undefined);
|
||||||
const [playbackCamera, setPlaybackCamera] = useState(startCamera);
|
const [playbackCamera, setPlaybackCamera] = useState(startCamera);
|
||||||
const [playbackStart, setPlaybackStart] = useState(startTime);
|
const [playbackStart, setPlaybackStart] = useState(startTime);
|
||||||
@ -341,20 +342,17 @@ export function MobileRecordingView({
|
|||||||
[reviewItems, playbackCamera],
|
[reviewItems, playbackCamera],
|
||||||
);
|
);
|
||||||
|
|
||||||
// move to next clip
|
// handle clip change
|
||||||
useEffect(() => {
|
|
||||||
|
const onClipEnded = useCallback(() => {
|
||||||
if (!controllerRef.current) {
|
if (!controllerRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
controllerRef.current.onClipChangedEvent((dir) => {
|
|
||||||
if (dir == "forward") {
|
|
||||||
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
||||||
setSelectedRangeIdx(selectedRangeIdx + 1);
|
setSelectedRangeIdx(selectedRangeIdx + 1);
|
||||||
}
|
}
|
||||||
}
|
}, [selectedRangeIdx, timeRange]);
|
||||||
});
|
|
||||||
}, [playerReady, selectedRangeIdx, timeRange]);
|
|
||||||
|
|
||||||
// scrubbing and timeline state
|
// scrubbing and timeline state
|
||||||
|
|
||||||
@ -448,16 +446,12 @@ export function MobileRecordingView({
|
|||||||
camera={playbackCamera}
|
camera={playbackCamera}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={relevantPreviews || []}
|
cameraPreviews={relevantPreviews || []}
|
||||||
startTime={playbackStart}
|
startTimestamp={playbackStart}
|
||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
controllerRef.current = controller;
|
controllerRef.current = controller;
|
||||||
setPlayerReady(true);
|
|
||||||
controllerRef.current.onPlayerTimeUpdate((timestamp: number) => {
|
|
||||||
setCurrentTime(timestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
controllerRef.current?.seekToTimestamp(startTime, true);
|
|
||||||
}}
|
}}
|
||||||
|
onTimestampUpdate={setCurrentTime}
|
||||||
|
onClipEnded={onClipEnded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user