mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +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