Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-04-16 07:01:58 -06:00 committed by GitHub
commit 7440f4b5cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 636 additions and 418 deletions

View File

@ -77,6 +77,8 @@ def events():
min_length = request.args.get("min_length", type=float) min_length = request.args.get("min_length", type=float)
max_length = request.args.get("max_length", type=float) max_length = request.args.get("max_length", type=float)
sort = request.args.get("sort", type=str)
clauses = [] clauses = []
selected_columns = [ selected_columns = [
@ -219,10 +221,22 @@ def events():
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((True)) clauses.append((True))
if sort:
if sort == "score_asc":
order_by = Event.data["score"].asc()
elif sort == "score_desc":
order_by = Event.data["score"].desc()
elif sort == "date_asc":
Event.start_time.asc()
elif sort == "date_desc":
Event.start_time.desc()
else:
order_by = Event.start_time.desc()
events = ( events = (
Event.select(*selected_columns) Event.select(*selected_columns)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.order_by(Event.start_time.desc()) .order_by(order_by)
.limit(limit) .limit(limit)
.dicts() .dicts()
.iterator() .iterator()

View File

@ -1,8 +1,9 @@
"""Configure and control camera via onvif.""" """Configure and control camera via onvif."""
import logging import logging
import site
from enum import Enum from enum import Enum
from importlib.util import find_spec
from pathlib import Path
import numpy import numpy
from onvif import ONVIFCamera, ONVIFError from onvif import ONVIFCamera, ONVIFError
@ -50,10 +51,7 @@ class OnvifController:
cam.onvif.port, cam.onvif.port,
cam.onvif.user, cam.onvif.user,
cam.onvif.password, cam.onvif.password,
wsdl_dir=site.getsitepackages()[0].replace( wsdl_dir=Path(find_spec("onvif").origin).parent / "../wsdl",
"dist-packages", "site-packages"
)
+ "/wsdl",
), ),
"init": False, "init": False,
"active": False, "active": False,

192
web/package-lock.json generated
View File

@ -34,10 +34,10 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"hls.js": "^1.5.7", "hls.js": "^1.5.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.0.4", "immer": "^10.0.4",
"lucide-react": "^0.365.0", "lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.2.0", "react": "^18.2.0",
@ -45,7 +45,7 @@
"react-day-picker": "^8.9.1", "react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.51.3",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
@ -68,9 +68,9 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.12.5", "@types/node": "^20.12.7",
"@types/react": "^18.2.74", "@types/react": "^18.2.78",
"@types/react-dom": "^18.2.24", "@types/react-dom": "^18.2.25",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
@ -93,9 +93,9 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.4", "typescript": "^5.4.5",
"vite": "^5.2.8", "vite": "^5.2.8",
"vitest": "^1.3.1" "vitest": "^1.4.0"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@ -715,9 +715,9 @@
} }
}, },
"node_modules/@humanwhocodes/object-schema": { "node_modules/@humanwhocodes/object-schema": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true "dev": true
}, },
"node_modules/@inquirer/confirm": { "node_modules/@inquirer/confirm": {
@ -2507,9 +2507,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.12.5", "version": "20.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==", "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -2522,9 +2522,9 @@
"devOptional": true "devOptional": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.74", "version": "18.2.78",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.78.tgz",
"integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", "integrity": "sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@ -2532,9 +2532,9 @@
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.2.24", "version": "18.2.25",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz",
"integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@types/react": "*" "@types/react": "*"
@ -2618,6 +2618,58 @@
} }
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz",
"integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.5.0",
"@typescript-eslint/utils": "7.5.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz",
"integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.5.0",
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/typescript-estree": "7.5.0",
"semver": "^7.5.4"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "7.5.0", "version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz",
@ -2663,33 +2715,6 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/type-utils": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz",
"integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.5.0",
"@typescript-eslint/utils": "7.5.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "7.5.0", "version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz",
@ -2755,31 +2780,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@typescript-eslint/utils": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz",
"integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.5.0",
"@typescript-eslint/types": "7.5.0",
"@typescript-eslint/typescript-estree": "7.5.0",
"semver": "^7.5.4"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "7.5.0", "version": "7.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz",
@ -2945,9 +2945,9 @@
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==" "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.11.2", "version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true, "dev": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -4476,9 +4476,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.2.9", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
@ -4622,9 +4622,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "13.23.0", "version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"type-fest": "^0.20.2" "type-fest": "^0.20.2"
@ -4703,9 +4703,9 @@
"dev": true "dev": true
}, },
"node_modules/hls.js": { "node_modules/hls.js": {
"version": "1.5.7", "version": "1.5.8",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.8.tgz",
"integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==" "integrity": "sha512-hJYMPfLhWO7/7+n4f9pn6bOheCGx0WgvVz7k3ouq3Pp1bja48NN+HeCQu3XCGYzqWQF/wo7Sk6dJAyWVJD8ECA=="
}, },
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
@ -5279,9 +5279,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.365.0", "version": "0.368.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.365.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.368.0.tgz",
"integrity": "sha512-sJYpPyyzGHI4B3pys+XSFnE4qtSWc68rFnDLxbNNKjkLST5XSx9DNn5+1Z3eFgFiw39PphNRiVBSVb+AL3oKwA==", "integrity": "sha512-soryVrCjheZs8rbXKdINw9B8iPi5OajBJZMJ1HORig89ljcOcEokKKAgGbg3QWxSXel7JwHOfDFUdDHAKyUAMw==",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
} }
@ -6261,9 +6261,9 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.51.2", "version": "7.51.3",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz",
"integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==", "integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==",
"engines": { "engines": {
"node": ">=12.22.0" "node": ">=12.22.0"
}, },
@ -7284,9 +7284,9 @@
"dev": true "dev": true
}, },
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "0.8.2", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.3.tgz",
"integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", "integrity": "sha512-Ud7uepAklqRH1bvwy22ynrliC7Dljz7Tm8M/0RBUW+YRa4YHhZ6e4PpgE+fu1zr/WqB1kbeuVrdfeuyIBpy4tw==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@ -7421,9 +7421,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.4.4", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true, "dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -39,10 +39,10 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"hls.js": "^1.5.7", "hls.js": "^1.5.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.0.4", "immer": "^10.0.4",
"lucide-react": "^0.365.0", "lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.2.0", "react": "^18.2.0",
@ -50,7 +50,7 @@
"react-day-picker": "^8.9.1", "react-day-picker": "^8.9.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.51.3",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
@ -73,9 +73,9 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@types/node": "^20.12.5", "@types/node": "^20.12.7",
"@types/react": "^18.2.74", "@types/react": "^18.2.78",
"@types/react-dom": "^18.2.24", "@types/react-dom": "^18.2.25",
"@types/react-icons": "^3.0.0", "@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10", "@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8", "@types/strftime": "^0.9.8",
@ -98,8 +98,8 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.4", "typescript": "^5.4.5",
"vite": "^5.2.8", "vite": "^5.2.8",
"vitest": "^1.3.1" "vitest": "^1.4.0"
} }
} }

View File

@ -209,7 +209,7 @@ type CameraFilterButtonProps = {
selectedCameras: string[] | undefined; selectedCameras: string[] | undefined;
updateCameraFilter: (cameras: string[] | undefined) => void; updateCameraFilter: (cameras: string[] | undefined) => void;
}; };
function CamerasFilterButton({ export function CamerasFilterButton({
allCameras, allCameras,
groups, groups,
selectedCameras, selectedCameras,
@ -227,7 +227,7 @@ function CamerasFilterButton({
size="sm" size="sm"
> >
<FaVideo <FaVideo
className={`${selectedCameras?.length == 1 ? "text-selected-foreground" : "text-secondary-foreground"}`} className={`${(selectedCameras?.length ?? 0) >= 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
/> />
<div <div
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`} className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}

View File

@ -92,6 +92,9 @@ export function ThresholdBarGraph({
}, },
tooltip: { tooltip: {
theme: systemTheme || theme, theme: systemTheme || theme,
y: {
formatter: (val) => `${val}${unit}`,
},
}, },
markers: { markers: {
size: 0, size: 0,
@ -118,7 +121,7 @@ export function ThresholdBarGraph({
min: 0, min: 0,
}, },
} as ApexCharts.ApexOptions; } as ApexCharts.ApexOptions;
}, [graphId, threshold, systemTheme, theme, formatTime]); }, [graphId, threshold, unit, systemTheme, theme, formatTime]);
useEffect(() => { useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true); ApexCharts.exec(graphId, "updateOptions", options, true, true);
@ -190,7 +193,7 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
}, },
}, },
tooltip: { tooltip: {
show: false, enabled: false,
}, },
xaxis: { xaxis: {
axisBorder: { axisBorder: {

View File

@ -1,9 +1,9 @@
import { LuLoader2 } from "react-icons/lu"; import { LuLoader2 } from "react-icons/lu";
export default function ActivityIndicator({ size = 30 }) { export default function ActivityIndicator({ className = "w-full", size = 30 }) {
return ( return (
<div <div
className="w-full flex items-center justify-center" className={`flex items-center justify-center ${className}`}
aria-label="Loading…" aria-label="Loading…"
> >
<LuLoader2 className="animate-spin" size={size} /> <LuLoader2 className="animate-spin" size={size} />

View File

@ -1,10 +1,4 @@
import { import { MutableRefObject, useEffect, useRef, useState } from "react";
MutableRefObject,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import Hls from "hls.js"; import Hls from "hls.js";
import { isAndroid, isDesktop, isMobile } from "react-device-detect"; import { isAndroid, isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
@ -19,7 +13,6 @@ const unsupportedErrorCodes = [
]; ];
type HlsVideoPlayerProps = { type HlsVideoPlayerProps = {
children?: ReactNode;
videoRef: MutableRefObject<HTMLVideoElement | null>; videoRef: MutableRefObject<HTMLVideoElement | null>;
visible: boolean; visible: boolean;
currentSource: string; currentSource: string;
@ -30,7 +23,6 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void; onPlaying?: () => void;
}; };
export default function HlsVideoPlayer({ export default function HlsVideoPlayer({
children,
videoRef, videoRef,
visible, visible,
currentSource, currentSource,
@ -83,19 +75,88 @@ export default function HlsVideoPlayer({
// controls // controls
const [isPlaying, setIsPlaying] = useState(true); const [isPlaying, setIsPlaying] = useState(true);
const [muted, setMuted] = useState(true);
const [volume, setVolume] = useState(1.0);
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>(); const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile); const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false); const [controlsOpen, setControlsOpen] = useState(false);
useEffect(() => {
if (!isDesktop) {
return;
}
const callback = (e: MouseEvent) => {
if (!videoRef.current) {
return;
}
const rect = videoRef.current.getBoundingClientRect();
if (
e.clientX > rect.left &&
e.clientX < rect.right &&
e.clientY > rect.top &&
e.clientY < rect.bottom
) {
setControls(true);
} else {
setControls(controlsOpen);
}
};
window.addEventListener("mousemove", callback);
return () => {
window.removeEventListener("mousemove", callback);
};
}, [videoRef, controlsOpen]);
return ( return (
<TransformWrapper minScale={1.0}> <TransformWrapper minScale={1.0}>
<VideoControls
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
video={videoRef.current}
isPlaying={isPlaying}
show={visible && controls}
muted={muted}
volume={volume}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
setMuted={setMuted}
playbackRate={videoRef.current?.playbackRate ?? 1}
hotKeys={hotKeys}
onPlayPause={(play) => {
if (!videoRef.current) {
return;
}
if (play) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}}
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
}
/>
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{
position: "relative",
display: visible ? undefined : "none", display: visible ? undefined : "none",
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}
wrapperProps={{
onClick: isDesktop ? undefined : () => setControls(!controls),
}}
contentStyle={{ contentStyle={{
width: "100%", width: "100%",
height: isMobile ? "100%" : undefined, height: isMobile ? "100%" : undefined,
@ -108,7 +169,8 @@ export default function HlsVideoPlayer({
autoPlay autoPlay
controls={false} controls={false}
playsInline playsInline
muted muted={muted}
onVolumeChange={() => setVolume(videoRef.current?.volume ?? 1.0)}
onPlay={() => { onPlay={() => {
setIsPlaying(true); setIsPlaying(true);
@ -145,61 +207,6 @@ export default function HlsVideoPlayer({
} }
}} }}
/> />
<div
className="absolute inset-0"
onMouseOver={
isDesktop
? () => {
setControls(true);
}
: undefined
}
onMouseOut={
isDesktop
? () => {
setControls(controlsOpen);
}
: undefined
}
onClick={isDesktop ? undefined : () => setControls(!controls)}
>
<div className={`size-full relative ${visible ? "" : "hidden"}`}>
<VideoControls
className="absolute bottom-5 left-1/2 -translate-x-1/2"
video={videoRef.current}
isPlaying={isPlaying}
show={controls}
controlsOpen={controlsOpen}
setControlsOpen={setControlsOpen}
playbackRate={videoRef.current?.playbackRate ?? 1}
hotKeys={hotKeys}
onPlayPause={(play) => {
if (!videoRef.current) {
return;
}
if (play) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}}
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) =>
videoRef.current ? (videoRef.current.playbackRate = rate) : null
}
/>
{children}
</div>
</div>
</TransformComponent> </TransformComponent>
</TransformWrapper> </TransformWrapper>
); );

View File

@ -14,6 +14,7 @@ import { isCurrentHour } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile } from "react-device-detect"; import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { Skeleton } from "../ui/skeleton";
type PreviewPlayerProps = { type PreviewPlayerProps = {
className?: string; className?: string;
@ -143,6 +144,8 @@ function PreviewVideoPlayer({
// initial state // initial state
const [firstLoad, setFirstLoad] = useState(true);
const initialPreview = useMemo(() => { const initialPreview = useMemo(() => {
return cameraPreviews.find( return cameraPreviews.find(
(preview) => (preview) =>
@ -253,6 +256,10 @@ function PreviewVideoPlayer({
disableRemotePlayback disableRemotePlayback
onSeeked={onPreviewSeeked} onSeeked={onPreviewSeeked}
onLoadedData={() => { onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
if (controller) { if (controller) {
controller.previewReady(); controller.previewReady();
} else { } else {
@ -280,6 +287,7 @@ function PreviewVideoPlayer({
No Preview Found No Preview Found
</div> </div>
)} )}
{firstLoad && <Skeleton className="absolute size-full aspect-video" />}
</div> </div>
); );
} }
@ -427,6 +435,8 @@ function PreviewFramesPlayer({
// initial state // initial state
const [firstLoad, setFirstLoad] = useState(true);
useEffect(() => { useEffect(() => {
if (!controller) { if (!controller) {
return; return;
@ -441,6 +451,8 @@ function PreviewFramesPlayer({
}, [controller]); }, [controller]);
const onImageLoaded = useCallback(() => { const onImageLoaded = useCallback(() => {
setFirstLoad(false);
if (!controller) { if (!controller) {
return; return;
} }
@ -477,6 +489,7 @@ function PreviewFramesPlayer({
No Preview Found No Preview Found
</div> </div>
)} )}
{firstLoad && <Skeleton className="absolute size-full aspect-video" />}
</div> </div>
); );
} }

View File

@ -8,7 +8,6 @@ import React, {
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isCurrentHour } from "@/utils/dateUtil"; import { isCurrentHour } from "@/utils/dateUtil";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { Slider } from "../ui/slider-no-thumb";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
@ -23,6 +22,7 @@ import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import useContextMenu from "@/hooks/use-contextmenu"; import useContextMenu from "@/hooks/use-contextmenu";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { NoThumbSlider } from "../ui/slider";
type PreviewPlayerProps = { type PreviewPlayerProps = {
review: ReviewSegment; review: ReviewSegment;
@ -543,7 +543,7 @@ function VideoPreview({
> >
<source src={relevantPreview.src} type={relevantPreview.type} /> <source src={relevantPreview.src} type={relevantPreview.type} />
</video> </video>
<Slider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30" className="absolute inset-x-0 bottom-0 z-30"
value={[progress]} value={[progress]}
@ -707,7 +707,7 @@ function InProgressPreview({
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`} src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
onLoad={handleLoad} onLoad={handleLoad}
/> />
<Slider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className="absolute inset-x-0 bottom-0 z-30" className="absolute inset-x-0 bottom-0 z-30"
value={[key]} value={[key]}

View File

@ -16,8 +16,8 @@ import {
MdVolumeOff, MdVolumeOff,
MdVolumeUp, MdVolumeUp,
} from "react-icons/md"; } from "react-icons/md";
import { Slider } from "../ui/slider-volume";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { VolumeSlider } from "../ui/slider";
type VideoControls = { type VideoControls = {
volume?: boolean; volume?: boolean;
@ -38,11 +38,14 @@ type VideoControlsProps = {
features?: VideoControls; features?: VideoControls;
isPlaying: boolean; isPlaying: boolean;
show: boolean; show: boolean;
muted?: boolean;
volume?: number;
controlsOpen?: boolean; controlsOpen?: boolean;
playbackRates?: number[]; playbackRates?: number[];
playbackRate: number; playbackRate: number;
hotKeys?: boolean; hotKeys?: boolean;
setControlsOpen?: (open: boolean) => void; setControlsOpen?: (open: boolean) => void;
setMuted?: (muted: boolean) => void;
onPlayPause: (play: boolean) => void; onPlayPause: (play: boolean) => void;
onSeek: (diff: number) => void; onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void; onSetPlaybackRate: (rate: number) => void;
@ -53,11 +56,14 @@ export default function VideoControls({
features = CONTROLS_DEFAULT, features = CONTROLS_DEFAULT,
isPlaying, isPlaying,
show, show,
muted,
volume,
controlsOpen, controlsOpen,
playbackRates = PLAYBACK_RATE_DEFAULT, playbackRates = PLAYBACK_RATE_DEFAULT,
playbackRate, playbackRate,
hotKeys = true, hotKeys = true,
setControlsOpen, setControlsOpen,
setMuted,
onPlayPause, onPlayPause,
onSeek, onSeek,
onSetPlaybackRate, onSetPlaybackRate,
@ -89,18 +95,18 @@ export default function VideoControls({
// volume control // volume control
const VolumeIcon = useMemo(() => { const VolumeIcon = useMemo(() => {
if (!video || video?.muted) { if (!volume || volume == 0.0 || muted) {
return MdVolumeOff; return MdVolumeOff;
} else if (video.volume <= 0.33) { } else if (volume <= 0.33) {
return MdVolumeMute; return MdVolumeMute;
} else if (video.volume <= 0.67) { } else if (volume <= 0.67) {
return MdVolumeDown; return MdVolumeDown;
} else { } else {
return MdVolumeUp; return MdVolumeUp;
} }
// only update when specific fields change // only update when specific fields change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.volume, video?.muted]); }, [volume, muted]);
const onKeyboardShortcut = useCallback( const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => { (key: string, down: boolean, repeat: boolean) => {
@ -116,8 +122,8 @@ export default function VideoControls({
} }
break; break;
case "m": case "m":
if (down && !repeat && video) { if (setMuted && down && !repeat && video) {
video.muted = !video.muted; setMuted(!muted);
} }
break; break;
case " ": case " ":
@ -150,13 +156,16 @@ export default function VideoControls({
className="size-5" className="size-5"
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
video.muted = !video.muted;
if (setMuted) {
setMuted(!muted);
}
}} }}
/> />
{video.muted == false && ( {muted == false && (
<Slider <VolumeSlider
className="w-20" className="w-20"
value={[video.volume]} value={[volume ?? 1.0]}
min={0} min={0}
max={1} max={1}
step={0.02} step={0.02}
@ -193,7 +202,11 @@ export default function VideoControls({
onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))} onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))}
> >
{playbackRates.map((rate) => ( {playbackRates.map((rate) => (
<DropdownMenuRadioItem key={rate} value={rate.toString()}> <DropdownMenuRadioItem
key={rate}
className="cursor-pointer"
value={rate.toString()}
>
{rate}x {rate}x
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TimelineEventOverlay from "../../overlay/TimelineDataOverlay";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -8,7 +7,8 @@ import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController"; import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer"; import HlsVideoPlayer from "../HlsVideoPlayer";
import { TimeRange, Timeline } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
/** /**
* Dynamically switches between video playback and scrubbing preview player. * Dynamically switches between video playback and scrubbing preview player.
@ -45,9 +45,6 @@ export default function DynamicVideoPlayer({
const playerRef = useRef<HTMLVideoElement | null>(null); const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] = const [previewController, setPreviewController] =
useState<PreviewController | null>(null); useState<PreviewController | null>(null);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined,
);
const controller = useMemo(() => { const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) { if (!config || !playerRef.current || !previewController) {
return undefined; return undefined;
@ -59,7 +56,7 @@ export default function DynamicVideoPlayer({
previewController, previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
isScrubbing ? "scrubbing" : "playback", isScrubbing ? "scrubbing" : "playback",
setFocusedItem, () => {},
); );
// we only want to fire once when players are ready // we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -81,6 +78,7 @@ export default function DynamicVideoPlayer({
// initial state // initial state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
const [source, setSource] = useState( const [source, setSource] = useState(
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
); );
@ -88,8 +86,8 @@ export default function DynamicVideoPlayer({
// start at correct time // start at correct time
useEffect(() => { useEffect(() => {
if (isScrubbing) { if (!isScrubbing) {
setIsLoading(true); setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
} }
}, [isScrubbing]); }, [isScrubbing]);
@ -137,7 +135,7 @@ export default function DynamicVideoPlayer({
setSource( setSource(
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
); );
setIsLoading(true); setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
controller.newPlayback({ controller.newPlayback({
recordings: recordings ?? [], recordings: recordings ?? [],
@ -162,16 +160,13 @@ export default function DynamicVideoPlayer({
playerRef.current?.pause(); playerRef.current?.pause();
} }
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
setIsLoading(false); setIsLoading(false);
}} }}
>
{config && focusedItem && (
<TimelineEventOverlay
timeline={focusedItem}
cameraConfig={config.cameras[camera]}
/> />
)}
</HlsVideoPlayer>
<PreviewPlayer <PreviewPlayer
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`} className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
camera={camera} camera={camera}
@ -183,6 +178,9 @@ export default function DynamicVideoPlayer({
setPreviewController(previewController); setPreviewController(previewController);
}} }}
/> />
{isLoading && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x1/2 -translate-y-1/2" />
)}
</> </>
); );
} }

View File

@ -1,26 +0,0 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -1,26 +0,0 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-white" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-white ring-white focus:ring-white disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@ -11,7 +11,7 @@ const Slider = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", "relative flex w-full touch-none select-none items-center",
className className,
)} )}
{...props} {...props}
> >
@ -20,7 +20,68 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider } const VolumeSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-white" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-white ring-white focus:ring-white disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
VolumeSlider.displayName = SliderPrimitive.Root.displayName;
const NoThumbSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />
</SliderPrimitive.Root>
));
NoThumbSlider.displayName = SliderPrimitive.Root.displayName;
const DualThumbSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-selected/60">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
DualThumbSlider.displayName = SliderPrimitive.Root.displayName;
export { DualThumbSlider, Slider, NoThumbSlider, VolumeSlider };

View File

@ -11,6 +11,7 @@ import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator";
const logTypes = ["frigate", "go2rtc", "nginx"] as const; const logTypes = ["frigate", "go2rtc", "nginx"] as const;
type LogType = (typeof logTypes)[number]; type LogType = (typeof logTypes)[number];
@ -278,6 +279,9 @@ function Logs() {
} }
}) })
.catch(() => {}); .catch(() => {});
contentRef.current?.scrollBy({
top: 10,
});
} }
}); });
if (node) startObserver.current.observe(node); if (node) startObserver.current.observe(node);
@ -388,7 +392,7 @@ function Logs() {
</Button> </Button>
)} )}
<div className="size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden"> <div className="relative size-full flex flex-col my-2 font-mono text-sm sm:p-2 whitespace-pre-wrap bg-background_alt border border-secondary rounded-md overflow-hidden">
<div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40"> <div className="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-12 *:px-2 *:py-3 *:text-sm *:text-primary/40">
<div className="p-1 flex items-center capitalize">Type</div> <div className="p-1 flex items-center capitalize">Type</div>
<div className="col-span-2 sm:col-span-1 flex items-center"> <div className="col-span-2 sm:col-span-1 flex items-center">
@ -443,6 +447,9 @@ function Logs() {
})} })}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />} {logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</div> </div>
{logLines.length == 0 && (
<ActivityIndicator className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2" />
)}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,8 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import FilterCheckBox from "@/components/filter/FilterCheckBox"; import {
CamerasFilterButton,
GeneralFilterContent,
} from "@/components/filter/ReviewFilterGroup";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -13,16 +16,25 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DualThumbSlider } from "@/components/ui/slider";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { FaList, FaVideo } from "react-icons/fa"; import {
FaList,
FaSort,
FaSortAmountDown,
FaSortAmountUp,
} from "react-icons/fa";
import { PiSlidersHorizontalFill } from "react-icons/pi";
import useSWR from "swr"; import useSWR from "swr";
export default function SubmitPlus() { export default function SubmitPlus() {
@ -36,6 +48,11 @@ export default function SubmitPlus() {
const [selectedCameras, setSelectedCameras] = useState<string[]>(); const [selectedCameras, setSelectedCameras] = useState<string[]>();
const [selectedLabels, setSelectedLabels] = useState<string[]>(); const [selectedLabels, setSelectedLabels] = useState<string[]>();
const [scoreRange, setScoreRange] = useState<number[]>();
// sort
const [sort, setSort] = useState<string>();
// data // data
@ -47,6 +64,9 @@ export default function SubmitPlus() {
is_submitted: 0, is_submitted: 0,
cameras: selectedCameras ? selectedCameras.join(",") : null, cameras: selectedCameras ? selectedCameras.join(",") : null,
labels: selectedLabels ? selectedLabels.join(",") : null, labels: selectedLabels ? selectedLabels.join(",") : null,
min_score: scoreRange ? scoreRange[0] : null,
max_score: scoreRange ? scoreRange[1] : null,
sort: sort ? sort : null,
}, },
]); ]);
const [upload, setUpload] = useState<Event>(); const [upload, setUpload] = useState<Event>();
@ -104,12 +124,17 @@ export default function SubmitPlus() {
return ( return (
<div className="size-full flex flex-col"> <div className="size-full flex flex-col">
<div className="w-full h-16 px-2 flex items-center justify-between overflow-x-auto">
<PlusFilterGroup <PlusFilterGroup
selectedCameras={selectedCameras} selectedCameras={selectedCameras}
setSelectedCameras={setSelectedCameras}
selectedLabels={selectedLabels} selectedLabels={selectedLabels}
selectedScoreRange={scoreRange}
setSelectedCameras={setSelectedCameras}
setSelectedLabels={setSelectedLabels} setSelectedLabels={setSelectedLabels}
setSelectedScoreRange={setScoreRange}
/> />
<PlusSortSelector selectedSort={sort} setSelectedSort={setSort} />
</div>
<div className="size-full flex flex-1 flex-wrap content-start gap-2 md:gap-4 overflow-y-auto no-scrollbar"> <div className="size-full flex flex-1 flex-wrap content-start gap-2 md:gap-4 overflow-y-auto no-scrollbar">
<div className="w-full p-2 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2"> <div className="w-full p-2 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
<Dialog <Dialog
@ -178,15 +203,19 @@ const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
type PlusFilterGroupProps = { type PlusFilterGroupProps = {
selectedCameras: string[] | undefined; selectedCameras: string[] | undefined;
setSelectedCameras: (cameras: string[] | undefined) => void;
selectedLabels: string[] | undefined; selectedLabels: string[] | undefined;
selectedScoreRange: number[] | undefined;
setSelectedCameras: (cameras: string[] | undefined) => void;
setSelectedLabels: (cameras: string[] | undefined) => void; setSelectedLabels: (cameras: string[] | undefined) => void;
setSelectedScoreRange: (range: number[] | undefined) => void;
}; };
function PlusFilterGroup({ function PlusFilterGroup({
selectedCameras, selectedCameras,
setSelectedCameras,
selectedLabels, selectedLabels,
selectedScoreRange,
setSelectedCameras,
setSelectedLabels, setSelectedLabels,
setSelectedScoreRange,
}: PlusFilterGroupProps) { }: PlusFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -217,97 +246,28 @@ function PlusFilterGroup({
return [...labels].sort(); return [...labels].sort();
}, [config, selectedCameras]); }, [config, selectedCameras]);
const [open, setOpen] = useState<"none" | "camera" | "label">("none"); const [open, setOpen] = useState<"none" | "camera" | "label" | "score">(
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>( "none",
undefined,
); );
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
undefined, undefined,
); );
const [currentScoreRange, setCurrentScoreRange] = useState<
number[] | undefined
>(undefined);
const Menu = isMobile ? Drawer : DropdownMenu; const Menu = isMobile ? Drawer : DropdownMenu;
const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger; const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger;
const Content = isMobile ? DrawerContent : DropdownMenuContent; const Content = isMobile ? DrawerContent : DropdownMenuContent;
return ( return (
<div className="w-full h-16 flex justify-start gap-2 items-center"> <div className="h-full flex justify-start gap-2 items-center">
<Menu <CamerasFilterButton
open={open == "camera"} allCameras={allCameras}
onOpenChange={(open) => { groups={[]}
if (!open) { selectedCameras={selectedCameras}
setCurrentCameras(selectedCameras); updateCameraFilter={setSelectedCameras}
}
setOpen(open ? "camera" : "none");
}}
>
<Trigger asChild>
<Button size="sm" className="mx-1 capitalize">
<FaVideo className="md:mr-[10px] text-secondary-foreground" />
<div className="hidden md:block text-primary">
{selectedCameras == undefined
? "All Cameras"
: `${selectedCameras.length} Cameras`}
</div>
</Button>
</Trigger>
<Content className={isMobile ? "max-h-[75dvh]" : ""}>
<DropdownMenuLabel className="flex justify-center">
Filter Cameras
</DropdownMenuLabel>
<DropdownMenuSeparator />
<FilterCheckBox
isChecked={currentCameras == undefined}
label="All Cameras"
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
}
}}
/> />
<DropdownMenuSeparator />
<div className={isMobile ? "h-auto overflow-y-auto" : ""}>
{allCameras.map((item) => (
<FilterCheckBox
key={item}
isChecked={currentCameras?.includes(item) ?? false}
label={item.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedCameras = currentCameras
? [...currentCameras]
: [];
updatedCameras.push(item);
setCurrentCameras(updatedCameras);
} else {
const updatedCameras = currentCameras
? [...currentCameras]
: [];
// can not deselect the last item
if (updatedCameras.length > 1) {
updatedCameras.splice(updatedCameras.indexOf(item), 1);
setCurrentCameras(updatedCameras);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex justify-center items-center">
<Button
variant="select"
onClick={() => {
setSelectedCameras(currentCameras);
setOpen("none");
}}
>
Apply
</Button>
</div>
</Content>
</Menu>
<Menu <Menu
open={open == "label"} open={open == "label"}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -318,8 +278,14 @@ function PlusFilterGroup({
}} }}
> >
<Trigger asChild> <Trigger asChild>
<Button size="sm" className="mx-1 capitalize"> <Button
<FaList className="md:mr-[10px] text-secondary-foreground" /> className="flex items-center gap-2 capitalize"
size="sm"
variant={selectedLabels == undefined ? "default" : "select"}
>
<FaList
className={`${selectedLabels == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
/>
<div className="hidden md:block text-primary"> <div className="hidden md:block text-primary">
{selectedLabels == undefined {selectedLabels == undefined
? "All Labels" ? "All Labels"
@ -328,60 +294,250 @@ function PlusFilterGroup({
</Button> </Button>
</Trigger> </Trigger>
<Content className={isMobile ? "max-h-[75dvh]" : ""}> <Content className={isMobile ? "max-h-[75dvh]" : ""}>
<DropdownMenuLabel className="flex justify-center"> <GeneralFilterContent
Filter Labels allLabels={allLabels}
</DropdownMenuLabel> selectedLabels={selectedLabels}
<DropdownMenuSeparator /> currentLabels={currentLabels}
<FilterCheckBox setCurrentLabels={setCurrentLabels}
isChecked={currentLabels == undefined} updateLabelFilter={setSelectedLabels}
label="All Labels" onClose={() => setOpen("none")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentLabels(undefined);
}
}}
/> />
<DropdownMenuSeparator /> </Content>
<div className={isMobile ? "h-auto overflow-y-auto" : ""}> </Menu>
{allLabels.map((item) => ( <Menu
<FilterCheckBox open={open == "score"}
key={item} onOpenChange={(open) => {
isChecked={currentLabels?.includes(item) ?? false} setOpen(open ? "score" : "none");
label={item.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
updatedLabels.push(item);
setCurrentLabels(updatedLabels);
} else {
const updatedLabels = currentLabels
? [...currentLabels]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentLabels(updatedLabels);
}
}
}} }}
>
<Trigger asChild>
<Button
className="flex items-center gap-2 capitalize"
size="sm"
variant={selectedScoreRange == undefined ? "default" : "select"}
>
<PiSlidersHorizontalFill
className={`${selectedScoreRange == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
/>
<div className="hidden md:block text-primary">
{selectedScoreRange == undefined
? "Score Range"
: `${selectedScoreRange[0] * 100}% - ${selectedScoreRange[1] * 100}%`}
</div>
</Button>
</Trigger>
<Content
className={`min-w-80 p-2 flex flex-col justify-center ${isMobile ? "gap-2 *:max-h-[75dvh]" : ""}`}
>
<div className="flex items-center gap-1">
<Input
className="w-12"
inputMode="numeric"
value={Math.round((currentScoreRange?.at(0) ?? 0.5) * 100)}
onChange={(e) =>
setCurrentScoreRange([
parseInt(e.target.value) / 100.0,
currentScoreRange?.at(1) ?? 1.0,
])
}
/>
<DualThumbSlider
className="w-full"
min={0.5}
max={1.0}
step={0.01}
value={currentScoreRange ?? [0.5, 1.0]}
onValueChange={setCurrentScoreRange}
/>
<Input
className="w-12"
inputMode="numeric"
value={Math.round((currentScoreRange?.at(1) ?? 1.0) * 100)}
onChange={(e) =>
setCurrentScoreRange([
currentScoreRange?.at(0) ?? 0.5,
parseInt(e.target.value) / 100.0,
])
}
/> />
))}
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex justify-center items-center"> <div className="p-2 flex justify-evenly items-center">
<Button <Button
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedLabels(currentLabels); setSelectedScoreRange(currentScoreRange);
setOpen("none"); setOpen("none");
}} }}
> >
Apply Apply
</Button> </Button>
<Button
onClick={() => {
setCurrentScoreRange(undefined);
setSelectedScoreRange(undefined);
}}
>
Reset
</Button>
</div>
</Content>
</Menu>
</div>
);
}
type PlusSortSelectorProps = {
selectedSort?: string;
setSelectedSort: (sort: string | undefined) => void;
};
function PlusSortSelector({
selectedSort,
setSelectedSort,
}: PlusSortSelectorProps) {
// menu state
const [open, setOpen] = useState(false);
// sort
const [currentSort, setCurrentSort] = useState<string>();
const [currentDir, setCurrentDir] = useState<string>("desc");
// components
const Sort = selectedSort
? selectedSort.split("_")[1] == "desc"
? FaSortAmountDown
: FaSortAmountUp
: FaSort;
const Menu = isMobile ? Drawer : DropdownMenu;
const Trigger = isMobile ? DrawerTrigger : DropdownMenuTrigger;
const Content = isMobile ? DrawerContent : DropdownMenuContent;
return (
<div className="h-full flex justify-start gap-2 items-center">
<Menu
open={open}
onOpenChange={(open) => {
setOpen(open);
if (!open) {
const parts = selectedSort?.split("_");
if (parts?.length == 2) {
setCurrentSort(parts[0]);
setCurrentDir(parts[1]);
}
}
}}
>
<Trigger asChild>
<Button
className="flex items-center gap-2 capitalize"
size="sm"
variant={selectedSort == undefined ? "default" : "select"}
>
<Sort
className={`${selectedSort == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
/>
<div className="hidden md:block text-primary">
{selectedSort == undefined ? "Sort" : selectedSort.split("_")[0]}
</div>
</Button>
</Trigger>
<Content
className={`p-2 flex flex-col justify-center gap-2 ${isMobile ? "max-h-[75dvh]" : ""}`}
>
<RadioGroup
className={`flex flex-col gap-4 ${isMobile ? "mt-4" : ""}`}
onValueChange={(value) => setCurrentSort(value)}
>
<div className="w-full flex items-center gap-2">
<RadioGroupItem
className={
currentSort == "date"
? "from-selected/50 to-selected/90 text-selected bg-selected"
: "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
}
id="date"
value="date"
/>
<Label
className="w-full cursor-pointer capitalize"
htmlFor="date"
>
Date
</Label>
{currentSort == "date" ? (
currentDir == "desc" ? (
<FaSortAmountDown
className="size-5 cursor-pointer"
onClick={() => setCurrentDir("asc")}
/>
) : (
<FaSortAmountUp
className="size-5 cursor-pointer"
onClick={() => setCurrentDir("desc")}
/>
)
) : (
<div className="size-5" />
)}
</div>
<div className="w-full flex items-center gap-2">
<RadioGroupItem
className={
currentSort == "score"
? "from-selected/50 to-selected/90 text-selected bg-selected"
: "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
}
id="score"
value="score"
/>
<Label
className="w-full cursor-pointer capitalize"
htmlFor="score"
>
Score
</Label>
{currentSort == "score" ? (
currentDir == "desc" ? (
<FaSortAmountDown
className="size-5 cursor-pointer"
onClick={() => setCurrentDir("asc")}
/>
) : (
<FaSortAmountUp
className="size-5 cursor-pointer"
onClick={() => setCurrentDir("desc")}
/>
)
) : (
<div className="size-5" />
)}
</div>
</RadioGroup>
<DropdownMenuSeparator />
<div className="p-2 flex justify-evenly items-center">
<Button
variant="select"
onClick={() => {
setSelectedSort(`${currentSort}_${currentDir}`);
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setCurrentSort(undefined);
setCurrentDir("desc");
setSelectedSort(undefined);
}}
>
Reset
</Button>
</div> </div>
</Content> </Content>
</Menu> </Menu>

View File

@ -364,11 +364,11 @@ export function RecordingView({
> >
<div <div
key={mainCamera} key={mainCamera}
className={ className={`relative ${
isDesktop isDesktop
? `${mainCameraAspect == "tall" ? "xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center` ? `${mainCameraAspect == "tall" ? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center`
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}` : `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}`
} }`}
style={{ style={{
aspectRatio: isDesktop aspectRatio: isDesktop
? mainCameraAspect == "tall" ? mainCameraAspect == "tall"

View File

@ -182,7 +182,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ x: statsIdx + 1, y: stats.gpu }); series[key].data.push({ x: statsIdx + 1, y: stats.gpu.slice(0, -1) });
}); });
}); });
return Object.keys(series).length > 0 ? Object.values(series) : []; return Object.keys(series).length > 0 ? Object.values(series) : [];
@ -215,7 +215,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ x: statsIdx + 1, y: stats.mem }); series[key].data.push({ x: statsIdx + 1, y: stats.mem.slice(0, -1) });
}); });
}); });
return Object.values(series); return Object.values(series);
@ -373,7 +373,7 @@ export default function GeneralMetrics({
key={series.name} key={series.name}
graphId={`${series.name}-gpu`} graphId={`${series.name}-gpu`}
name={series.name} name={series.name}
unit="" unit="%"
threshold={GPUUsageThreshold} threshold={GPUUsageThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
@ -392,7 +392,7 @@ export default function GeneralMetrics({
<ThresholdBarGraph <ThresholdBarGraph
key={series.name} key={series.name}
graphId={`${series.name}-mem`} graphId={`${series.name}-mem`}
unit="" unit="%"
name={series.name} name={series.name}
threshold={GPUMemThreshold} threshold={GPUMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}