mirror of
https://github.com/ajouatom/openpilot.git
synced 2026-06-08 11:04:57 +08:00
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"crcmod-plus", # cars + qcomgpsd
|
||||
"tqdm", # cars (fw_versions.py) on start + many one-off uses
|
||||
"msgpack", # realtime broker / carrot live payload transport
|
||||
"brotli", # carrot QR backup compression
|
||||
|
||||
# core
|
||||
"cffi",
|
||||
|
||||
@@ -28,6 +28,7 @@ server/
|
||||
│ ├── ssh_keys.py GitHub SSH key fetch/store helpers for Device developer panel
|
||||
│ ├── time_sync.py browser → system time sync
|
||||
│ ├── device_info.py focused calibration + network helpers for Device tab
|
||||
│ ├── setting_favorites.py CarrotPilot setting favorites state
|
||||
│ ├── web_settings.py device/server-backed Web Settings state
|
||||
│ └── tmux.py tmux session helpers
|
||||
└── features/ HTTP entry points (one feature per file/folder)
|
||||
@@ -36,6 +37,7 @@ server/
|
||||
├── ws.py /ws/raw, /ws/raw_multiplex, /ws/camera
|
||||
├── settings.py /api/settings
|
||||
├── params.py /api/params_*, /download/params_backup.json
|
||||
├── setting_favorites.py /api/setting_favorites
|
||||
├── web_settings.py /api/web_settings
|
||||
├── ssh_keys.py /api/ssh_keys
|
||||
├── cars.py /api/cars
|
||||
|
||||
@@ -21,6 +21,7 @@ CARROT_STATE_DIR = os.path.join(CARROT_DATA_DIR, "state")
|
||||
CARROT_GIT_STATE_PATH = os.path.join(CARROT_STATE_DIR, "git.json")
|
||||
CARROT_TOOL_JOBS_STATE_PATH = os.path.join(CARROT_STATE_DIR, "tool_jobs.json")
|
||||
CARROT_WEB_SETTINGS_PATH = os.path.join(CARROT_STATE_DIR, "web_settings.json")
|
||||
CARROT_SETTING_FAVORITES_PATH = os.path.join(CARROT_STATE_DIR, "setting_favorites.json")
|
||||
|
||||
# Dashcam
|
||||
DASHCAM_ROOT = "/data/media/0/realdata"
|
||||
|
||||
@@ -6,6 +6,7 @@ from . import (
|
||||
params,
|
||||
screenrecord,
|
||||
settings,
|
||||
setting_favorites,
|
||||
ssh_keys,
|
||||
static,
|
||||
stream,
|
||||
@@ -23,6 +24,7 @@ def register_all(app: web.Application) -> None:
|
||||
ws.register(app)
|
||||
settings.register(app)
|
||||
params.register(app)
|
||||
setting_favorites.register(app)
|
||||
web_settings.register(app)
|
||||
ssh_keys.register(app)
|
||||
cars.register(app)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -16,6 +18,10 @@ from .paths import (
|
||||
segment_index,
|
||||
)
|
||||
|
||||
ROUTE_CACHE_TTL = 3.0
|
||||
_route_cache_lock = threading.Lock()
|
||||
_route_cache = {"time": 0.0, "routes": []}
|
||||
|
||||
|
||||
async def request_upload_segments(request: web.Request) -> list[str]:
|
||||
try:
|
||||
@@ -32,10 +38,36 @@ async def request_upload_segments(request: web.Request) -> list[str]:
|
||||
return segments
|
||||
|
||||
|
||||
def cached_dashcam_routes() -> list[dict]:
|
||||
now = time.monotonic()
|
||||
with _route_cache_lock:
|
||||
if now - float(_route_cache.get("time") or 0.0) < ROUTE_CACHE_TTL:
|
||||
return list(_route_cache.get("routes") or [])
|
||||
|
||||
routes = build_routes()
|
||||
with _route_cache_lock:
|
||||
_route_cache["time"] = time.monotonic()
|
||||
_route_cache["routes"] = routes
|
||||
return list(routes)
|
||||
|
||||
|
||||
async def api_dashcam_routes(request: web.Request) -> web.Response:
|
||||
try:
|
||||
routes = await asyncio.to_thread(build_routes)
|
||||
return web.json_response({"ok": True, "routes": routes, "root": DASHCAM_ROOT})
|
||||
offset = max(0, int(request.query.get("offset", "0") or 0))
|
||||
limit = max(1, min(200, int(request.query.get("limit", "80") or 80)))
|
||||
routes = await asyncio.to_thread(cached_dashcam_routes)
|
||||
total = len(routes)
|
||||
end = min(offset + limit, total)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"routes": routes[offset:end],
|
||||
"root": DASHCAM_ROOT,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"nextOffset": end if end < total else None,
|
||||
"hasMore": end < total,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
@@ -55,13 +87,14 @@ async def api_dashcam_preview(request: web.Request) -> web.StreamResponse:
|
||||
async def api_dashcam_video(request: web.Request) -> web.StreamResponse:
|
||||
segment = request.match_info.get("segment", "")
|
||||
path, content_type = await asyncio.to_thread(browser_video, segment)
|
||||
return web.FileResponse(
|
||||
path,
|
||||
headers={
|
||||
"Content-Type": content_type,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
}
|
||||
if request.query.get("download"):
|
||||
ext = os.path.splitext(path)[1] or ".mp4"
|
||||
headers["Content-Disposition"] = f'attachment; filename="{segment}{ext}"'
|
||||
return web.FileResponse(path, headers=headers)
|
||||
|
||||
|
||||
async def api_dashcam_download(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -7,8 +8,14 @@ from ..config import PARAMS_BACKUP_PATH
|
||||
from ..services.params import (
|
||||
HAS_PARAMS,
|
||||
ParamKeyType,
|
||||
build_params_qr_payload,
|
||||
clamp_numeric,
|
||||
ensure_qr_dependency,
|
||||
get_param_values,
|
||||
get_qr_dependency_status,
|
||||
parse_params_qr_payload,
|
||||
preview_param_restore_values,
|
||||
restore_param_values_validated,
|
||||
restore_param_values_from_backup,
|
||||
set_param_value,
|
||||
)
|
||||
@@ -122,8 +129,78 @@ async def api_params_restore(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_params_qr_backup(request: web.Request) -> web.Response:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
return web.json_response({"ok": False, "error": "Params/ParamKeyType not available"}, status=500)
|
||||
|
||||
try:
|
||||
payload = build_params_qr_payload()
|
||||
return web.json_response({"ok": True, **payload}, headers={"Cache-Control": "no-store"})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_params_qr_dependency(request: web.Request) -> web.Response:
|
||||
try:
|
||||
return web.json_response(get_qr_dependency_status())
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_params_qr_dependency_ensure(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(ensure_qr_dependency)
|
||||
status = 200 if result.get("ok") else 500
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def api_params_restore_preview(request: web.Request) -> web.Response:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
return web.json_response({"ok": False, "error": "Params/ParamKeyType not available"}, status=500)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
payload = body.get("payload")
|
||||
values = body.get("values")
|
||||
selected_keys = body.get("keys")
|
||||
restore_values = parse_params_qr_payload(values if isinstance(values, dict) else payload)
|
||||
preview = preview_param_restore_values(
|
||||
restore_values,
|
||||
selected_keys if isinstance(selected_keys, list) else None,
|
||||
)
|
||||
return web.json_response({"ok": True, "preview": preview})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
async def api_params_restore_json(request: web.Request) -> web.Response:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
return web.json_response({"ok": False, "error": "Params/ParamKeyType not available"}, status=500)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
payload = body.get("payload")
|
||||
values = body.get("values")
|
||||
selected_keys = body.get("keys")
|
||||
restore_values = parse_params_qr_payload(values if isinstance(values, dict) else payload)
|
||||
restored = restore_param_values_validated(
|
||||
restore_values,
|
||||
selected_keys if isinstance(selected_keys, list) else None,
|
||||
)
|
||||
return web.json_response({"ok": True, **restored})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
def register(app: web.Application) -> None:
|
||||
app.router.add_get("/api/params_bulk", api_params_bulk)
|
||||
app.router.add_post("/api/param_set", api_param_set)
|
||||
app.router.add_post("/api/params_restore", api_params_restore)
|
||||
app.router.add_get("/api/params_qr_dependency", api_params_qr_dependency)
|
||||
app.router.add_post("/api/params_qr_dependency/ensure", api_params_qr_dependency_ensure)
|
||||
app.router.add_get("/api/params_qr_backup", api_params_qr_backup)
|
||||
app.router.add_post("/api/params_restore_preview", api_params_restore_preview)
|
||||
app.router.add_post("/api/params_restore_json", api_params_restore_json)
|
||||
app.router.add_get("/download/params_backup.json", handle_download_params_backup)
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import SCREEN_RECORDING_DIRS
|
||||
from .catalog import build_videos, find_file, thumbnail_path
|
||||
|
||||
VIDEO_CACHE_TTL = 3.0
|
||||
_video_cache_lock = threading.Lock()
|
||||
_video_cache = {"time": 0.0, "videos": []}
|
||||
|
||||
|
||||
def cached_screenrecord_videos() -> list[dict]:
|
||||
now = time.monotonic()
|
||||
with _video_cache_lock:
|
||||
if now - float(_video_cache.get("time") or 0.0) < VIDEO_CACHE_TTL:
|
||||
return list(_video_cache.get("videos") or [])
|
||||
|
||||
videos = build_videos()
|
||||
with _video_cache_lock:
|
||||
_video_cache["time"] = time.monotonic()
|
||||
_video_cache["videos"] = videos
|
||||
return list(videos)
|
||||
|
||||
|
||||
async def api_screenrecord_videos(request: web.Request) -> web.Response:
|
||||
try:
|
||||
videos = await asyncio.to_thread(build_videos)
|
||||
offset = max(0, int(request.query.get("offset", "0") or 0))
|
||||
limit = max(1, min(200, int(request.query.get("limit", "80") or 80)))
|
||||
videos = await asyncio.to_thread(cached_screenrecord_videos)
|
||||
total = len(videos)
|
||||
end = min(offset + limit, total)
|
||||
folders = [folder for folder in SCREEN_RECORDING_DIRS if os.path.isdir(folder)]
|
||||
return web.json_response({"ok": True, "videos": videos, "folders": folders})
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"videos": videos[offset:end],
|
||||
"folders": folders,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"nextOffset": end if end < total else None,
|
||||
"hasMore": end < total,
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
@@ -27,13 +59,15 @@ async def api_screenrecord_video(request: web.Request) -> web.StreamResponse:
|
||||
file_id_in = request.match_info.get("file_id", "")
|
||||
path = await asyncio.to_thread(find_file, file_id_in)
|
||||
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
return web.FileResponse(
|
||||
path,
|
||||
headers={
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
}
|
||||
if request.query.get("download"):
|
||||
filename = os.path.basename(path)
|
||||
safe_filename = "".join(ch if 32 <= ord(ch) < 127 and ch not in {'"', "\\"} else "_" for ch in filename)
|
||||
headers["Content-Disposition"] = f'attachment; filename="{safe_filename or "screenrecord"}"'
|
||||
return web.FileResponse(path, headers=headers)
|
||||
|
||||
|
||||
async def api_screenrecord_download(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
23
selfdrive/carrot/server/features/setting_favorites.py
Normal file
23
selfdrive/carrot/server/features/setting_favorites.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.setting_favorites import read_setting_favorites, update_setting_favorites
|
||||
|
||||
|
||||
async def get_setting_favorites(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, **read_setting_favorites()})
|
||||
|
||||
|
||||
async def set_setting_favorites(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"ok": False, "error": "bad request"}, status=400)
|
||||
settings = update_setting_favorites(body)
|
||||
return web.json_response({"ok": True, **settings})
|
||||
|
||||
|
||||
def register(app: web.Application) -> None:
|
||||
app.router.add_get("/api/setting_favorites", get_setting_favorites)
|
||||
app.router.add_post("/api/setting_favorites", set_setting_favorites)
|
||||
@@ -1,5 +1,18 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import brotli
|
||||
except Exception:
|
||||
brotli = None
|
||||
|
||||
|
||||
# -----------------------
|
||||
@@ -28,6 +41,24 @@ if HAS_PARAMS:
|
||||
# In-memory fallback store when Params is unavailable
|
||||
_mem_store: Dict[str, str] = {}
|
||||
|
||||
QR_BACKUP_PREFIX_V1 = "CQR1"
|
||||
QR_BACKUP_PREFIX_V2 = "CQR2"
|
||||
QR_BACKUP_PREFIX_V3 = "CQR3"
|
||||
QR_BACKUP_PREFIX_V4 = "CQR4"
|
||||
QR_BACKUP_PREFIX = QR_BACKUP_PREFIX_V4
|
||||
QR_BACKUP_TYPE = "params_backup"
|
||||
QR_BACKUP_VERSION = 4
|
||||
QR_BACKUP_ECC = "L"
|
||||
QR_BACKUP_CODE_BYTES = 3
|
||||
QR_BACKUP_CODE_BYTES_V3 = 2
|
||||
QR_BACKUP_CHECKSUM_CHARS = 12
|
||||
QR_BACKUP_SCHEMA_BYTES = 4
|
||||
QR_BACKUP_BASE45_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
||||
QR_BACKUP_DEPENDENCY = "brotli"
|
||||
QR_BACKUP_PYDEPS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "pydeps"))
|
||||
QR_BACKUP_WHEEL_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "third_party", "wheels"))
|
||||
_qr_dependency_lock = threading.Lock()
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Type inference / clamp
|
||||
@@ -252,6 +283,55 @@ def get_all_param_values_for_backup() -> Dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def _backup_param_names() -> List[str]:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
raise RuntimeError("Params/ParamKeyType not available")
|
||||
|
||||
params = Params()
|
||||
names: List[str] = []
|
||||
|
||||
for k in params.all_keys():
|
||||
if isinstance(k, (bytes, bytearray, memoryview)):
|
||||
try:
|
||||
key = k.decode("utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
key = str(k)
|
||||
|
||||
try:
|
||||
t = params.get_type(key)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if t in (ParamKeyType.BYTES, ParamKeyType.JSON):
|
||||
continue
|
||||
|
||||
try:
|
||||
if params.get_default_value(key) is None:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
names.append(key)
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def _backup_param_type_map(names: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
return {}
|
||||
|
||||
params = Params()
|
||||
type_map: Dict[str, Any] = {}
|
||||
for key in names or _backup_param_names():
|
||||
try:
|
||||
type_map[key] = params.get_type(key)
|
||||
except Exception:
|
||||
continue
|
||||
return type_map
|
||||
|
||||
|
||||
def restore_param_values_from_backup(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
raise RuntimeError("Params/ParamKeyType not available")
|
||||
@@ -292,3 +372,745 @@ def restore_param_values_from_backup(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
fails.append({"key": key, "err": str(e)})
|
||||
|
||||
return {"ok_cnt": ok_cnt, "fail_cnt": fail_cnt, "fails": fails[:30]}
|
||||
|
||||
|
||||
# -----------------------
|
||||
# QR backup / restore
|
||||
# -----------------------
|
||||
def _ensure_pydeps_on_path() -> None:
|
||||
if QR_BACKUP_PYDEPS_DIR not in sys.path:
|
||||
sys.path.insert(0, QR_BACKUP_PYDEPS_DIR)
|
||||
|
||||
|
||||
def _load_brotli_module() -> Any:
|
||||
global brotli
|
||||
if brotli is not None:
|
||||
return brotli
|
||||
_ensure_pydeps_on_path()
|
||||
brotli = importlib.import_module(QR_BACKUP_DEPENDENCY)
|
||||
return brotli
|
||||
|
||||
|
||||
def get_qr_dependency_status() -> Dict[str, Any]:
|
||||
try:
|
||||
module = _load_brotli_module()
|
||||
return {
|
||||
"ok": True,
|
||||
"installed": True,
|
||||
"dependency": QR_BACKUP_DEPENDENCY,
|
||||
"format": QR_BACKUP_PREFIX_V3,
|
||||
"module_path": getattr(module, "__file__", ""),
|
||||
"target": QR_BACKUP_PYDEPS_DIR,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": True,
|
||||
"installed": False,
|
||||
"dependency": QR_BACKUP_DEPENDENCY,
|
||||
"format": QR_BACKUP_PREFIX_V4,
|
||||
"module_path": "",
|
||||
"target": QR_BACKUP_PYDEPS_DIR,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def ensure_qr_dependency() -> Dict[str, Any]:
|
||||
status = get_qr_dependency_status()
|
||||
if status.get("installed"):
|
||||
return {**status, "configured": False, "message": "already installed"}
|
||||
|
||||
with _qr_dependency_lock:
|
||||
status = get_qr_dependency_status()
|
||||
if status.get("installed"):
|
||||
return {**status, "configured": False, "message": "already installed"}
|
||||
|
||||
os.makedirs(QR_BACKUP_PYDEPS_DIR, exist_ok=True)
|
||||
_ensure_pydeps_on_path()
|
||||
cmd = [
|
||||
sys.executable or "python3",
|
||||
"-m", "pip", "install",
|
||||
"--target", QR_BACKUP_PYDEPS_DIR,
|
||||
"--upgrade",
|
||||
QR_BACKUP_DEPENDENCY,
|
||||
]
|
||||
|
||||
wheel_files = []
|
||||
try:
|
||||
wheel_files = [
|
||||
name for name in os.listdir(QR_BACKUP_WHEEL_DIR)
|
||||
if name.lower().startswith("brotli-") and name.endswith(".whl")
|
||||
]
|
||||
except Exception:
|
||||
wheel_files = []
|
||||
|
||||
if wheel_files:
|
||||
cmd = [
|
||||
sys.executable or "python3",
|
||||
"-m", "pip", "install",
|
||||
"--no-index",
|
||||
"--find-links", QR_BACKUP_WHEEL_DIR,
|
||||
"--target", QR_BACKUP_PYDEPS_DIR,
|
||||
"--upgrade",
|
||||
QR_BACKUP_DEPENDENCY,
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = QR_BACKUP_PYDEPS_DIR + os.pathsep + env.get("PYTHONPATH", "")
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180, env=env, check=False)
|
||||
if proc.returncode != 0 and wheel_files:
|
||||
fallback_cmd = [
|
||||
sys.executable or "python3",
|
||||
"-m", "pip", "install",
|
||||
"--target", QR_BACKUP_PYDEPS_DIR,
|
||||
"--upgrade",
|
||||
QR_BACKUP_DEPENDENCY,
|
||||
]
|
||||
proc = subprocess.run(fallback_cmd, capture_output=True, text=True, timeout=180, env=env, check=False)
|
||||
|
||||
if proc.returncode != 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"installed": False,
|
||||
"configured": False,
|
||||
"dependency": QR_BACKUP_DEPENDENCY,
|
||||
"format": QR_BACKUP_PREFIX_V4,
|
||||
"target": QR_BACKUP_PYDEPS_DIR,
|
||||
"error": (proc.stderr or proc.stdout or "pip install failed")[-1200:],
|
||||
}
|
||||
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
module = _load_brotli_module()
|
||||
return {
|
||||
"ok": True,
|
||||
"installed": True,
|
||||
"configured": True,
|
||||
"dependency": QR_BACKUP_DEPENDENCY,
|
||||
"format": QR_BACKUP_PREFIX_V3,
|
||||
"module_path": getattr(module, "__file__", ""),
|
||||
"target": QR_BACKUP_PYDEPS_DIR,
|
||||
"message": "installed",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"installed": False,
|
||||
"configured": False,
|
||||
"dependency": QR_BACKUP_DEPENDENCY,
|
||||
"format": QR_BACKUP_PREFIX_V4,
|
||||
"target": QR_BACKUP_PYDEPS_DIR,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + ("=" * (-len(text) % 4)))
|
||||
|
||||
|
||||
def _base45_encode(data: bytes) -> str:
|
||||
chars = QR_BACKUP_BASE45_ALPHABET
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(data):
|
||||
if i + 1 < len(data):
|
||||
n = (data[i] << 8) + data[i + 1]
|
||||
out.append(chars[n % 45])
|
||||
out.append(chars[(n // 45) % 45])
|
||||
out.append(chars[n // (45 * 45)])
|
||||
i += 2
|
||||
else:
|
||||
n = data[i]
|
||||
out.append(chars[n % 45])
|
||||
out.append(chars[n // 45])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _base45_decode(text: str) -> bytes:
|
||||
chars = QR_BACKUP_BASE45_ALPHABET
|
||||
values = {ch: i for i, ch in enumerate(chars)}
|
||||
out = bytearray()
|
||||
i = 0
|
||||
while i < len(text):
|
||||
remaining = len(text) - i
|
||||
if remaining == 1:
|
||||
raise ValueError("bad base45 payload")
|
||||
if remaining >= 3:
|
||||
try:
|
||||
n = values[text[i]] + values[text[i + 1]] * 45 + values[text[i + 2]] * 45 * 45
|
||||
except KeyError:
|
||||
raise ValueError("bad base45 payload")
|
||||
if n > 0xFFFF:
|
||||
raise ValueError("bad base45 payload")
|
||||
out.append(n >> 8)
|
||||
out.append(n & 0xFF)
|
||||
i += 3
|
||||
else:
|
||||
try:
|
||||
n = values[text[i]] + values[text[i + 1]] * 45
|
||||
except KeyError:
|
||||
raise ValueError("bad base45 payload")
|
||||
if n > 0xFF:
|
||||
raise ValueError("bad base45 payload")
|
||||
out.append(n)
|
||||
i += 2
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _write_varint(value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise ValueError("negative varint")
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _read_varint(data: bytes, pos: int) -> tuple[int, int]:
|
||||
shift = 0
|
||||
value = 0
|
||||
while True:
|
||||
if pos >= len(data) or shift > 63:
|
||||
raise ValueError("bad varint")
|
||||
b = data[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if not (b & 0x80):
|
||||
return value, pos
|
||||
shift += 7
|
||||
|
||||
|
||||
def _zigzag_encode(value: int) -> int:
|
||||
return (value << 1) ^ (value >> 63)
|
||||
|
||||
|
||||
def _zigzag_decode(value: int) -> int:
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
|
||||
def _param_short_code(key: str) -> str:
|
||||
digest = hashlib.sha256(key.encode("utf-8")).digest()[:QR_BACKUP_CODE_BYTES]
|
||||
return _b64url_encode(digest)
|
||||
|
||||
|
||||
def _param_short_code_bytes(key: str, size: int = QR_BACKUP_CODE_BYTES_V3) -> bytes:
|
||||
return hashlib.sha256(key.encode("utf-8")).digest()[:size]
|
||||
|
||||
|
||||
def _build_param_code_maps(names: List[str]) -> tuple[Dict[str, str], Dict[str, str]]:
|
||||
buckets: Dict[str, List[str]] = {}
|
||||
for name in sorted(set(str(n) for n in names)):
|
||||
buckets.setdefault(_param_short_code(name), []).append(name)
|
||||
|
||||
code_to_key = {
|
||||
code: keys[0]
|
||||
for code, keys in buckets.items()
|
||||
if len(keys) == 1
|
||||
}
|
||||
key_to_code = {key: code for code, key in code_to_key.items()}
|
||||
return key_to_code, code_to_key
|
||||
|
||||
|
||||
def _build_param_code_maps_bytes(names: List[str], size: int = QR_BACKUP_CODE_BYTES_V3) -> tuple[Dict[str, bytes], Dict[bytes, str]]:
|
||||
buckets: Dict[bytes, List[str]] = {}
|
||||
for name in sorted(set(str(n) for n in names)):
|
||||
buckets.setdefault(_param_short_code_bytes(name, size), []).append(name)
|
||||
|
||||
code_to_key = {
|
||||
code: keys[0]
|
||||
for code, keys in buckets.items()
|
||||
if len(keys) == 1
|
||||
}
|
||||
key_to_code = {key: code for code, key in code_to_key.items()}
|
||||
return key_to_code, code_to_key
|
||||
|
||||
|
||||
def _schema_fingerprint(names: List[str], size: int = QR_BACKUP_CODE_BYTES_V3) -> bytes:
|
||||
encoded_names = "\n".join(sorted(set(str(n) for n in names))).encode("utf-8")
|
||||
return hashlib.sha256(bytes([size]) + encoded_names).digest()[:QR_BACKUP_SCHEMA_BYTES]
|
||||
|
||||
|
||||
def _is_int_string(value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
if value == "0":
|
||||
return True
|
||||
if value.startswith("-"):
|
||||
body = value[1:]
|
||||
return bool(body) and body.isdigit() and not (len(body) > 1 and body.startswith("0"))
|
||||
return value.isdigit() and not value.startswith("0")
|
||||
|
||||
|
||||
def _encode_qr_value(value: Any, param_type: Any = None) -> bytes:
|
||||
if ParamKeyType is not None and param_type == ParamKeyType.BOOL:
|
||||
bool_value = value in ("1", "true", "True", "on", "yes") if isinstance(value, str) else bool(value)
|
||||
return b"\x02" if bool_value else b"\x01"
|
||||
|
||||
text = str(value)
|
||||
if text == "":
|
||||
return b"\x00"
|
||||
if text == "0":
|
||||
return b"\x01"
|
||||
if text == "1":
|
||||
return b"\x02"
|
||||
|
||||
if ParamKeyType is not None and param_type == ParamKeyType.INT:
|
||||
try:
|
||||
return b"\x03" + _write_varint(_zigzag_encode(int(float(value))))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if param_type is None and _is_int_string(text):
|
||||
return b"\x03" + _write_varint(_zigzag_encode(int(text)))
|
||||
|
||||
raw = text.encode("utf-8")
|
||||
return b"\x04" + _write_varint(len(raw)) + raw
|
||||
|
||||
|
||||
def _decode_qr_value(data: bytes, pos: int) -> tuple[str, int]:
|
||||
if pos >= len(data):
|
||||
raise ValueError("bad QR backup value")
|
||||
tag = data[pos]
|
||||
pos += 1
|
||||
if tag == 0:
|
||||
return "", pos
|
||||
if tag == 1:
|
||||
return "0", pos
|
||||
if tag == 2:
|
||||
return "1", pos
|
||||
if tag == 3:
|
||||
value, pos = _read_varint(data, pos)
|
||||
return str(_zigzag_decode(value)), pos
|
||||
if tag == 4:
|
||||
size, pos = _read_varint(data, pos)
|
||||
end = pos + size
|
||||
if end > len(data):
|
||||
raise ValueError("bad QR backup string")
|
||||
return data[pos:end].decode("utf-8"), end
|
||||
raise ValueError("unsupported QR backup value")
|
||||
|
||||
|
||||
def _build_params_qr_payload_v2(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
code_names = _backup_param_names()
|
||||
except Exception:
|
||||
code_names = list(values.keys())
|
||||
key_to_code, _ = _build_param_code_maps(code_names)
|
||||
pairs = []
|
||||
fallback: Dict[str, Any] = {}
|
||||
for key in sorted(values.keys()):
|
||||
key_text = str(key)
|
||||
code = key_to_code.get(key_text)
|
||||
if code:
|
||||
pairs.append([code, values[key]])
|
||||
else:
|
||||
fallback[key_text] = values[key]
|
||||
|
||||
envelope: List[Any] = [2, pairs]
|
||||
if fallback:
|
||||
envelope.append(fallback)
|
||||
raw = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
compressed = zlib.compress(raw, 9)
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS]
|
||||
payload = f"{QR_BACKUP_PREFIX_V2}.{_b64url_encode(compressed)}.{checksum}"
|
||||
return {
|
||||
"payload": payload,
|
||||
"format": QR_BACKUP_PREFIX_V2,
|
||||
"count": len(values),
|
||||
"json_bytes": len(raw),
|
||||
"compressed_bytes": len(compressed),
|
||||
"payload_chars": len(payload),
|
||||
"ecc": QR_BACKUP_ECC,
|
||||
"version": 2,
|
||||
"checksum": checksum,
|
||||
}
|
||||
|
||||
|
||||
def _build_params_qr_binary(values: Dict[str, Any], version: int) -> bytes:
|
||||
try:
|
||||
code_names = _backup_param_names()
|
||||
except Exception:
|
||||
code_names = list(values.keys())
|
||||
|
||||
type_map = _backup_param_type_map(code_names)
|
||||
key_to_code, _ = _build_param_code_maps_bytes(code_names)
|
||||
raw = bytearray()
|
||||
pairs = []
|
||||
fallback = []
|
||||
|
||||
for key in sorted(values.keys()):
|
||||
key_text = str(key)
|
||||
encoded_value = _encode_qr_value(values[key], type_map.get(key_text))
|
||||
code = key_to_code.get(key_text)
|
||||
if code:
|
||||
pairs.append((code, encoded_value))
|
||||
else:
|
||||
fallback.append((key_text.encode("utf-8"), encoded_value))
|
||||
|
||||
raw.append(version)
|
||||
raw.append(QR_BACKUP_CODE_BYTES_V3)
|
||||
raw.extend(_schema_fingerprint(code_names))
|
||||
raw.extend(_write_varint(len(pairs)))
|
||||
for code, encoded_value in pairs:
|
||||
raw.extend(code)
|
||||
raw.extend(encoded_value)
|
||||
|
||||
raw.extend(_write_varint(len(fallback)))
|
||||
for key_bytes, encoded_value in fallback:
|
||||
raw.extend(_write_varint(len(key_bytes)))
|
||||
raw.extend(key_bytes)
|
||||
raw.extend(encoded_value)
|
||||
|
||||
return bytes(raw)
|
||||
|
||||
|
||||
def _build_params_qr_payload_v3(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
brotli_module = _load_brotli_module()
|
||||
|
||||
raw_bytes = _build_params_qr_binary(values, 3)
|
||||
compressed = brotli_module.compress(raw_bytes, quality=11)
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS].upper()
|
||||
payload = f"{QR_BACKUP_PREFIX_V3}:{_base45_encode(compressed)}:{checksum}"
|
||||
return {
|
||||
"payload": payload,
|
||||
"format": QR_BACKUP_PREFIX_V3,
|
||||
"count": len(values),
|
||||
"json_bytes": len(raw_bytes),
|
||||
"compressed_bytes": len(compressed),
|
||||
"payload_chars": len(payload),
|
||||
"ecc": QR_BACKUP_ECC,
|
||||
"version": 3,
|
||||
"checksum": checksum,
|
||||
}
|
||||
|
||||
|
||||
def _build_params_qr_payload_v4(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw_bytes = _build_params_qr_binary(values, 4)
|
||||
compressed = zlib.compress(raw_bytes, 9)
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS].upper()
|
||||
payload = f"{QR_BACKUP_PREFIX_V4}:{_base45_encode(compressed)}:{checksum}"
|
||||
return {
|
||||
"payload": payload,
|
||||
"format": QR_BACKUP_PREFIX_V4,
|
||||
"count": len(values),
|
||||
"json_bytes": len(raw_bytes),
|
||||
"compressed_bytes": len(compressed),
|
||||
"payload_chars": len(payload),
|
||||
"ecc": QR_BACKUP_ECC,
|
||||
"version": 4,
|
||||
"checksum": checksum,
|
||||
}
|
||||
|
||||
|
||||
def build_params_qr_payload(values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if values is None:
|
||||
values = get_all_param_values_for_backup()
|
||||
|
||||
try:
|
||||
return _build_params_qr_payload_v3(values)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _build_params_qr_payload_v4(values)
|
||||
except Exception:
|
||||
return _build_params_qr_payload_v2(values)
|
||||
|
||||
|
||||
def _parse_params_qr_payload_v1(compressed: bytes, checksum_text: str) -> Dict[str, Any]:
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:16]
|
||||
if checksum != checksum_text:
|
||||
raise ValueError("QR payload checksum mismatch")
|
||||
|
||||
raw = zlib.decompress(compressed)
|
||||
envelope = json.loads(raw.decode("utf-8"))
|
||||
if envelope.get("type") != QR_BACKUP_TYPE:
|
||||
raise ValueError("unsupported QR backup type")
|
||||
if int(envelope.get("version", 0)) > 1:
|
||||
raise ValueError("unsupported QR backup version")
|
||||
|
||||
values = envelope.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError("bad QR backup values")
|
||||
return values
|
||||
|
||||
|
||||
def _parse_params_qr_payload_v2(compressed: bytes, checksum_text: str) -> Dict[str, Any]:
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS]
|
||||
if checksum != checksum_text:
|
||||
raise ValueError("QR payload checksum mismatch")
|
||||
|
||||
raw = zlib.decompress(compressed)
|
||||
envelope = json.loads(raw.decode("utf-8"))
|
||||
|
||||
if isinstance(envelope, list):
|
||||
if len(envelope) < 2:
|
||||
raise ValueError("bad QR backup format")
|
||||
version = int(envelope[0])
|
||||
pairs = envelope[1]
|
||||
fallback = envelope[2] if len(envelope) > 2 else {}
|
||||
elif isinstance(envelope, dict):
|
||||
version = int(envelope.get("v", 0))
|
||||
pairs = envelope.get("d")
|
||||
fallback = envelope.get("n", {})
|
||||
else:
|
||||
raise ValueError("bad QR backup format")
|
||||
|
||||
if version > QR_BACKUP_VERSION:
|
||||
raise ValueError("unsupported QR backup version")
|
||||
if not isinstance(pairs, list):
|
||||
raise ValueError("bad QR backup values")
|
||||
if fallback is None:
|
||||
fallback = {}
|
||||
if not isinstance(fallback, dict):
|
||||
raise ValueError("bad QR backup fallback")
|
||||
|
||||
_, code_to_key = _build_param_code_maps(_backup_param_names())
|
||||
values: Dict[str, Any] = {}
|
||||
for item in pairs:
|
||||
if not isinstance(item, list) or len(item) != 2:
|
||||
continue
|
||||
key = code_to_key.get(str(item[0]))
|
||||
if key:
|
||||
values[key] = item[1]
|
||||
|
||||
for key, value in fallback.items():
|
||||
values[str(key)] = value
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _parse_params_qr_binary(raw: bytes) -> Dict[str, Any]:
|
||||
if len(raw) < 2 + QR_BACKUP_SCHEMA_BYTES:
|
||||
raise ValueError("bad QR backup format")
|
||||
|
||||
version = raw[0]
|
||||
code_size = raw[1]
|
||||
pos = 2 + QR_BACKUP_SCHEMA_BYTES
|
||||
if version > QR_BACKUP_VERSION:
|
||||
raise ValueError("unsupported QR backup version")
|
||||
if code_size < 1 or code_size > 8:
|
||||
raise ValueError("bad QR backup code size")
|
||||
|
||||
_, code_to_key = _build_param_code_maps_bytes(_backup_param_names(), code_size)
|
||||
values: Dict[str, Any] = {}
|
||||
pair_count, pos = _read_varint(raw, pos)
|
||||
for _ in range(pair_count):
|
||||
end = pos + code_size
|
||||
if end > len(raw):
|
||||
raise ValueError("bad QR backup code")
|
||||
code = raw[pos:end]
|
||||
pos = end
|
||||
value, pos = _decode_qr_value(raw, pos)
|
||||
key = code_to_key.get(code)
|
||||
if key:
|
||||
values[key] = value
|
||||
|
||||
fallback_count, pos = _read_varint(raw, pos)
|
||||
for _ in range(fallback_count):
|
||||
key_size, pos = _read_varint(raw, pos)
|
||||
key_end = pos + key_size
|
||||
if key_end > len(raw):
|
||||
raise ValueError("bad QR backup key")
|
||||
key = raw[pos:key_end].decode("utf-8")
|
||||
pos = key_end
|
||||
value, pos = _decode_qr_value(raw, pos)
|
||||
values[key] = value
|
||||
|
||||
if pos != len(raw):
|
||||
raise ValueError("bad QR backup trailing data")
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def _parse_params_qr_payload_v3(payload: str) -> Dict[str, Any]:
|
||||
brotli_module = _load_brotli_module()
|
||||
prefix = f"{QR_BACKUP_PREFIX_V3}:"
|
||||
if not payload.startswith(prefix):
|
||||
raise ValueError("unsupported QR payload")
|
||||
|
||||
try:
|
||||
encoded, checksum_text = payload[len(prefix):].rsplit(":", 1)
|
||||
except ValueError:
|
||||
raise ValueError("bad QR payload")
|
||||
|
||||
compressed = _base45_decode(encoded)
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS].upper()
|
||||
if checksum != checksum_text.upper():
|
||||
raise ValueError("QR payload checksum mismatch")
|
||||
|
||||
return _parse_params_qr_binary(brotli_module.decompress(compressed))
|
||||
|
||||
|
||||
def _parse_params_qr_payload_v4(payload: str) -> Dict[str, Any]:
|
||||
prefix = f"{QR_BACKUP_PREFIX_V4}:"
|
||||
if not payload.startswith(prefix):
|
||||
raise ValueError("unsupported QR payload")
|
||||
|
||||
try:
|
||||
encoded, checksum_text = payload[len(prefix):].rsplit(":", 1)
|
||||
except ValueError:
|
||||
raise ValueError("bad QR payload")
|
||||
|
||||
compressed = _base45_decode(encoded)
|
||||
checksum = hashlib.sha256(compressed).hexdigest()[:QR_BACKUP_CHECKSUM_CHARS].upper()
|
||||
if checksum != checksum_text.upper():
|
||||
raise ValueError("QR payload checksum mismatch")
|
||||
|
||||
return _parse_params_qr_binary(zlib.decompress(compressed))
|
||||
|
||||
|
||||
def parse_params_qr_payload(data: Any) -> Dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
values = data.get("values") if isinstance(data.get("values"), dict) else data
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError("bad payload format")
|
||||
return values
|
||||
|
||||
payload = str(data or "").strip()
|
||||
if not payload:
|
||||
raise ValueError("empty payload")
|
||||
|
||||
if payload.startswith("{"):
|
||||
j = json.loads(payload)
|
||||
return parse_params_qr_payload(j)
|
||||
|
||||
if payload.startswith(f"{QR_BACKUP_PREFIX_V3}:"):
|
||||
return _parse_params_qr_payload_v3(payload)
|
||||
if payload.startswith(f"{QR_BACKUP_PREFIX_V4}:"):
|
||||
return _parse_params_qr_payload_v4(payload)
|
||||
|
||||
parts = payload.split(".")
|
||||
if len(parts) != 3 or parts[0] not in (QR_BACKUP_PREFIX_V1, QR_BACKUP_PREFIX_V2):
|
||||
raise ValueError("unsupported QR payload")
|
||||
|
||||
compressed = _b64url_decode(parts[1])
|
||||
if parts[0] == QR_BACKUP_PREFIX_V1:
|
||||
return _parse_params_qr_payload_v1(compressed, parts[2])
|
||||
return _parse_params_qr_payload_v2(compressed, parts[2])
|
||||
|
||||
|
||||
def _param_type_name(t: Any) -> str:
|
||||
name = getattr(t, "name", None)
|
||||
return str(name or t)
|
||||
|
||||
|
||||
def _is_unsupported_param_type(t: Any) -> bool:
|
||||
if ParamKeyType is None:
|
||||
return True
|
||||
return t in (ParamKeyType.BYTES, ParamKeyType.JSON)
|
||||
|
||||
|
||||
def _normalize_param_value(t: Any, value: Any) -> Any:
|
||||
if ParamKeyType is not None and t == ParamKeyType.BOOL:
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("1", "true", "on", "yes"):
|
||||
return True
|
||||
if v in ("0", "false", "off", "no", ""):
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
if ParamKeyType is not None and t == ParamKeyType.INT:
|
||||
return int(float(value))
|
||||
|
||||
if ParamKeyType is not None and t == ParamKeyType.FLOAT:
|
||||
return float(value)
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def _values_equal(t: Any, left: Any, right: Any) -> bool:
|
||||
try:
|
||||
l_norm = _normalize_param_value(t, left)
|
||||
r_norm = _normalize_param_value(t, right)
|
||||
if ParamKeyType is not None and t == ParamKeyType.FLOAT:
|
||||
return abs(float(l_norm) - float(r_norm)) < 0.000001
|
||||
return l_norm == r_norm
|
||||
except Exception:
|
||||
return str(left) == str(right)
|
||||
|
||||
|
||||
def preview_param_restore_values(values: Dict[str, Any], selected_keys: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
if not HAS_PARAMS or ParamKeyType is None:
|
||||
raise RuntimeError("Params/ParamKeyType not available")
|
||||
|
||||
selected = set(selected_keys or [])
|
||||
params = Params()
|
||||
current_values = get_param_values(list(values.keys()), {})
|
||||
entries = []
|
||||
summary = {"changed": 0, "same": 0, "skipped": 0, "invalid": 0, "selected": 0}
|
||||
|
||||
for key in sorted(values.keys()):
|
||||
raw_value = values[key]
|
||||
status = "changed"
|
||||
reason = ""
|
||||
can_apply = True
|
||||
type_name = "unknown"
|
||||
normalized_value: Any = raw_value
|
||||
|
||||
try:
|
||||
t = params.get_type(key)
|
||||
type_name = _param_type_name(t)
|
||||
if _is_unsupported_param_type(t):
|
||||
status = "skipped"
|
||||
reason = "unsupported type"
|
||||
can_apply = False
|
||||
else:
|
||||
normalized_value = _normalize_param_value(t, raw_value)
|
||||
current_value = current_values.get(key, "")
|
||||
if _values_equal(t, current_value, normalized_value):
|
||||
status = "same"
|
||||
can_apply = False
|
||||
except Exception as e:
|
||||
current_value = current_values.get(key, "")
|
||||
status = "invalid"
|
||||
reason = str(e)
|
||||
can_apply = False
|
||||
|
||||
is_selected = can_apply and (not selected or key in selected)
|
||||
if is_selected:
|
||||
summary["selected"] += 1
|
||||
summary[status] += 1
|
||||
entries.append({
|
||||
"key": key,
|
||||
"type": type_name,
|
||||
"current": current_values.get(key, ""),
|
||||
"value": normalized_value,
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"apply": is_selected,
|
||||
})
|
||||
|
||||
return {
|
||||
"count": len(entries),
|
||||
"summary": summary,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def restore_param_values_validated(values: Dict[str, Any], selected_keys: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
preview = preview_param_restore_values(values, selected_keys)
|
||||
apply_values = {
|
||||
entry["key"]: entry["value"]
|
||||
for entry in preview["entries"]
|
||||
if entry.get("apply")
|
||||
}
|
||||
result = restore_param_values_from_backup(apply_values) if apply_values else {
|
||||
"ok_cnt": 0,
|
||||
"fail_cnt": 0,
|
||||
"fails": [],
|
||||
}
|
||||
return {
|
||||
"preview": preview,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
65
selfdrive/carrot/server/services/setting_favorites.py
Normal file
65
selfdrive/carrot/server/services/setting_favorites.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from collections.abc import Iterable
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..config import CARROT_SETTING_FAVORITES_PATH
|
||||
|
||||
|
||||
MAX_SETTING_FAVORITES = 200
|
||||
DEFAULT_SETTING_FAVORITES: Dict[str, Any] = {
|
||||
"favorites": [],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_favorites(value: Any) -> List[str]:
|
||||
if not isinstance(value, Iterable) or isinstance(value, (str, bytes, bytearray, dict)):
|
||||
return []
|
||||
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for item in value:
|
||||
name = str(item or "").strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
if len(out) >= MAX_SETTING_FAVORITES:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def sanitize_setting_favorites(raw: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
raw = raw or {}
|
||||
return {
|
||||
"favorites": _normalize_favorites(raw.get("favorites")),
|
||||
}
|
||||
|
||||
|
||||
def read_setting_favorites() -> Dict[str, Any]:
|
||||
try:
|
||||
with open(CARROT_SETTING_FAVORITES_PATH, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except Exception:
|
||||
return dict(DEFAULT_SETTING_FAVORITES)
|
||||
return sanitize_setting_favorites(raw if isinstance(raw, dict) else {})
|
||||
|
||||
|
||||
def write_setting_favorites(settings: Dict[str, Any]) -> Dict[str, Any]:
|
||||
clean = sanitize_setting_favorites(settings)
|
||||
os.makedirs(os.path.dirname(CARROT_SETTING_FAVORITES_PATH), exist_ok=True)
|
||||
tmp_path = CARROT_SETTING_FAVORITES_PATH + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(clean, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, CARROT_SETTING_FAVORITES_PATH)
|
||||
return clean
|
||||
|
||||
|
||||
def update_setting_favorites(updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(updates, dict):
|
||||
updates = {}
|
||||
current = read_setting_favorites()
|
||||
if "favorites" in updates:
|
||||
current["favorites"] = updates.get("favorites")
|
||||
return write_setting_favorites(current)
|
||||
@@ -660,6 +660,91 @@ body[data-page="terminal"] .app-toast-host {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
/* ── Action Grid (settings/tools/log-style command groups) ── */
|
||||
.ui-action-grid {
|
||||
--ui-action-min: 124px;
|
||||
--ui-action-gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ui-action-min)), 1fr));
|
||||
gap: var(--ui-action-gap);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ui-action-grid--quick {
|
||||
--ui-action-min: 136px;
|
||||
--ui-action-gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn,
|
||||
.ui-action-grid > .smallBtn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--md-surface-cont);
|
||||
color: var(--md-on-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
line-height: 1.18;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn:hover,
|
||||
.ui-action-grid > .btn:focus-visible,
|
||||
.ui-action-grid > .smallBtn:hover,
|
||||
.ui-action-grid > .smallBtn:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--md-primary) 36%, var(--md-outline-var));
|
||||
background: color-mix(in srgb, var(--md-surface-cont-h) 92%, var(--md-primary));
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn:active,
|
||||
.ui-action-grid > .smallBtn:active {
|
||||
background: var(--md-surface-cont-h);
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn--filled {
|
||||
justify-content: center;
|
||||
background: var(--md-primary);
|
||||
border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var));
|
||||
color: var(--md-on-primary);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn--filled:hover,
|
||||
.ui-action-grid > .btn--filled:focus-visible {
|
||||
background: color-mix(in srgb, var(--md-primary) 88%, #fff);
|
||||
border-color: color-mix(in srgb, var(--md-primary) 82%, var(--md-outline-var));
|
||||
color: var(--md-on-primary);
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn--filled:active {
|
||||
background: color-mix(in srgb, var(--md-primary) 82%, #fff);
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn--danger,
|
||||
.ui-action-grid > .smallBtn.btn--danger {
|
||||
color: color-mix(in srgb, var(--md-error) 78%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.ui-action-grid > .btn--danger:hover,
|
||||
.ui-action-grid > .btn--danger:focus-visible,
|
||||
.ui-action-grid > .smallBtn.btn--danger:hover,
|
||||
.ui-action-grid > .smallBtn.btn--danger:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--md-error) 46%, var(--md-outline-var));
|
||||
background: color-mix(in srgb, var(--md-error-cont) 18%, var(--md-surface-cont-h));
|
||||
}
|
||||
|
||||
.ui-action-grid > .git-pull-btn {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── Setting Item ─────────────────────────────────────────── */
|
||||
.setting {
|
||||
padding: var(--sp-lg) 0;
|
||||
|
||||
@@ -197,6 +197,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashcam-virtual-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashcam-route-card {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
@@ -801,6 +808,62 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr {
|
||||
width: auto;
|
||||
max-width: min(92vw, 960px);
|
||||
max-height: min(78vh, 680px);
|
||||
background: #000;
|
||||
--plyr-color-main: var(--md-primary);
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr video {
|
||||
max-width: min(92vw, 960px);
|
||||
max-height: min(78vh, 680px);
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr:-webkit-full-screen,
|
||||
.dashcam-player-frame .plyr:fullscreen,
|
||||
.dashcam-player-frame .plyr.plyr--fullscreen-active {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr:-webkit-full-screen video,
|
||||
.dashcam-player-frame .plyr:fullscreen video,
|
||||
.dashcam-player-frame .plyr.plyr--fullscreen-active video {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
object-position: center center;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr__video-wrapper {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr:-webkit-full-screen .plyr__video-wrapper,
|
||||
.dashcam-player-frame .plyr:fullscreen .plyr__video-wrapper,
|
||||
.dashcam-player-frame .plyr.plyr--fullscreen-active .plyr__video-wrapper {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.dashcam-player-frame .plyr__control svg {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dashcam-player-overlay--dashcam .dashcam-player-dialog {
|
||||
width: min(84vw, 760px);
|
||||
max-width: min(96vw, 920px);
|
||||
@@ -814,11 +877,19 @@
|
||||
max-height: min(84vh, 760px);
|
||||
}
|
||||
|
||||
.dashcam-player-overlay--dashcam .dashcam-player-frame .plyr,
|
||||
.dashcam-player-overlay--dashcam .dashcam-player-frame .plyr video {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: min(84vh, 760px);
|
||||
}
|
||||
|
||||
.dashcam-player-top {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -827,6 +898,32 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashcam-player-toast {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
padding: 12px 22px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,.74);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(.92);
|
||||
transition: opacity .14s ease, transform .14s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.36);
|
||||
}
|
||||
|
||||
.dashcam-player-toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.dashcam-player-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -851,52 +948,6 @@
|
||||
background: rgba(255,255,255,.16);
|
||||
}
|
||||
|
||||
.dashcam-player-controls {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(132px, 22vw, 280px);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: .9;
|
||||
pointer-events: none;
|
||||
transition: opacity .15s ease, transform .15s ease;
|
||||
}
|
||||
|
||||
.dashcam-player-frame:hover .dashcam-player-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dashcam-player-overlay.is-player-controls-hidden .dashcam-player-controls {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(.96);
|
||||
}
|
||||
|
||||
.dashcam-player-control {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background: rgba(0,0,0,.46);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.28);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dashcam-player-control:hover {
|
||||
background: color-mix(in srgb, var(--md-primary) 72%, rgba(0,0,0,.44));
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.screenrecord-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -909,6 +960,25 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashcam-routes.is-loading-more::after,
|
||||
.screenrecord-list.is-loading-more::after {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 10px auto 2px;
|
||||
border: 2px solid color-mix(in srgb, var(--md-outline-var) 48%, transparent);
|
||||
border-top-color: var(--md-primary);
|
||||
border-radius: 999px;
|
||||
animation: screenrecord-loading-spin .78s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes screenrecord-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.screenrecord-row {
|
||||
flex: 0 0 auto;
|
||||
min-height: 64px;
|
||||
@@ -925,6 +995,17 @@
|
||||
contain-intrinsic-size: auto 64px;
|
||||
}
|
||||
|
||||
.screenrecord-list .screenrecord-row.ui-stagger-item {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.screenrecord-virtual-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screenrecord-row:hover {
|
||||
border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var));
|
||||
background: color-mix(in srgb, var(--md-surface-cont-h) 88%, var(--md-primary));
|
||||
@@ -1231,7 +1312,9 @@
|
||||
max-height: 68vh;
|
||||
}
|
||||
|
||||
.dashcam-player-video {
|
||||
.dashcam-player-video,
|
||||
.dashcam-player-frame .plyr,
|
||||
.dashcam-player-frame .plyr video {
|
||||
max-width: min(76vw, 760px);
|
||||
max-height: 68vh;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
--setting-menu-item-padding-inline: 12px;
|
||||
}
|
||||
|
||||
.page--setting .setting.is-restored-live .val {
|
||||
border-color: color-mix(in srgb, #8fdc9b 62%, var(--md-outline-var));
|
||||
background: color-mix(in srgb, #8fdc9b 14%, var(--md-surface-cont-h));
|
||||
color: color-mix(in srgb, #a9e8b2 82%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.setting-car-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -242,6 +248,16 @@
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.page--setting #groupList .groupBtn--favorites {
|
||||
border-color: color-mix(in srgb, #7dd3fc 34%, var(--md-outline-var));
|
||||
background: color-mix(in srgb, #7dd3fc 7%, var(--md-surface-cont-h));
|
||||
}
|
||||
|
||||
.page--setting #groupList .groupBtn--favorites.active {
|
||||
border-color: color-mix(in srgb, #7dd3fc 64%, var(--md-outline-var));
|
||||
color: color-mix(in srgb, #bae6fd 82%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.page--setting #carrotTabContent,
|
||||
.page--setting #deviceTabContent,
|
||||
.page--setting #items,
|
||||
@@ -329,6 +345,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page--setting .setting-subnav__tab--favorites {
|
||||
color: color-mix(in srgb, #bae6fd 82%, var(--md-on-surface-var));
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.setting-search-panel {
|
||||
--setting-search-form-width: clamp(360px, 34vw, 540px);
|
||||
@@ -752,6 +772,76 @@
|
||||
.page--setting #settingScreenItems .setting-copy {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-title-row .title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorite-mark {
|
||||
display: none;
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #7dd3fc;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorite-mark.is-active {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorite-mark svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting.is-longpressing {
|
||||
background: color-mix(in srgb, #7dd3fc 7%, transparent);
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorites-empty {
|
||||
max-width: min(460px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 22px 4px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 52%, transparent);
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorites-empty__title {
|
||||
color: var(--md-on-surface);
|
||||
font-size: var(--fs-body-md);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-favorites-empty__desc {
|
||||
margin-top: 6px;
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: var(--fs-body-sm);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
:lang(ko) .page--setting #settingScreenItems .setting-favorites-empty__desc,
|
||||
:lang(ja) .page--setting #settingScreenItems .setting-favorites-empty__desc,
|
||||
:lang(zh) .page--setting #settingScreenItems .setting-favorites-empty__desc {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.page--setting #settingScreenItems .setting-marquee {
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
--tools-console-current: #d9f1ff;
|
||||
--tools-console-history: #7f95a7;
|
||||
--tools-console-divider: color-mix(in srgb, var(--md-outline-var) 24%, transparent);
|
||||
--tools-log-scroll-gap: 6px;
|
||||
--tools-detail-scroll-gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
@@ -534,6 +536,388 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.app-dialog--tools-qr .app-dialog__sheet {
|
||||
width: min(calc(100vw - 32px), 520px);
|
||||
max-height: min(calc(100dvh - 32px), 720px);
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-dialog--tools-qr .app-dialog__body {
|
||||
min-height: 0;
|
||||
white-space: normal;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tools-qr-backup,
|
||||
.tools-qr-restore {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tools-qr-code {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tools-qr-code svg {
|
||||
display: block;
|
||||
width: min(100%, 300px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tools-qr-stats,
|
||||
.tools-qr-actions,
|
||||
.tools-qr-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tools-qr-restore .tools-qr-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--md-surface-cont-h) 70%, #000);
|
||||
}
|
||||
|
||||
.tools-qr-action-btn {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.tools-qr-action-btn:hover,
|
||||
.tools-qr-action-btn:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--md-primary) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--md-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-action-btn--camera.is-disabled,
|
||||
.tools-qr-action-btn--camera:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.56;
|
||||
color: color-mix(in srgb, var(--md-on-surface-var) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--md-on-surface) 5%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-action-btn--camera.is-disabled::before,
|
||||
.tools-qr-action-btn--camera:disabled::before {
|
||||
content: "X";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tools-qr-stats {
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tools-qr-camera {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent);
|
||||
border-radius: 8px;
|
||||
background: #05080d;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.tools-qr-camera video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
max-height: min(54dvh, 420px);
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tools-qr-camera__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tools-qr-camera__guide {
|
||||
position: relative;
|
||||
width: 74%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid color-mix(in srgb, var(--md-outline-var) 78%, transparent);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 999px rgb(0 0 0 / 42%);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.tools-qr-camera__corner {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: color-mix(in srgb, var(--md-primary) 82%, #ffffff);
|
||||
border-color: currentColor;
|
||||
transition: color 160ms ease, filter 160ms ease;
|
||||
}
|
||||
|
||||
.tools-qr-camera__corner--tl {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
border-top: 3px solid;
|
||||
border-left: 3px solid;
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-camera__corner--tr {
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
border-top: 3px solid;
|
||||
border-right: 3px solid;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-camera__corner--bl {
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
border-bottom: 3px solid;
|
||||
border-left: 3px solid;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-camera__corner--br {
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border-right: 3px solid;
|
||||
border-bottom: 3px solid;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="detected"] {
|
||||
border-color: color-mix(in srgb, var(--md-primary) 82%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--md-primary) 24%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="detected"] .tools-qr-camera__guide {
|
||||
border-color: color-mix(in srgb, var(--md-primary) 80%, #ffffff);
|
||||
box-shadow: 0 0 0 999px rgb(0 0 0 / 34%), 0 0 0 2px color-mix(in srgb, var(--md-primary) 28%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="aligned"],
|
||||
.tools-qr-camera[data-scan-state="locked"] {
|
||||
border-color: color-mix(in srgb, #4fd18b 82%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, #4fd18b 34%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__guide,
|
||||
.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide {
|
||||
border-color: #4fd18b;
|
||||
box-shadow: 0 0 0 999px rgb(0 0 0 / 28%), 0 0 0 2px color-mix(in srgb, #4fd18b 38%, transparent);
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__corner,
|
||||
.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__corner {
|
||||
color: #4fd18b;
|
||||
filter: drop-shadow(0 0 7px color-mix(in srgb, #4fd18b 68%, transparent));
|
||||
}
|
||||
|
||||
.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide {
|
||||
animation: toolsQrLockPulse 260ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes toolsQrLockPulse {
|
||||
0% {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.045);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
}
|
||||
|
||||
.tools-qr-status,
|
||||
.tools-qr-empty,
|
||||
.tools-qr-more {
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tools-qr-status {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: color-mix(in srgb, var(--md-on-surface) 86%, var(--md-on-surface-var));
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.tools-qr-chip {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tools-qr-chip strong {
|
||||
color: var(--md-on-surface);
|
||||
}
|
||||
|
||||
.tools-qr-summary {
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
padding: 2px 0 4px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 28%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-diff {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tools-qr-diff__list {
|
||||
max-height: min(36dvh, 300px);
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
padding-right: 4px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.tools-qr-diff__row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 0 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 30%, transparent);
|
||||
}
|
||||
|
||||
.tools-qr-diff__row:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.tools-qr-diff__head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-diff__key {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--md-on-surface);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tools-qr-diff__status {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface));
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tools-qr-diff__compare {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tools-qr-diff__value {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tools-qr-diff__value span {
|
||||
min-width: 0;
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tools-qr-diff__value code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--md-on-surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tools-qr-diff__value--old {
|
||||
color: color-mix(in srgb, #ff8a80 74%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.tools-qr-diff__value--new {
|
||||
color: color-mix(in srgb, #8fdc9b 78%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.tools-qr-diff__value--old code {
|
||||
color: color-mix(in srgb, #ff8a80 76%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.tools-qr-diff__value--new code {
|
||||
color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface));
|
||||
}
|
||||
|
||||
.tools-qr-diff__arrow {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--md-on-surface-var);
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@media (max-width: 640px), (orientation: portrait) {
|
||||
.app-dialog--web-settings .app-dialog__sheet {
|
||||
--web-settings-content-max: min(58dvh, 440px);
|
||||
@@ -575,6 +959,34 @@
|
||||
width: min(132px, 40vw);
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.app-dialog--tools-qr .app-dialog__sheet {
|
||||
width: min(calc(100vw - 24px), 440px);
|
||||
max-height: min(calc(100dvh - 24px), 680px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.tools-qr-code svg {
|
||||
width: min(100%, 276px);
|
||||
}
|
||||
|
||||
.tools-qr-camera video {
|
||||
min-height: 270px;
|
||||
max-height: min(52dvh, 390px);
|
||||
}
|
||||
|
||||
.tools-qr-diff__row {
|
||||
padding: 12px 0 14px;
|
||||
}
|
||||
|
||||
.tools-qr-diff__compare {
|
||||
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tools-qr-diff__value code {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 430px) and (orientation: landscape) {
|
||||
@@ -605,21 +1017,30 @@
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: var(--sp-lg);
|
||||
padding: 2px 0 var(--sp-lg);
|
||||
}
|
||||
|
||||
.tools-quick-actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tools-quick-actions a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tools-group__toggle {
|
||||
width: auto;
|
||||
max-width: calc(100% - 10px);
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tools-group__title {
|
||||
@@ -642,7 +1063,16 @@
|
||||
}
|
||||
|
||||
.tools-group__body {
|
||||
margin-top: var(--sp-sm);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.page--tools .section.tools-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page--tools .divider {
|
||||
margin: 16px 0 14px;
|
||||
border-top-color: color-mix(in srgb, var(--md-outline-var) 44%, transparent);
|
||||
}
|
||||
|
||||
.tools-progress {
|
||||
@@ -1002,6 +1432,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
.tools-console-log__card.is-collapsing {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tools-console-log__cardHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -1046,19 +1480,19 @@
|
||||
}
|
||||
|
||||
.tools-console-log__detailWrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
display: block;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
grid-template-rows var(--tools-detail-duration) var(--tools-motion-decelerate),
|
||||
max-height var(--tools-detail-duration) var(--tools-motion-decelerate),
|
||||
opacity calc(var(--tools-detail-duration) * .78) var(--tools-motion-decelerate),
|
||||
transform var(--tools-detail-duration) var(--tools-motion-decelerate);
|
||||
}
|
||||
|
||||
.tools-console-log__card.is-expanded .tools-console-log__detailWrap {
|
||||
grid-template-rows: 1fr;
|
||||
max-height: var(--tools-detail-height, min(42dvh, 360px));
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
animation: tools-detail-open var(--tools-detail-duration) var(--tools-motion-decelerate) both;
|
||||
@@ -1072,12 +1506,12 @@
|
||||
|
||||
@keyframes tools-detail-open {
|
||||
from {
|
||||
grid-template-rows: 0fr;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
grid-template-rows: 1fr;
|
||||
max-height: var(--tools-detail-height, min(42dvh, 360px));
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -1094,8 +1528,14 @@
|
||||
|
||||
.tools-console-log__detail {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - var(--tools-detail-scroll-gap));
|
||||
max-height: min(32dvh, 230px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-y;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--md-primary) 26%, var(--md-outline-var));
|
||||
border-radius: 10px;
|
||||
@@ -1183,6 +1623,8 @@
|
||||
@media (orientation: landscape) {
|
||||
.page--tools {
|
||||
--tools-detail-duration: 340ms;
|
||||
--tools-log-scroll-gap: 10px;
|
||||
--tools-detail-scroll-gap: 12px;
|
||||
--tools-console-form-height: 40px;
|
||||
--tools-console-log-font-size: 14px;
|
||||
--tools-console-log-line-height: 1.55;
|
||||
@@ -1261,9 +1703,12 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
border: 1px solid var(--tools-console-divider);
|
||||
border-radius: var(--r-xl, 18px);
|
||||
box-shadow: none;
|
||||
border: 1px solid color-mix(in srgb, var(--md-outline-var) 34%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--md-surface-cont) 88%, #060a10);
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset,
|
||||
0 10px 28px rgba(0, 0, 0, .22);
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log {
|
||||
@@ -1273,27 +1718,108 @@
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 14px 12px;
|
||||
padding: 14px 12px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__header {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 10px;
|
||||
margin: 0 0 8px;
|
||||
padding: 0 2px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 24%, transparent);
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: 20px;
|
||||
padding: 0 var(--tools-log-scroll-gap) 16px 0;
|
||||
scroll-padding-bottom: 20px;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__card {
|
||||
margin: 0;
|
||||
padding: 10px 10px 11px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
opacity 170ms ease,
|
||||
transform 170ms ease;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__card + .tools-console-log__card {
|
||||
border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent);
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__card:hover {
|
||||
background: color-mix(in srgb, var(--md-on-surface) 5%, transparent);
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__card.is-expanded {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--md-on-surface) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--md-on-surface) 94%, var(--tools-console-current));
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__cardHead {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__cardTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__cardTime {
|
||||
color: color-mix(in srgb, var(--md-primary) 62%, var(--md-on-surface-var));
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__cardBody {
|
||||
color: color-mix(in srgb, var(--md-on-surface-var) 86%, var(--md-on-surface));
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__detailTitle {
|
||||
margin: 10px 0 4px;
|
||||
color: color-mix(in srgb, var(--md-on-surface-var) 86%, var(--md-on-surface));
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__detail {
|
||||
width: calc(100% - var(--tools-detail-scroll-gap));
|
||||
max-height: min(30dvh, 210px);
|
||||
padding: 8px 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 26%, transparent);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--md-on-surface) 86%, var(--tools-console-current));
|
||||
font-size: 12px;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__empty {
|
||||
margin: 6px 0 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--md-on-surface) 5%, transparent);
|
||||
}
|
||||
|
||||
.page--tools > .tools-console-dock .tools-console-log__clearBtn {
|
||||
min-height: 26px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1346,6 +1872,8 @@
|
||||
@media (orientation: portrait) {
|
||||
.page--tools {
|
||||
--tools-detail-duration: 360ms;
|
||||
--tools-log-scroll-gap: 6px;
|
||||
--tools-detail-scroll-gap: 10px;
|
||||
}
|
||||
|
||||
.page--tools > #toolsMeta {
|
||||
@@ -1415,7 +1943,7 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: clamp(22px, 7dvh, 58px) 0 0;
|
||||
padding: clamp(22px, 7dvh, 58px) var(--tools-log-scroll-gap) 0 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
@@ -1444,14 +1972,23 @@
|
||||
}
|
||||
|
||||
.tools-console-log__card {
|
||||
min-height: 64px;
|
||||
margin-bottom: 10px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 24px;
|
||||
background: color-mix(in srgb, var(--md-surface-cont-h) 62%, #0a0d15);
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in srgb, #fff 8%, transparent) inset,
|
||||
0 16px 36px rgba(0, 0, 0, .22);
|
||||
min-height: 62px;
|
||||
margin: 0;
|
||||
padding: 13px 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tools-console-log__card + .tools-console-log__card {
|
||||
border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent);
|
||||
}
|
||||
|
||||
.tools-console-log__card.is-expanded {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--md-on-surface) 7%, transparent);
|
||||
color: color-mix(in srgb, var(--md-on-surface) 94%, var(--tools-console-current));
|
||||
}
|
||||
|
||||
.tools-console-log__detailWrap {
|
||||
@@ -1481,8 +2018,14 @@
|
||||
}
|
||||
|
||||
.tools-console-log__detail {
|
||||
width: calc(100% - var(--tools-detail-scroll-gap));
|
||||
max-height: min(36dvh, 280px);
|
||||
border-radius: 16px;
|
||||
padding: 8px 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 26%, transparent);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--md-on-surface) 86%, var(--tools-console-current));
|
||||
}
|
||||
|
||||
.tools-console-log__empty {
|
||||
@@ -1538,7 +2081,7 @@
|
||||
}
|
||||
|
||||
.tools-console-log__card.is-expanded .tools-console-log__detailWrap {
|
||||
grid-template-rows: 1fr;
|
||||
max-height: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
1
selfdrive/carrot/web/css/vendor/plyr.css
vendored
Normal file
1
selfdrive/carrot/web/css/vendor/plyr.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -74,13 +74,14 @@
|
||||
<link rel="stylesheet" href="/css/hud_card.css?v=2605-03" />
|
||||
<link rel="stylesheet" href="/css/base.css?v=2605-07" />
|
||||
<link rel="stylesheet" href="/css/layout.css?v=2605-08" />
|
||||
<link rel="stylesheet" href="/css/components.css?v=2605-02" />
|
||||
<link rel="stylesheet" href="/css/pages/logs.css?v=2605-07" />
|
||||
<link rel="stylesheet" href="/css/components.css?v=2605-03" />
|
||||
<link rel="stylesheet" href="/css/pages/logs.css?v=2605-15" />
|
||||
<link rel="stylesheet" href="/css/pages/terminal.css?v=2605-07" />
|
||||
<link rel="stylesheet" href="/css/pages/settings.css?v=2605-16" />
|
||||
<link rel="stylesheet" href="/css/pages/tools.css?v=2605-23" />
|
||||
<link rel="stylesheet" href="/css/pages/settings.css?v=2605-19" />
|
||||
<link rel="stylesheet" href="/css/pages/tools.css?v=2605-36" />
|
||||
<link rel="stylesheet" href="/css/pages/drive.css?v=2605-10" />
|
||||
<link rel="stylesheet" href="/css/responsive.css?v=2605-07" />
|
||||
<link rel="stylesheet" href="/css/vendor/plyr.css?v=3.7.8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -203,8 +204,8 @@
|
||||
</div>
|
||||
|
||||
<div class="tools-scroll-stack">
|
||||
<div class="row-wrap mb-md" style="gap: var(--sp-md);">
|
||||
<a id="btnQuickLinkWeb" class="btn btn--filled" href="#" aria-disabled="true" style="text-decoration:none; font-weight:700;">CarrotMan</a>
|
||||
<div class="tools-quick-actions ui-action-grid ui-action-grid--quick mb-md">
|
||||
<a id="btnQuickLinkWeb" class="btn btn--filled" href="#" aria-disabled="true">CarrotMan</a>
|
||||
<button id="btnDeviceInfo" class="btn btn--filled" type="button">Carrot Info</button>
|
||||
</div>
|
||||
|
||||
@@ -213,7 +214,7 @@
|
||||
<span id="gitCommandsTitle" class="section-title tools-group__title">Git Commands</span>
|
||||
<span class="tools-group__chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="toolsGroupBodyGit" class="tools-group__body row-wrap">
|
||||
<div id="toolsGroupBodyGit" class="tools-group__body ui-action-grid">
|
||||
<button id="btnGitPull" class="btn git-pull-btn" type="button">
|
||||
<span>git pull</span>
|
||||
<span id="gitPullBadge" class="git-pull-btn__badge" hidden aria-label="pending commits">0</span>
|
||||
@@ -235,7 +236,7 @@
|
||||
<span id="userSystemTitle" class="section-title tools-group__title">User / System</span>
|
||||
<span class="tools-group__chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="toolsGroupBodySystem" class="tools-group__body row-wrap hidden" hidden>
|
||||
<div id="toolsGroupBodySystem" class="tools-group__body ui-action-grid hidden" hidden>
|
||||
<button id="btnDeviceLang" class="btn">Device Lang</button>
|
||||
<button id="btnResetCalib" class="btn btn--danger">Reset Calib</button>
|
||||
<button id="btnSendTmuxLog" class="btn">capture tmux</button>
|
||||
@@ -255,9 +256,11 @@
|
||||
<span id="userSettingsTitle" class="section-title tools-group__title">Settings</span>
|
||||
<span class="tools-group__chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="toolsGroupBodySettings" class="tools-group__body row-wrap hidden" hidden>
|
||||
<div id="toolsGroupBodySettings" class="tools-group__body ui-action-grid hidden" hidden>
|
||||
<button id="btnBackupSettings" class="btn">Backup</button>
|
||||
<button id="btnRestoreSettings" class="btn">Restore</button>
|
||||
<button id="btnQrBackupSettings" class="btn">QR Backup</button>
|
||||
<button id="btnQrRestoreSettings" class="btn">QR Restore</button>
|
||||
<button id="btnCopySettings" class="btn">Copy</button>
|
||||
<button id="btnViewSettings" class="btn">View</button>
|
||||
</div>
|
||||
@@ -578,35 +581,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/vendor/plyr.min.js?v=3.7.8"></script>
|
||||
<script src="/js/vendor/qrcode-generator.js?v=1.4.4"></script>
|
||||
<script src="/js/vendor/jsQR.js?v=1.4.0"></script>
|
||||
<script src="/js/translations/registry.js?v=2604-01"></script>
|
||||
<script src="/js/translations/ko.js?v=2604-06"></script>
|
||||
<script src="/js/translations/en.js?v=2604-06"></script>
|
||||
<script src="/js/translations/zh.js?v=2604-04"></script>
|
||||
<script src="/js/translations/ja.js?v=2604-04"></script>
|
||||
<script src="/js/translations/fr.js?v=2604-04"></script>
|
||||
<script src="/js/translations/ko.js?v=2605-07"></script>
|
||||
<script src="/js/translations/en.js?v=2605-07"></script>
|
||||
<script src="/js/translations/zh.js?v=2605-07"></script>
|
||||
<script src="/js/translations/ja.js?v=2605-07"></script>
|
||||
<script src="/js/translations/fr.js?v=2605-07"></script>
|
||||
<script src="/js/hud_card.js?v=2604-05"></script>
|
||||
<script src="/js/shared/constants.js?v=2604-72"></script>
|
||||
<script src="/js/shared/dom.js?v=2604-71"></script>
|
||||
<script src="/js/shared/utils.js?v=2604-71"></script>
|
||||
<script src="/js/shared/i18n.js?v=2605-01"></script>
|
||||
<script src="/js/shared/api.js?v=2604-72"></script>
|
||||
<script src="/js/shared/i18n.js?v=2605-02"></script>
|
||||
<script src="/js/shared/api.js?v=2604-73"></script>
|
||||
<script src="/js/shared/activity.js?v=2604-04"></script>
|
||||
<script src="/js/shared/ui/dialog.js?v=2604-71"></script>
|
||||
<script src="/js/shared/ui/dialog.js?v=2604-72"></script>
|
||||
<script src="/js/shared/ui/viewport.js?v=2604-71"></script>
|
||||
<script src="/js/shared/ui/effects.js?v=2604-72"></script>
|
||||
<script src="/js/shared/ui/navigation.js?v=2605-11"></script>
|
||||
<script src="/js/pages/car.js?v=2604-75"></script>
|
||||
<script src="/js/pages/setting.js?v=2604-83"></script>
|
||||
<script src="/js/pages/setting.js?v=2605-05"></script>
|
||||
<script src="/js/pages/setting_device_config.js?v=2605-02"></script>
|
||||
<script src="/js/pages/setting_device_render.js?v=2605-02"></script>
|
||||
<script src="/js/pages/setting_device_network.js?v=2605-01"></script>
|
||||
<script src="/js/pages/setting_device_actions.js?v=2605-02"></script>
|
||||
<script src="/js/pages/setting_device.js?v=2605-05"></script>
|
||||
<script src="/js/pages/tools_web_settings.js?v=2605-04"></script>
|
||||
<script src="/js/pages/tools_notifications.js?v=2605-14"></script>
|
||||
<script src="/js/pages/tools_notifications.js?v=2605-18"></script>
|
||||
<script src="/js/pages/tools.js?v=2605-07"></script>
|
||||
<script src="/js/pages/branch.js?v=2604-73"></script>
|
||||
<script src="/js/pages/logs.js?v=2604-75"></script>
|
||||
<script src="/js/pages/tools_settings_qr.js?v=2605-11"></script>
|
||||
<script src="/js/pages/branch.js?v=2604-80"></script>
|
||||
<script src="/js/pages/logs.js?v=2605-08"></script>
|
||||
<script src="/js/pages/terminal.js?v=2604-72"></script>
|
||||
<script src="/js/raw_capnp.js?v=2604-05"></script>
|
||||
<script src="/js/vision_state.js?v=2605-01"></script>
|
||||
|
||||
@@ -373,21 +373,39 @@ const dashcamState = {
|
||||
expanded: new Set(),
|
||||
selected: new Set(),
|
||||
refreshTimer: null,
|
||||
loadingMore: false,
|
||||
scrollBusy: false,
|
||||
scrollTimer: null,
|
||||
renderFrame: 0,
|
||||
loadSeq: 0,
|
||||
layoutBound: false,
|
||||
layoutTimer: null,
|
||||
landscape: null,
|
||||
layoutKey: "",
|
||||
total: 0,
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
routeHeight: 300,
|
||||
routeHeights: Object.create(null),
|
||||
windowStart: 0,
|
||||
windowEnd: 0,
|
||||
signature: "",
|
||||
};
|
||||
|
||||
const screenrecordState = {
|
||||
initialized: false,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
videos: [],
|
||||
loadSeq: 0,
|
||||
signature: "",
|
||||
total: 0,
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
rowHeight: 80,
|
||||
windowStart: 0,
|
||||
windowEnd: 0,
|
||||
renderFrame: 0,
|
||||
};
|
||||
|
||||
let logsActiveTab = "dashcam";
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
// + Screen Recording listing/playback. Tab switching between the two.
|
||||
|
||||
const DASHCAM_UPLOAD_JOB_STORAGE_KEY = "carrot_dashcam_upload_job_id";
|
||||
const DASHCAM_ROUTE_INITIAL_BATCH = 6;
|
||||
const DASHCAM_ROUTE_BATCH_SIZE = 8;
|
||||
const DASHCAM_PAGE_SIZE = 40;
|
||||
const DASHCAM_LOAD_AHEAD_PX = 1200;
|
||||
const DASHCAM_ROUTE_WINDOW_OVERSCAN = 10;
|
||||
const SCREENRECORD_PAGE_SIZE = 40;
|
||||
const SCREENRECORD_LOAD_AHEAD_PX = 720;
|
||||
const SCREENRECORD_WINDOW_OVERSCAN = 8;
|
||||
let dashcamUploadActiveJobId = null;
|
||||
let dashcamUploadResumePromise = null;
|
||||
let dashcamRouteRenderToken = 0;
|
||||
let dashcamRouteRenderIdleId = null;
|
||||
|
||||
function isLogsPageActive() {
|
||||
return CURRENT_PAGE === "logs";
|
||||
@@ -41,6 +43,8 @@ function restoreLogsScrollTop(tab = logsActiveTab, options = {}) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!isLogsPageActive()) return;
|
||||
scroller.scrollTop = nextTop;
|
||||
if (key === "dashcam" && typeof scheduleDashcamWindowRender === "function") scheduleDashcamWindowRender();
|
||||
if (key === "screen" && typeof scheduleScreenrecordWindowRender === "function") scheduleScreenrecordWindowRender();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -118,7 +122,6 @@ function screenrecordApiPath(kind, fileId) {
|
||||
function dashcamRoutesSignature(routes) {
|
||||
return (routes || []).map((entry) => [
|
||||
entry.route || "",
|
||||
entry.latestModifiedLabel || "",
|
||||
...(entry.segmentFolders || []),
|
||||
].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : "");
|
||||
}
|
||||
@@ -127,25 +130,166 @@ function screenrecordVideosSignature(videos) {
|
||||
return (videos || []).map((video) => [
|
||||
video.id || "",
|
||||
video.name || "",
|
||||
video.modifiedLabel || video.relativeModifiedLabel || "",
|
||||
video.modifiedLabel || "",
|
||||
video.size || 0,
|
||||
].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : "");
|
||||
}
|
||||
|
||||
function hasCompleteDashcamDom(host, routes) {
|
||||
if (!host || !Array.isArray(routes) || !routes.length) return false;
|
||||
const renderedCount = Number.parseInt(host.dataset.renderCount || "0", 10);
|
||||
return host.dataset.signature === dashcamState.signature
|
||||
&& renderedCount >= routes.length
|
||||
&& host.querySelectorAll("[data-route-card]").length >= routes.length;
|
||||
function dashcamDefaultRouteHeight() {
|
||||
return isCompactLandscapeMode() ? 210 : 310;
|
||||
}
|
||||
|
||||
function hasCompleteScreenrecordDom(host, videos) {
|
||||
if (!host || !Array.isArray(videos) || !videos.length) return false;
|
||||
const renderedCount = Number.parseInt(host.dataset.renderCount || "0", 10);
|
||||
return host.dataset.signature === screenrecordState.signature
|
||||
&& renderedCount >= videos.length
|
||||
&& host.querySelectorAll(".screenrecord-row").length >= videos.length;
|
||||
function dashcamLayoutKey() {
|
||||
const wide = window.matchMedia?.("(min-width: 900px)")?.matches ? "wide" : "narrow";
|
||||
const compact = isCompactLandscapeMode() ? "landscape" : "portrait";
|
||||
return `${compact}:${wide}:${window.innerWidth}x${window.innerHeight}`;
|
||||
}
|
||||
|
||||
function dashcamRouteHeightFor(route) {
|
||||
const key = String(route || "");
|
||||
const cached = Number(dashcamState.routeHeights?.[key]);
|
||||
if (Number.isFinite(cached) && cached > 0) return cached;
|
||||
const fallback = Number(dashcamState.routeHeight) || dashcamDefaultRouteHeight();
|
||||
if (key && dashcamState.expanded.has(key) && !isCompactLandscapeMode()) {
|
||||
return Math.max(560, fallback);
|
||||
}
|
||||
return Math.max(120, fallback);
|
||||
}
|
||||
|
||||
function dashcamRouteGap(host) {
|
||||
const styles = window.getComputedStyle?.(host);
|
||||
return Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0;
|
||||
}
|
||||
|
||||
function dashcamWindowFor(host, routes) {
|
||||
const list = Array.isArray(routes) ? routes : [];
|
||||
const count = list.length;
|
||||
const viewportHeight = Math.max(1, host?.clientHeight || dashcamDefaultRouteHeight() * 2);
|
||||
const scrollTop = Math.max(0, host?.scrollTop || 0);
|
||||
const overscanPx = dashcamRouteHeightFor("") * DASHCAM_ROUTE_WINDOW_OVERSCAN;
|
||||
const minTop = Math.max(0, scrollTop - overscanPx);
|
||||
const maxBottom = scrollTop + viewportHeight + overscanPx;
|
||||
const gap = dashcamRouteGap(host);
|
||||
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
let topHeight = 0;
|
||||
let cursor = 0;
|
||||
|
||||
while (start < count) {
|
||||
const height = dashcamRouteHeightFor(list[start]?.route) + (start > 0 ? gap : 0);
|
||||
if (cursor + height >= minTop) break;
|
||||
cursor += height;
|
||||
topHeight = cursor;
|
||||
start += 1;
|
||||
}
|
||||
|
||||
end = start;
|
||||
let endHeight = topHeight;
|
||||
while (end < count && endHeight < maxBottom) {
|
||||
endHeight += dashcamRouteHeightFor(list[end]?.route) + (end > 0 ? gap : 0);
|
||||
end += 1;
|
||||
}
|
||||
const minEnd = Math.min(count, Math.max(end + DASHCAM_ROUTE_WINDOW_OVERSCAN, start + 1));
|
||||
while (end < minEnd) {
|
||||
endHeight += dashcamRouteHeightFor(list[end]?.route) + (end > 0 ? gap : 0);
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let totalHeight = topHeight;
|
||||
for (let i = start; i < count; i += 1) {
|
||||
totalHeight += dashcamRouteHeightFor(list[i]?.route) + (i > 0 ? gap : 0);
|
||||
}
|
||||
|
||||
const bottomHeight = Math.max(0, totalHeight - endHeight);
|
||||
return { start, end, topHeight, bottomHeight };
|
||||
}
|
||||
|
||||
function screenrecordShouldLoadMore(scroller) {
|
||||
if (!scroller || !screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore) return false;
|
||||
const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
return remaining <= SCREENRECORD_LOAD_AHEAD_PX;
|
||||
}
|
||||
|
||||
function dashcamShouldLoadMore(scroller) {
|
||||
if (!scroller || !dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore) return false;
|
||||
const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
return remaining <= DASHCAM_LOAD_AHEAD_PX;
|
||||
}
|
||||
|
||||
function screenrecordWindowFor(host, count) {
|
||||
const rowHeight = Math.max(48, Number(screenrecordState.rowHeight) || 80);
|
||||
const viewportHeight = Math.max(1, host?.clientHeight || rowHeight * 8);
|
||||
const scrollTop = Math.max(0, host?.scrollTop || 0);
|
||||
const visibleRows = Math.ceil(viewportHeight / rowHeight);
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - SCREENRECORD_WINDOW_OVERSCAN);
|
||||
const end = Math.min(count, start + visibleRows + (SCREENRECORD_WINDOW_OVERSCAN * 2));
|
||||
return { start, end, rowHeight };
|
||||
}
|
||||
|
||||
function screenrecordMeasureRowHeight(host) {
|
||||
const row = host?.querySelector?.(".screenrecord-row");
|
||||
if (!row) return;
|
||||
const styles = window.getComputedStyle?.(host);
|
||||
const gap = Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0;
|
||||
const nextHeight = Math.max(48, row.getBoundingClientRect().height + gap);
|
||||
if (Math.abs(nextHeight - screenrecordState.rowHeight) < 1) return;
|
||||
screenrecordState.rowHeight = nextHeight;
|
||||
}
|
||||
|
||||
function screenrecordSpacerNode(height, position) {
|
||||
if (height <= 0) return null;
|
||||
const node = document.createElement("div");
|
||||
node.className = "screenrecord-virtual-spacer";
|
||||
node.dataset.spacer = position;
|
||||
node.style.height = `${Math.round(height)}px`;
|
||||
return node;
|
||||
}
|
||||
|
||||
function screenrecordRowNode(video, index, existingRows) {
|
||||
const id = String(video?.id || "");
|
||||
const existing = id ? existingRows.get(id) : null;
|
||||
if (existing) {
|
||||
existing.style.setProperty("--i", String(index));
|
||||
existing.classList.remove("ui-stagger-item");
|
||||
return existing;
|
||||
}
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = screenrecordVideoRowHtml(video, index);
|
||||
return template.content.firstElementChild;
|
||||
}
|
||||
|
||||
function patchScreenrecordWindow(host, videos, view) {
|
||||
const existingRows = new Map(
|
||||
Array.from(host.querySelectorAll(".screenrecord-row"))
|
||||
.map((node) => [node.dataset.id || "", node])
|
||||
.filter(([id]) => Boolean(id))
|
||||
);
|
||||
const frag = document.createDocumentFragment();
|
||||
const topSpacer = screenrecordSpacerNode(view.start * view.rowHeight, "top");
|
||||
const bottomSpacer = screenrecordSpacerNode((videos.length - view.end) * view.rowHeight, "bottom");
|
||||
if (topSpacer) frag.appendChild(topSpacer);
|
||||
videos.slice(view.start, view.end).forEach((video, offset) => {
|
||||
const row = screenrecordRowNode(video, view.start + offset, existingRows);
|
||||
if (row) frag.appendChild(row);
|
||||
});
|
||||
if (bottomSpacer) frag.appendChild(bottomSpacer);
|
||||
unobserveLogsLazyImages(host);
|
||||
host.replaceChildren(frag);
|
||||
}
|
||||
|
||||
function setScreenrecordLoadingMoreUi(active) {
|
||||
const host = document.getElementById("screenrecordVideos");
|
||||
if (!host) return;
|
||||
host.classList.toggle("is-loading-more", Boolean(active));
|
||||
}
|
||||
|
||||
function scheduleScreenrecordWindowRender() {
|
||||
if (screenrecordState.renderFrame) return;
|
||||
screenrecordState.renderFrame = requestAnimationFrame(() => {
|
||||
screenrecordState.renderFrame = 0;
|
||||
renderScreenrecordVideos({ preserve: true });
|
||||
});
|
||||
}
|
||||
|
||||
function loadLogsLazyImage(img) {
|
||||
@@ -186,6 +330,13 @@ function disconnectLogsLazyImages() {
|
||||
logsLazyImageObserver = null;
|
||||
}
|
||||
|
||||
function unobserveLogsLazyImages(root) {
|
||||
if (!logsLazyImageObserver || !root) return;
|
||||
root.querySelectorAll?.("img[data-src]").forEach((img) => {
|
||||
logsLazyImageObserver.unobserve(img);
|
||||
});
|
||||
}
|
||||
|
||||
function logsLoadingSkeletonHtml(type = "dashcam") {
|
||||
const count = type === "screen" ? 6 : 4;
|
||||
const itemClass = type === "screen" ? "logs-loading-row" : "logs-loading-card";
|
||||
@@ -207,45 +358,138 @@ function logsEmptyStateHtml(type = "dashcam") {
|
||||
}
|
||||
|
||||
function cancelDashcamRouteRender() {
|
||||
dashcamRouteRenderToken += 1;
|
||||
if (dashcamRouteRenderIdleId != null) {
|
||||
if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(dashcamRouteRenderIdleId);
|
||||
else window.clearTimeout(dashcamRouteRenderIdleId);
|
||||
dashcamRouteRenderIdleId = null;
|
||||
if (dashcamState.renderFrame) {
|
||||
window.cancelAnimationFrame(dashcamState.renderFrame);
|
||||
dashcamState.renderFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function appendDashcamRouteBatch(host, routes, options) {
|
||||
const token = options.token;
|
||||
if (token !== dashcamRouteRenderToken || !isLogsPageActive() || !host || !Array.isArray(routes)) return;
|
||||
function setDashcamLoadingMoreUi(active) {
|
||||
const host = document.getElementById("dashcamRoutes");
|
||||
if (!host) return;
|
||||
host.classList.toggle("is-loading-more", Boolean(active));
|
||||
}
|
||||
|
||||
const start = options.start || 0;
|
||||
const batchSize = start === 0 ? DASHCAM_ROUTE_INITIAL_BATCH : DASHCAM_ROUTE_BATCH_SIZE;
|
||||
const end = Math.min(start + batchSize, routes.length);
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = routes
|
||||
.slice(start, end)
|
||||
.map((entry, offset) => dashcamRouteCardHtml(entry, start + offset, {
|
||||
animate: options.animate,
|
||||
animateIndex: offset,
|
||||
}))
|
||||
.join("");
|
||||
const nodes = Array.from(template.content.children);
|
||||
host.appendChild(template.content);
|
||||
nodes.forEach((node) => hydrateLogsLazyImages(node));
|
||||
host.dataset.renderCount = String(end);
|
||||
function dashcamSpacerNode(height, position) {
|
||||
if (height <= 0) return null;
|
||||
const node = document.createElement("div");
|
||||
node.className = "dashcam-virtual-spacer";
|
||||
node.dataset.spacer = position;
|
||||
node.style.height = `${Math.round(height)}px`;
|
||||
return node;
|
||||
}
|
||||
|
||||
if (end >= routes.length) {
|
||||
host.dataset.signature = dashcamState.signature || dashcamRoutesSignature(routes);
|
||||
return;
|
||||
function dashcamRouteRenderKey(entry) {
|
||||
const route = String(entry?.route || "");
|
||||
const selected = dashcamSelectedForRoute(entry || { segmentFolders: [] }).join(",");
|
||||
const segments = Array.isArray(entry?.segmentFolders) ? entry.segmentFolders.join(",") : "";
|
||||
return [
|
||||
isCompactLandscapeMode() ? "landscape" : "portrait",
|
||||
dashcamState.expanded.has(route) ? "expanded" : "collapsed",
|
||||
typeof LANG !== "undefined" ? LANG : "",
|
||||
entry?.title || "",
|
||||
entry?.dateLabel || "",
|
||||
entry?.latestModifiedEpoch || "",
|
||||
entry?.latestModifiedLabel || "",
|
||||
segments,
|
||||
selected,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function dashcamRouteNode(entry, index, existingCards, options = {}) {
|
||||
const route = String(entry?.route || "");
|
||||
const nextRenderKey = dashcamRouteRenderKey(entry);
|
||||
const existing = route ? existingCards.get(route) : null;
|
||||
if (existing && existing.dataset.renderKey === nextRenderKey) {
|
||||
existing.style.setProperty("--i", String(index));
|
||||
existing.dataset.routeIndex = String(index);
|
||||
existing.classList.remove("ui-stagger-item");
|
||||
existing.querySelectorAll(".ui-stagger-item").forEach((node) => node.classList.remove("ui-stagger-item"));
|
||||
return existing;
|
||||
}
|
||||
dashcamRouteRenderIdleId = requestIdleTask(() => {
|
||||
dashcamRouteRenderIdleId = null;
|
||||
appendDashcamRouteBatch(host, routes, {
|
||||
...options,
|
||||
start: end,
|
||||
});
|
||||
}, 180);
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = dashcamRouteCardHtml(entry, index, {
|
||||
animate: options.animate,
|
||||
animateIndex: index,
|
||||
});
|
||||
return template.content.firstElementChild;
|
||||
}
|
||||
|
||||
function patchDashcamWindow(host, routes, view, options = {}) {
|
||||
const existingCards = new Map(
|
||||
Array.from(host.querySelectorAll("[data-route-card]"))
|
||||
.map((node) => [node.dataset.routeCard || "", node])
|
||||
.filter(([route]) => Boolean(route))
|
||||
);
|
||||
const frag = document.createDocumentFragment();
|
||||
const topSpacer = dashcamSpacerNode(view.topHeight, "top");
|
||||
const bottomSpacer = dashcamSpacerNode(view.bottomHeight, "bottom");
|
||||
if (topSpacer) frag.appendChild(topSpacer);
|
||||
routes.slice(view.start, view.end).forEach((entry, offset) => {
|
||||
const card = dashcamRouteNode(entry, view.start + offset, existingCards, options);
|
||||
if (card) frag.appendChild(card);
|
||||
});
|
||||
if (bottomSpacer) frag.appendChild(bottomSpacer);
|
||||
unobserveLogsLazyImages(host);
|
||||
host.replaceChildren(frag);
|
||||
}
|
||||
|
||||
function measureDashcamRouteHeights(host) {
|
||||
if (!host) return false;
|
||||
const gap = dashcamRouteGap(host);
|
||||
const cards = Array.from(host.querySelectorAll("[data-route-card]"));
|
||||
let changed = false;
|
||||
let total = 0;
|
||||
let measured = 0;
|
||||
|
||||
cards.forEach((card) => {
|
||||
const route = card.dataset.routeCard || "";
|
||||
const index = Number.parseInt(card.dataset.routeIndex || "0", 10) || 0;
|
||||
const height = Math.max(120, card.getBoundingClientRect().height + (index > 0 ? gap : 0));
|
||||
if (!route || !Number.isFinite(height)) return;
|
||||
if (!dashcamState.expanded.has(route) || isCompactLandscapeMode()) {
|
||||
total += height;
|
||||
measured += 1;
|
||||
}
|
||||
if (Math.abs((Number(dashcamState.routeHeights[route]) || 0) - height) > 1) {
|
||||
dashcamState.routeHeights[route] = height;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (measured) {
|
||||
const average = total / measured;
|
||||
if (Number.isFinite(average) && Math.abs(average - dashcamState.routeHeight) > 1) {
|
||||
dashcamState.routeHeight = average;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function scheduleDashcamWindowRender() {
|
||||
if (dashcamState.renderFrame) return;
|
||||
dashcamState.renderFrame = requestAnimationFrame(() => {
|
||||
dashcamState.renderFrame = 0;
|
||||
renderDashcamRoutes({ preserve: true, animate: false });
|
||||
});
|
||||
}
|
||||
|
||||
function dashcamWindowNeedsRender(host) {
|
||||
if (!host || !(dashcamState.routes || []).length) return false;
|
||||
const cards = Array.from(host.querySelectorAll("[data-route-card]"));
|
||||
if (!cards.length) return true;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const firstRect = cards[0].getBoundingClientRect();
|
||||
const lastRect = cards[cards.length - 1].getBoundingClientRect();
|
||||
const buffer = dashcamRouteHeightFor("") * 2;
|
||||
return firstRect.top > hostRect.top - buffer || lastRect.bottom < hostRect.bottom + buffer;
|
||||
}
|
||||
|
||||
function maybeLoadMoreDashcamRoutes(scroller = document.getElementById("dashcamRoutes")) {
|
||||
if (!dashcamShouldLoadMore(scroller)) return;
|
||||
loadDashcamRoutes({ silent: true, append: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function dashcamSelectedForRoute(entry) {
|
||||
@@ -256,6 +500,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
|
||||
const animate = options.animate !== false;
|
||||
const animateIndex = Number.isFinite(options.animateIndex) ? options.animateIndex : index;
|
||||
const route = String(entry.route || "");
|
||||
const renderKey = escapeHtml(dashcamRouteRenderKey(entry));
|
||||
const segments = Array.isArray(entry.segmentFolders) ? entry.segmentFolders : [];
|
||||
const expanded = dashcamState.expanded.has(route);
|
||||
const compactSegments = isCompactLandscapeMode();
|
||||
@@ -323,7 +568,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
|
||||
</div>`;
|
||||
}).join("") : "";
|
||||
|
||||
return `<article class="dashcam-route-card${animate ? " ui-stagger-item" : ""}"${animate ? ` style="--i:${animateIndex}"` : ""} data-route-card="${routeAttr}">
|
||||
return `<article class="dashcam-route-card${animate ? " ui-stagger-item" : ""}"${animate ? ` style="--i:${animateIndex}"` : ""} data-route-card="${routeAttr}" data-route-index="${index}" data-render-key="${renderKey}">
|
||||
${preview}
|
||||
<div class="dashcam-route-main">
|
||||
<div class="dashcam-route-head" data-action="toggle-route" data-route="${routeAttr}">
|
||||
@@ -370,17 +615,25 @@ function renderDashcamRoutes(options = {}) {
|
||||
return;
|
||||
}
|
||||
setDashcamStatus("");
|
||||
if (preserve && hasCompleteDashcamDom(host, routes)) {
|
||||
const view = dashcamWindowFor(host, routes);
|
||||
const nextSignature = `${dashcamState.signature || dashcamRoutesSignature(routes)}|${dashcamLayoutKey()}|${view.start}:${view.end}|${Math.round(view.topHeight)}:${Math.round(view.bottomHeight)}`;
|
||||
if (preserve && host.dataset.signature === nextSignature) {
|
||||
hydrateLogsLazyImages(host);
|
||||
return;
|
||||
}
|
||||
host.innerHTML = "";
|
||||
host.dataset.signature = "";
|
||||
host.dataset.renderCount = "0";
|
||||
appendDashcamRouteBatch(host, routes, {
|
||||
patchDashcamWindow(host, routes, view, {
|
||||
animate,
|
||||
start: 0,
|
||||
token: dashcamRouteRenderToken,
|
||||
});
|
||||
host.dataset.signature = nextSignature;
|
||||
host.dataset.renderCount = String(view.end - view.start);
|
||||
host.dataset.windowStart = String(view.start);
|
||||
host.dataset.windowEnd = String(view.end);
|
||||
dashcamState.windowStart = view.start;
|
||||
dashcamState.windowEnd = view.end;
|
||||
hydrateLogsLazyImages(host);
|
||||
requestAnimationFrame(() => {
|
||||
if (!isLogsPageActive()) return;
|
||||
if (measureDashcamRouteHeights(host) && !dashcamState.scrollBusy) scheduleDashcamWindowRender();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,7 +655,12 @@ function renderDashcamRoute(route) {
|
||||
if (!nextMain || !currentMain) return false;
|
||||
|
||||
currentMain.replaceWith(nextMain);
|
||||
current.dataset.renderKey = dashcamRouteRenderKey(routes[index]);
|
||||
hydrateLogsLazyImages(nextMain);
|
||||
requestAnimationFrame(() => {
|
||||
if (!isLogsPageActive()) return;
|
||||
if (measureDashcamRouteHeights(host)) scheduleDashcamWindowRender();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -436,41 +694,82 @@ function updateDashcamRouteSelectionUi(route) {
|
||||
const segment = input.dataset.segment || "";
|
||||
input.checked = dashcamState.selected.has(segment);
|
||||
});
|
||||
card.dataset.renderKey = dashcamRouteRenderKey(entry);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadDashcamRoutes({ silent = false } = {}) {
|
||||
async function loadDashcamRoutes({ silent = false, append = false } = {}) {
|
||||
if (append && (!dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore)) return;
|
||||
const seq = ++dashcamState.loadSeq;
|
||||
if (!silent) {
|
||||
if (append) {
|
||||
dashcamState.loadingMore = true;
|
||||
setDashcamLoadingMoreUi(true);
|
||||
} else if (!silent) {
|
||||
dashcamState.loading = true;
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
renderDashcamRoutes();
|
||||
}
|
||||
try {
|
||||
const json = await getJson("/api/dashcam/routes");
|
||||
if (seq !== dashcamState.loadSeq) return;
|
||||
if (!isLogsPageActive()) {
|
||||
dashcamState.loading = false;
|
||||
const offset = append ? (dashcamState.nextOffset || dashcamState.routes.length || 0) : 0;
|
||||
const currentCount = dashcamState.routes.length || 0;
|
||||
const limit = append ? DASHCAM_PAGE_SIZE : Math.max(DASHCAM_PAGE_SIZE, currentCount || 0);
|
||||
const json = await getJson(`/api/dashcam/routes?offset=${offset}&limit=${limit}`);
|
||||
if (seq !== dashcamState.loadSeq) {
|
||||
if (append) {
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const routes = Array.isArray(json.routes) ? json.routes : [];
|
||||
if (!isLogsPageActive()) {
|
||||
dashcamState.loading = false;
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
return;
|
||||
}
|
||||
const incoming = Array.isArray(json.routes) ? json.routes : [];
|
||||
const routes = append ? dashcamState.routes.concat(incoming) : incoming;
|
||||
const nextSignature = dashcamRoutesSignature(routes);
|
||||
if (silent && nextSignature === dashcamState.signature) {
|
||||
dashcamState.loading = false;
|
||||
dashcamState.loadingMore = false;
|
||||
dashcamState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : routes.length;
|
||||
dashcamState.nextOffset = json.nextOffset == null ? routes.length : Number(json.nextOffset) || routes.length;
|
||||
dashcamState.hasMore = Boolean(json.hasMore);
|
||||
setDashcamLoadingMoreUi(false);
|
||||
return;
|
||||
}
|
||||
const validRoutes = new Set(routes.map((entry) => entry.route));
|
||||
const validSegments = new Set(routes.flatMap((entry) => entry.segmentFolders || []));
|
||||
dashcamState.expanded = new Set(Array.from(dashcamState.expanded).filter((route) => validRoutes.has(route)));
|
||||
dashcamState.selected = new Set(Array.from(dashcamState.selected).filter((segment) => validSegments.has(segment)));
|
||||
dashcamState.routeHeights = Object.fromEntries(
|
||||
Object.entries(dashcamState.routeHeights || {}).filter(([route]) => validRoutes.has(route))
|
||||
);
|
||||
dashcamState.routes = routes;
|
||||
dashcamState.signature = nextSignature;
|
||||
dashcamState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : routes.length;
|
||||
dashcamState.nextOffset = json.nextOffset == null ? routes.length : Number(json.nextOffset) || routes.length;
|
||||
dashcamState.hasMore = Boolean(json.hasMore);
|
||||
dashcamState.loading = false;
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
renderDashcamRoutes({ animate: !silent });
|
||||
if (!silent && logsScrollTops.dashcam === 0) restoreLogsScrollTop("dashcam", { reset: true });
|
||||
requestAnimationFrame(() => maybeLoadMoreDashcamRoutes());
|
||||
} catch (e) {
|
||||
if (seq !== dashcamState.loadSeq) return;
|
||||
if (seq !== dashcamState.loadSeq) {
|
||||
if (append) {
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
dashcamState.loading = false;
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
if (!silent && isLogsPageActive()) {
|
||||
setDashcamStatus(`${getUIText("dashcam_load_failed", "Failed to load dashcam list")}: ${e.message || e}`, "error");
|
||||
showAppToast(e.message || getUIText("dashcam_load_failed", "Failed to load dashcam list"), { tone: "error" });
|
||||
@@ -483,7 +782,7 @@ function startDashcamAutoRefresh() {
|
||||
dashcamState.refreshTimer = window.setInterval(() => {
|
||||
if (CURRENT_PAGE !== "logs" || dashcamState.scrollBusy) return;
|
||||
if (logsActiveTab === "screen") loadScreenrecordVideos({ silent: true }).catch(() => {});
|
||||
else loadDashcamRoutes({ silent: true }).catch(() => {});
|
||||
else if (!dashcamState.loading && !dashcamState.loadingMore) loadDashcamRoutes({ silent: true }).catch(() => {});
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
@@ -492,6 +791,7 @@ function markDashcamScrollBusy() {
|
||||
if (dashcamState.scrollTimer) window.clearTimeout(dashcamState.scrollTimer);
|
||||
dashcamState.scrollTimer = window.setTimeout(() => {
|
||||
dashcamState.scrollBusy = false;
|
||||
if (isLogsPageActive() && logsActiveTab === "dashcam") scheduleDashcamWindowRender();
|
||||
}, 380);
|
||||
}
|
||||
|
||||
@@ -501,15 +801,8 @@ function openLogsVideoPlayer(title, src, options = {}) {
|
||||
overlay.className = `dashcam-player-overlay dashcam-player-overlay--${kind}`;
|
||||
overlay.innerHTML = `<div class="dashcam-player-dialog" role="dialog" aria-modal="true">
|
||||
<div class="dashcam-player-frame">
|
||||
<video class="dashcam-player-video" autoplay controls playsinline src="${src}"></video>
|
||||
<div class="dashcam-player-controls" aria-label="${escapeHtml(getUIText("video_controls", "Video controls"))}">
|
||||
<button class="dashcam-player-control" type="button" data-skip="-5" aria-label="${escapeHtml(getUIText("rewind_5", "Back 5 seconds"))}" title="${escapeHtml(getUIText("rewind_5", "Back 5 seconds"))}">
|
||||
<span aria-hidden="true">-5</span>
|
||||
</button>
|
||||
<button class="dashcam-player-control" type="button" data-skip="5" aria-label="${escapeHtml(getUIText("forward_5", "Forward 5 seconds"))}" title="${escapeHtml(getUIText("forward_5", "Forward 5 seconds"))}">
|
||||
<span aria-hidden="true">+5</span>
|
||||
</button>
|
||||
</div>
|
||||
<video class="dashcam-player-video" playsinline></video>
|
||||
<div class="dashcam-player-toast" aria-live="polite"></div>
|
||||
<div class="dashcam-player-top">
|
||||
<div class="dashcam-player-title">${escapeHtml(title || "Video")}</div>
|
||||
<button class="dashcam-player-close" type="button" aria-label="${escapeHtml(getUIText("close", "Close"))}" title="${escapeHtml(getUIText("close", "Close"))}">
|
||||
@@ -518,105 +811,73 @@ function openLogsVideoPlayer(title, src, options = {}) {
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
const videoEl = overlay.querySelector("video");
|
||||
const toastEl = overlay.querySelector(".dashcam-player-toast");
|
||||
const downloadUrl = src + (src.includes("?") ? "&" : "?") + "download=1";
|
||||
let toastTimer = null;
|
||||
let suppressToasts = true;
|
||||
const showToast = (text) => {
|
||||
if (!toastEl || suppressToasts || !text) return;
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add("is-visible");
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => toastEl.classList.remove("is-visible"), 850);
|
||||
};
|
||||
let player = null;
|
||||
const close = () => {
|
||||
const video = overlay.querySelector("video");
|
||||
clearHideControlsTimer();
|
||||
try { video?.pause?.(); } catch {}
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
try { player?.destroy?.(); } catch {}
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector(".dashcam-player-close")?.addEventListener("click", close);
|
||||
const video = overlay.querySelector("video");
|
||||
const frame = overlay.querySelector(".dashcam-player-frame");
|
||||
let hideControlsTimer = null;
|
||||
if (video) video.controls = true;
|
||||
const clearHideControlsTimer = () => {
|
||||
if (!hideControlsTimer) return;
|
||||
window.clearTimeout(hideControlsTimer);
|
||||
hideControlsTimer = null;
|
||||
};
|
||||
const scheduleHidePlayerControls = () => {
|
||||
clearHideControlsTimer();
|
||||
if (!video || video.paused || video.ended) return;
|
||||
hideControlsTimer = window.setTimeout(() => {
|
||||
if (!video || video.paused || video.ended) return;
|
||||
overlay.classList.add("is-player-controls-hidden");
|
||||
}, 2200);
|
||||
};
|
||||
const showPlayerControls = () => {
|
||||
overlay.classList.remove("is-player-controls-hidden");
|
||||
scheduleHidePlayerControls();
|
||||
};
|
||||
const seekVideo = (delta) => {
|
||||
if (!video) return;
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : Infinity;
|
||||
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
||||
video.currentTime = Math.max(0, Math.min(duration, current + delta));
|
||||
};
|
||||
const isPlayerControlTarget = (target) => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("button, .dashcam-player-top, .dashcam-player-controls"));
|
||||
};
|
||||
const syncPlayerControlsVisibility = () => {
|
||||
if (!video) return;
|
||||
const paused = video.paused || video.ended;
|
||||
if (paused) {
|
||||
clearHideControlsTimer();
|
||||
overlay.classList.remove("is-player-controls-hidden");
|
||||
} else {
|
||||
scheduleHidePlayerControls();
|
||||
}
|
||||
};
|
||||
video?.addEventListener("play", syncPlayerControlsVisibility);
|
||||
video?.addEventListener("pause", syncPlayerControlsVisibility);
|
||||
video?.addEventListener("ended", syncPlayerControlsVisibility);
|
||||
overlay.querySelectorAll("[data-skip]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const delta = Number(button.dataset.skip || 0);
|
||||
seekVideo(delta);
|
||||
showPlayerControls();
|
||||
});
|
||||
});
|
||||
frame?.addEventListener("mousemove", showPlayerControls);
|
||||
frame?.addEventListener("touchstart", showPlayerControls, { passive: true });
|
||||
frame?.addEventListener("click", (ev) => {
|
||||
if (isPlayerControlTarget(ev.target)) return;
|
||||
showPlayerControls();
|
||||
});
|
||||
frame?.addEventListener("dblclick", (ev) => {
|
||||
if (isPlayerControlTarget(ev.target)) return;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const x = ev.clientX - rect.left;
|
||||
seekVideo(x < rect.width / 2 ? -5 : 5);
|
||||
showPlayerControls();
|
||||
});
|
||||
let lastPlayerTap = { time: 0, x: 0, y: 0 };
|
||||
frame?.addEventListener("touchend", (ev) => {
|
||||
if (isPlayerControlTarget(ev.target)) return;
|
||||
const touch = ev.changedTouches?.[0];
|
||||
if (!touch) return;
|
||||
const now = performance.now();
|
||||
const dx = touch.clientX - lastPlayerTap.x;
|
||||
const dy = touch.clientY - lastPlayerTap.y;
|
||||
const isDoubleTap = now - lastPlayerTap.time < 320 && Math.hypot(dx, dy) < 34;
|
||||
if (isDoubleTap) {
|
||||
ev.preventDefault();
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const x = touch.clientX - rect.left;
|
||||
seekVideo(x < rect.width / 2 ? -5 : 5);
|
||||
showPlayerControls();
|
||||
lastPlayerTap = { time: 0, x: 0, y: 0 };
|
||||
return;
|
||||
}
|
||||
lastPlayerTap = { time: now, x: touch.clientX, y: touch.clientY };
|
||||
}, { passive: false });
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add("is-open");
|
||||
syncPlayerControlsVisibility();
|
||||
showPlayerControls();
|
||||
try {
|
||||
player = new Plyr(videoEl, {
|
||||
controls: ["play-large","rewind","play","fast-forward","progress","current-time","fullscreen","download"],
|
||||
hideControls: false,
|
||||
seekTime: 5,
|
||||
keyboard: { focused: true, global: false },
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true },
|
||||
urls: { download: downloadUrl },
|
||||
});
|
||||
player.source = {
|
||||
type: "video",
|
||||
title: title || "Video",
|
||||
sources: [{ src, type: "video/mp4" }],
|
||||
};
|
||||
player.once("ready", () => {
|
||||
try { player.play()?.catch?.(() => {}); } catch {}
|
||||
const container = player.elements?.container || overlay;
|
||||
const bindBtn = (sel, label) => {
|
||||
container.querySelectorAll(sel).forEach((btn) => btn.addEventListener("click", () => showToast(label)));
|
||||
};
|
||||
bindBtn('[data-plyr="rewind"]', `⏪ ${getUIText("rewind_5", "5s")}`);
|
||||
bindBtn('[data-plyr="fast-forward"]', `${getUIText("forward_5", "5s")} ⏩`);
|
||||
bindBtn('[data-plyr="download"]', `⤓ ${getUIText("download", "Download")}`);
|
||||
container.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "ArrowLeft") showToast(`⏪ ${getUIText("rewind_5", "5s")}`);
|
||||
else if (ev.key === "ArrowRight") showToast(`${getUIText("forward_5", "5s")} ⏩`);
|
||||
});
|
||||
player.on("play", () => showToast(`▶ ${getUIText("play", "Play")}`));
|
||||
player.on("pause", () => showToast(`⏸ ${getUIText("pause", "Pause")}`));
|
||||
player.on("ended", () => showToast(getUIText("ended", "End")));
|
||||
player.on("ratechange", () => showToast(`⚡ ${player.speed}x`));
|
||||
player.on("enterfullscreen", () => showToast(`⛶ ${getUIText("fullscreen", "Fullscreen")}`));
|
||||
player.on("exitfullscreen", () => showToast(getUIText("fullscreen_exit", "Exit fullscreen")));
|
||||
videoEl.addEventListener("enterpictureinpicture", () => showToast("⊞ PiP"));
|
||||
videoEl.addEventListener("leavepictureinpicture", () => showToast(`⊟ ${getUIText("pip_exit", "Exit PiP")}`));
|
||||
window.setTimeout(() => { suppressToasts = false; }, 350);
|
||||
});
|
||||
} catch (err) {
|
||||
videoEl.controls = true;
|
||||
videoEl.src = src;
|
||||
videoEl.play?.().catch?.(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -950,43 +1211,69 @@ function renderScreenrecordVideos(options = {}) {
|
||||
return;
|
||||
}
|
||||
setScreenrecordStatus("");
|
||||
if (preserve && hasCompleteScreenrecordDom(host, videos)) {
|
||||
const view = screenrecordWindowFor(host, videos.length);
|
||||
const nextSignature = `${screenrecordState.signature || screenrecordVideosSignature(videos)}|${view.start}:${view.end}|${screenrecordState.loadingMore ? "more" : ""}`;
|
||||
if (preserve && host.dataset.signature === nextSignature) {
|
||||
hydrateLogsLazyImages(host);
|
||||
return;
|
||||
}
|
||||
host.innerHTML = videos.map(screenrecordVideoRowHtml).join("");
|
||||
host.dataset.signature = screenrecordState.signature || screenrecordVideosSignature(videos);
|
||||
host.dataset.renderCount = String(videos.length);
|
||||
patchScreenrecordWindow(host, videos, view);
|
||||
host.dataset.signature = nextSignature;
|
||||
host.dataset.renderCount = String(view.end - view.start);
|
||||
screenrecordState.windowStart = view.start;
|
||||
screenrecordState.windowEnd = view.end;
|
||||
setScreenrecordLoadingMoreUi(screenrecordState.loadingMore);
|
||||
hydrateLogsLazyImages(host);
|
||||
requestAnimationFrame(() => screenrecordMeasureRowHeight(host));
|
||||
}
|
||||
|
||||
async function loadScreenrecordVideos({ silent = false } = {}) {
|
||||
async function loadScreenrecordVideos({ silent = false, append = false } = {}) {
|
||||
if (append && (!screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore)) return;
|
||||
const seq = ++screenrecordState.loadSeq;
|
||||
if (!silent) {
|
||||
if (append) {
|
||||
screenrecordState.loadingMore = true;
|
||||
setScreenrecordLoadingMoreUi(true);
|
||||
} else if (!silent) {
|
||||
screenrecordState.loading = true;
|
||||
screenrecordState.loadingMore = false;
|
||||
setScreenrecordLoadingMoreUi(false);
|
||||
renderScreenrecordVideos();
|
||||
}
|
||||
try {
|
||||
const json = await getJson("/api/screenrecord/videos");
|
||||
const offset = append ? (screenrecordState.nextOffset || screenrecordState.videos.length || 0) : 0;
|
||||
const limit = append ? SCREENRECORD_PAGE_SIZE : Math.max(SCREENRECORD_PAGE_SIZE, screenrecordState.videos.length || 0);
|
||||
const json = await getJson(`/api/screenrecord/videos?offset=${offset}&limit=${limit}`);
|
||||
if (seq !== screenrecordState.loadSeq) return;
|
||||
if (!isLogsPageActive()) {
|
||||
screenrecordState.loading = false;
|
||||
screenrecordState.loadingMore = false;
|
||||
setScreenrecordLoadingMoreUi(false);
|
||||
return;
|
||||
}
|
||||
const videos = Array.isArray(json.videos) ? json.videos : [];
|
||||
const incoming = Array.isArray(json.videos) ? json.videos : [];
|
||||
const videos = append ? screenrecordState.videos.concat(incoming) : incoming;
|
||||
const nextSignature = screenrecordVideosSignature(videos);
|
||||
if (silent && nextSignature === screenrecordState.signature) {
|
||||
screenrecordState.loading = false;
|
||||
screenrecordState.loadingMore = false;
|
||||
setScreenrecordLoadingMoreUi(false);
|
||||
return;
|
||||
}
|
||||
screenrecordState.videos = videos;
|
||||
screenrecordState.signature = nextSignature;
|
||||
screenrecordState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : videos.length;
|
||||
screenrecordState.nextOffset = json.nextOffset == null ? videos.length : Number(json.nextOffset) || videos.length;
|
||||
screenrecordState.hasMore = Boolean(json.hasMore);
|
||||
screenrecordState.loading = false;
|
||||
renderScreenrecordVideos();
|
||||
screenrecordState.loadingMore = false;
|
||||
setScreenrecordLoadingMoreUi(false);
|
||||
renderScreenrecordVideos({ animate: !silent });
|
||||
if (!silent && logsScrollTops.screen === 0) restoreLogsScrollTop("screen", { reset: true });
|
||||
} catch (e) {
|
||||
if (seq !== screenrecordState.loadSeq) return;
|
||||
screenrecordState.loading = false;
|
||||
screenrecordState.loadingMore = false;
|
||||
setScreenrecordLoadingMoreUi(false);
|
||||
if (!silent && isLogsPageActive()) {
|
||||
setScreenrecordStatus(`${getUIText("screenrecord_load_failed", "Failed to load screen recordings")}: ${e.message || e}`, "error");
|
||||
showAppToast(e.message || getUIText("screenrecord_load_failed", "Failed to load screen recordings"), { tone: "error" });
|
||||
@@ -1033,6 +1320,8 @@ function handleLogsPageChange(event) {
|
||||
dashcamState.loadSeq += 1;
|
||||
screenrecordState.loadSeq += 1;
|
||||
dashcamState.loading = false;
|
||||
dashcamState.loadingMore = false;
|
||||
setDashcamLoadingMoreUi(false);
|
||||
screenrecordState.loading = false;
|
||||
dashcamState.scrollBusy = false;
|
||||
if (dashcamState.scrollTimer) {
|
||||
@@ -1055,10 +1344,12 @@ function bindLogsPage() {
|
||||
if (!dashcamState.layoutBound) {
|
||||
dashcamState.layoutBound = true;
|
||||
dashcamState.landscape = isCompactLandscapeMode();
|
||||
dashcamState.layoutKey = dashcamLayoutKey();
|
||||
window.addEventListener("carrot:pagechange", handleLogsPageChange);
|
||||
window.addEventListener("carrot:languagechange", () => {
|
||||
dashcamState.signature = "";
|
||||
screenrecordState.signature = "";
|
||||
dashcamState.routeHeights = Object.create(null);
|
||||
const dashcamHost = document.getElementById("dashcamRoutes");
|
||||
if (dashcamHost) dashcamHost.dataset.signature = "";
|
||||
const screenHost = document.getElementById("screenrecordVideos");
|
||||
@@ -1076,9 +1367,16 @@ function bindLogsPage() {
|
||||
dashcamState.layoutTimer = null;
|
||||
if (!isLogsPageActive()) return;
|
||||
const nextLandscape = isCompactLandscapeMode();
|
||||
if (dashcamState.landscape === nextLandscape) return;
|
||||
const nextLayoutKey = dashcamLayoutKey();
|
||||
if (dashcamState.layoutKey === nextLayoutKey) return;
|
||||
dashcamState.landscape = nextLandscape;
|
||||
dashcamState.layoutKey = nextLayoutKey;
|
||||
dashcamState.routeHeights = Object.create(null);
|
||||
dashcamState.routeHeight = dashcamDefaultRouteHeight();
|
||||
const dashcamHost = document.getElementById("dashcamRoutes");
|
||||
if (dashcamHost) dashcamHost.dataset.signature = "";
|
||||
renderDashcamRoutes({ animate: false });
|
||||
if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ preserve: true, animate: false });
|
||||
}, 120);
|
||||
}, { passive: true });
|
||||
}
|
||||
@@ -1098,6 +1396,8 @@ function bindLogsPage() {
|
||||
routesHost.addEventListener("scroll", () => {
|
||||
markDashcamScrollBusy();
|
||||
saveLogsScrollTop("dashcam");
|
||||
if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender();
|
||||
maybeLoadMoreDashcamRoutes(routesHost);
|
||||
}, { passive: true });
|
||||
routesHost.addEventListener("click", (ev) => {
|
||||
const actionEl = ev.target?.closest?.("[data-action]");
|
||||
@@ -1108,6 +1408,7 @@ function bindLogsPage() {
|
||||
if (action === "toggle-route") {
|
||||
if (dashcamState.expanded.has(route)) dashcamState.expanded.delete(route);
|
||||
else dashcamState.expanded.add(route);
|
||||
if (route && dashcamState.routeHeights) delete dashcamState.routeHeights[route];
|
||||
if (!renderDashcamRoute(route)) renderDashcamRoutes({ animate: false });
|
||||
} else if (action === "play") {
|
||||
openDashcamPlayer(route, segment);
|
||||
@@ -1145,6 +1446,10 @@ function bindLogsPage() {
|
||||
screenHost.addEventListener("scroll", () => {
|
||||
markDashcamScrollBusy();
|
||||
saveLogsScrollTop("screen");
|
||||
scheduleScreenrecordWindowRender();
|
||||
if (screenrecordShouldLoadMore(screenHost)) {
|
||||
loadScreenrecordVideos({ silent: true, append: true }).catch(() => {});
|
||||
}
|
||||
}, { passive: true });
|
||||
screenHost.addEventListener("click", (ev) => {
|
||||
const actionEl = ev.target?.closest?.("[data-action]");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let settingsLoadPromise = null;
|
||||
let settingValueWarmupTimer = null;
|
||||
let settingValueWarmupPromise = null;
|
||||
let settingRestoreRefreshTimer = null;
|
||||
const SETTING_VALUES_TTL_MS = 60000;
|
||||
const settingValueCache = new Map();
|
||||
const settingGroupValueCache = new Map();
|
||||
@@ -14,7 +15,189 @@ let settingSubnavSettleTimer = null;
|
||||
let settingSubnavProgrammaticScroll = false;
|
||||
let settingSubnavFocusTimer = null;
|
||||
|
||||
const SETTING_FAVORITES_GROUP = "__setting_favorites__";
|
||||
const SETTING_FAVORITES_LONG_PRESS_MS = 620;
|
||||
const SETTING_FAVORITES_MOVE_TOLERANCE = 10;
|
||||
const settingFavoritesState = {
|
||||
names: [],
|
||||
loaded: false,
|
||||
loadPromise: null,
|
||||
};
|
||||
|
||||
function isSettingFavoritesGroup(group) {
|
||||
return group === SETTING_FAVORITES_GROUP;
|
||||
}
|
||||
|
||||
function normalizeSettingFavoriteNames(names) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
(Array.isArray(names) ? names : []).forEach((item) => {
|
||||
const name = String(item || "").trim();
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function findSettingItemByName(name) {
|
||||
const target = String(name || "").trim();
|
||||
if (!target || !SETTINGS?.items_by_group) return null;
|
||||
|
||||
for (const [group, list] of Object.entries(SETTINGS.items_by_group)) {
|
||||
const item = (list || []).find((entry) => entry?.name === target);
|
||||
if (item) return { group, item };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFavoriteSettingEntries() {
|
||||
return settingFavoritesState.names
|
||||
.map((name) => findSettingItemByName(name))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getValidSettingFavoriteNames() {
|
||||
return getFavoriteSettingEntries().map((entry) => entry.item.name).filter(Boolean);
|
||||
}
|
||||
|
||||
function isSettingFavorite(name) {
|
||||
return settingFavoritesState.names.includes(String(name || "").trim());
|
||||
}
|
||||
|
||||
function getSettingFavoritesLabel() {
|
||||
return getUIText("setting_favorites", "Favorites");
|
||||
}
|
||||
|
||||
function getSettingGroupsForDisplay() {
|
||||
const groups = SETTINGS?.groups || [];
|
||||
return [
|
||||
{
|
||||
group: SETTING_FAVORITES_GROUP,
|
||||
count: getFavoriteSettingEntries().length,
|
||||
virtual: true,
|
||||
},
|
||||
...groups,
|
||||
];
|
||||
}
|
||||
|
||||
function getSettingItemEntriesForGroup(group) {
|
||||
if (isSettingFavoritesGroup(group)) return getFavoriteSettingEntries();
|
||||
return (SETTINGS?.items_by_group?.[group] || []).map((item) => ({ group, item }));
|
||||
}
|
||||
|
||||
async function loadSettingFavorites(force = false) {
|
||||
if (!force && settingFavoritesState.loaded) return settingFavoritesState.names;
|
||||
if (!force && settingFavoritesState.loadPromise) return settingFavoritesState.loadPromise;
|
||||
|
||||
settingFavoritesState.loadPromise = getJson("/api/setting_favorites")
|
||||
.then((payload) => {
|
||||
settingFavoritesState.loaded = true;
|
||||
settingFavoritesState.names = normalizeSettingFavoriteNames(payload?.favorites || []);
|
||||
return settingFavoritesState.names;
|
||||
})
|
||||
.catch(() => {
|
||||
settingFavoritesState.loaded = true;
|
||||
settingFavoritesState.names = [];
|
||||
return settingFavoritesState.names;
|
||||
})
|
||||
.finally(() => {
|
||||
settingFavoritesState.loadPromise = null;
|
||||
});
|
||||
|
||||
return settingFavoritesState.loadPromise;
|
||||
}
|
||||
|
||||
function invalidateSettingFavoriteRenderState() {
|
||||
settingGroupValueCache.delete(SETTING_FAVORITES_GROUP);
|
||||
settingGroupValuePromises.delete(SETTING_FAVORITES_GROUP);
|
||||
const itemsBox = document.getElementById("items");
|
||||
if (itemsBox?.dataset.renderedGroup === SETTING_FAVORITES_GROUP) {
|
||||
delete itemsBox.dataset.renderedGroup;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettingFavoriteMark(name) {
|
||||
const active = isSettingFavorite(name);
|
||||
return `
|
||||
<span class="setting-favorite-mark${active ? " is-active" : ""}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M6 3.5h12a1 1 0 0 1 1 1v16l-7-4-7 4v-16a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateSettingFavoriteRowMarks(root = document.getElementById("items")) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll(".setting[data-setting-name]").forEach((row) => {
|
||||
const active = isSettingFavorite(row.dataset.settingName);
|
||||
row.classList.toggle("is-favorite", active);
|
||||
const mark = row.querySelector(".setting-favorite-mark");
|
||||
if (mark) mark.classList.toggle("is-active", active);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSettingFavoriteChrome(options = {}) {
|
||||
const animateGroups = options.animateGroups === true;
|
||||
renderGroups({ animateGroups });
|
||||
renderSettingSubnav();
|
||||
syncSettingGroupChrome(CURRENT_GROUP);
|
||||
updateSettingFavoriteRowMarks();
|
||||
}
|
||||
|
||||
async function persistSettingFavorites(nextNames) {
|
||||
const payload = await postJson("/api/setting_favorites", {
|
||||
favorites: normalizeSettingFavoriteNames(nextNames),
|
||||
});
|
||||
settingFavoritesState.names = normalizeSettingFavoriteNames(payload?.favorites || nextNames);
|
||||
return settingFavoritesState.names;
|
||||
}
|
||||
|
||||
async function toggleSettingFavorite(name) {
|
||||
const cleanName = String(name || "").trim();
|
||||
if (!cleanName || !findSettingItemByName(cleanName)) return;
|
||||
|
||||
const previous = settingFavoritesState.names.slice();
|
||||
const exists = previous.includes(cleanName);
|
||||
const next = exists
|
||||
? previous.filter((entry) => entry !== cleanName)
|
||||
: [...previous, cleanName];
|
||||
|
||||
settingFavoritesState.names = normalizeSettingFavoriteNames(next);
|
||||
invalidateSettingFavoriteRenderState();
|
||||
refreshSettingFavoriteChrome({ animateGroups: false });
|
||||
|
||||
if (isSettingFavoritesGroup(CURRENT_GROUP)) {
|
||||
const scrollTop = getSettingItemsScrollTop();
|
||||
renderItems(SETTING_FAVORITES_GROUP, {
|
||||
animateItems: false,
|
||||
scrollMode: "restore",
|
||||
scrollTop,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
await persistSettingFavorites(getValidSettingFavoriteNames());
|
||||
invalidateSettingFavoriteRenderState();
|
||||
refreshSettingFavoriteChrome({ animateGroups: false });
|
||||
if (navigator.vibrate) navigator.vibrate(12);
|
||||
showAppToast(exists
|
||||
? getUIText("setting_favorite_removed", "Removed from favorites")
|
||||
: getUIText("setting_favorite_added", "Added to favorites"));
|
||||
} catch (e) {
|
||||
settingFavoritesState.names = previous;
|
||||
invalidateSettingFavoriteRenderState();
|
||||
refreshSettingFavoriteChrome({ animateGroups: false });
|
||||
if (isSettingFavoritesGroup(CURRENT_GROUP)) {
|
||||
renderItems(SETTING_FAVORITES_GROUP, { animateItems: false, scrollMode: "restore" }).catch(() => {});
|
||||
}
|
||||
showAppToast(e?.message || getUIText("setting_favorites_save_failed", "Failed to save favorites"), { tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
function getSettingGroupParamNames(group) {
|
||||
if (isSettingFavoritesGroup(group)) return getValidSettingFavoriteNames();
|
||||
const list = SETTINGS?.items_by_group?.[group] || [];
|
||||
return list.map((item) => item.name).filter(Boolean);
|
||||
}
|
||||
@@ -40,6 +223,22 @@ function primeSettingGroupValueCache(group, values) {
|
||||
});
|
||||
}
|
||||
|
||||
function applyRestoredSettingValuesToRenderedItems(values) {
|
||||
if (!values || typeof values !== "object") return false;
|
||||
let updated = false;
|
||||
document.querySelectorAll(".setting[data-setting-name]").forEach((row) => {
|
||||
const name = row.dataset.settingName;
|
||||
if (!name || !(name in values)) return;
|
||||
const valueButton = row.querySelector(".val");
|
||||
if (!valueButton) return;
|
||||
valueButton.textContent = String(values[name]);
|
||||
row.classList.add("is-restored-live");
|
||||
window.setTimeout(() => row.classList.remove("is-restored-live"), 900);
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function fetchSettingGroupValues(group, options = {}) {
|
||||
if (!group) return {};
|
||||
const force = options.force === true;
|
||||
@@ -192,6 +391,7 @@ async function loadSettings(options = {}) {
|
||||
const meta = document.getElementById("settingsMeta");
|
||||
|
||||
if (SETTINGS && !force) {
|
||||
await loadSettingFavorites();
|
||||
renderGroups({ animateGroups: false });
|
||||
renderSettingSubnav();
|
||||
syncSettingSearchFabState();
|
||||
@@ -212,6 +412,7 @@ async function loadSettings(options = {}) {
|
||||
settingValueCache.clear();
|
||||
settingGroupValueCache.clear();
|
||||
settingGroupValuePromises.clear();
|
||||
await loadSettingFavorites(force);
|
||||
rebuildSettingSearchEntries();
|
||||
|
||||
if (meta) {
|
||||
@@ -262,7 +463,7 @@ async function loadSettings(options = {}) {
|
||||
function renderGroups(options = {}) {
|
||||
const box = document.getElementById("groupList");
|
||||
const animateGroups = options.animateGroups !== false;
|
||||
const groups = SETTINGS.groups || [];
|
||||
const groups = getSettingGroupsForDisplay();
|
||||
const signature = groups.map((g) => `${g.group}:${g.count}`).join("|");
|
||||
|
||||
function setGroupButtonLabel(button, label, count) {
|
||||
@@ -283,6 +484,7 @@ function renderGroups(options = {}) {
|
||||
const g = groups[index];
|
||||
const label = getSettingGroupLabel(g.group);
|
||||
button.className = "btn groupBtn";
|
||||
if (isSettingFavoritesGroup(g.group)) button.classList.add("groupBtn--favorites");
|
||||
if (g.group === CURRENT_GROUP) button.classList.add("active");
|
||||
button.dataset.group = g.group;
|
||||
setGroupButtonLabel(button, label, g.count);
|
||||
@@ -299,6 +501,7 @@ function renderGroups(options = {}) {
|
||||
|
||||
const b = document.createElement("button");
|
||||
b.className = animateGroups ? "btn groupBtn ui-stagger-item" : "btn groupBtn";
|
||||
if (isSettingFavoritesGroup(g.group)) b.classList.add("groupBtn--favorites");
|
||||
if (animateGroups) b.style.setProperty("--i", String(box.children.length));
|
||||
if (g.group === CURRENT_GROUP) b.classList.add("active");
|
||||
b.dataset.group = g.group;
|
||||
@@ -309,11 +512,20 @@ function renderGroups(options = {}) {
|
||||
}
|
||||
|
||||
function getSettingGroupMeta(group) {
|
||||
if (isSettingFavoritesGroup(group)) {
|
||||
return {
|
||||
group,
|
||||
egroup: "Favorites",
|
||||
count: getFavoriteSettingEntries().length,
|
||||
virtual: true,
|
||||
};
|
||||
}
|
||||
const groups = SETTINGS?.groups || [];
|
||||
return groups.find((entry) => entry.group === group) || null;
|
||||
}
|
||||
|
||||
function getSettingGroupLabel(group) {
|
||||
if (isSettingFavoritesGroup(group)) return getSettingFavoritesLabel();
|
||||
const meta = getSettingGroupMeta(group);
|
||||
if (!meta) return group;
|
||||
if (LANG === "zh") return meta.cgroup || meta.egroup || meta.group;
|
||||
@@ -533,7 +745,7 @@ function isCarrotSettingTabActive() {
|
||||
|
||||
function syncSettingGroupChrome(group = CURRENT_GROUP) {
|
||||
const meta = document.getElementById("groupMeta");
|
||||
const list = SETTINGS?.items_by_group?.[group] || [];
|
||||
const list = getSettingItemEntriesForGroup(group);
|
||||
if (meta && group) meta.textContent = `${group} / ${list.length}`;
|
||||
const groupLabel = group ? getSettingGroupLabel(group) : "";
|
||||
if (group) {
|
||||
@@ -756,7 +968,7 @@ function updateSettingSubnavLayoutState() {
|
||||
}
|
||||
|
||||
function getSettingSubnavGroups() {
|
||||
return SETTINGS?.groups || [];
|
||||
return getSettingGroupsForDisplay();
|
||||
}
|
||||
|
||||
function getSettingSubnavGroupIndex(group = CURRENT_GROUP) {
|
||||
@@ -975,13 +1187,14 @@ function stopSettingSubnavMotion() {
|
||||
function renderSettingSubnav() {
|
||||
if (!settingSubnav) return;
|
||||
|
||||
const groups = SETTINGS?.groups || [];
|
||||
const signature = groups.map((entry) => entry.group).join("|");
|
||||
const groups = getSettingSubnavGroups();
|
||||
const signature = groups.map((entry) => `${entry.group}:${entry.count ?? ""}`).join("|");
|
||||
|
||||
if (settingSubnav.dataset.groupsSignature === signature && settingSubnav.children.length === groups.length) {
|
||||
Array.from(settingSubnav.children).forEach((button, index) => {
|
||||
const entry = groups[index];
|
||||
button.className = "setting-subnav__tab";
|
||||
if (isSettingFavoritesGroup(entry.group)) button.classList.add("setting-subnav__tab--favorites");
|
||||
if (entry.group === CURRENT_GROUP) button.classList.add("is-active");
|
||||
button.dataset.group = entry.group;
|
||||
button.textContent = getSettingGroupLabel(entry.group);
|
||||
@@ -998,6 +1211,7 @@ function renderSettingSubnav() {
|
||||
groups.forEach((entry) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "setting-subnav__tab";
|
||||
if (isSettingFavoritesGroup(entry.group)) button.classList.add("setting-subnav__tab--favorites");
|
||||
if (entry.group === CURRENT_GROUP) button.classList.add("is-active");
|
||||
button.dataset.group = entry.group;
|
||||
button.textContent = getSettingGroupLabel(entry.group);
|
||||
@@ -1169,7 +1383,8 @@ async function renderItems(group, options = {}) {
|
||||
delete itemsBox.dataset.renderedGroup;
|
||||
renderSettingSubnav();
|
||||
|
||||
const list = SETTINGS.items_by_group[group] || [];
|
||||
const entries = getSettingItemEntriesForGroup(group);
|
||||
const list = entries.map((entry) => entry.item);
|
||||
if (meta) meta.textContent = `${group} / ${list.length}`;
|
||||
const groupLabel = getSettingGroupLabel(group);
|
||||
settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + groupLabel;
|
||||
@@ -1189,8 +1404,29 @@ async function renderItems(group, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!list.length && isSettingFavoritesGroup(group)) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "setting-favorites-empty";
|
||||
const emptyTitle = document.createElement("div");
|
||||
emptyTitle.className = "setting-favorites-empty__title";
|
||||
emptyTitle.textContent = getUIText("setting_favorites_empty_title", "No favorites");
|
||||
const emptyDesc = document.createElement("div");
|
||||
emptyDesc.className = "setting-favorites-empty__desc";
|
||||
emptyDesc.textContent = getUIText(
|
||||
"setting_favorites_empty_desc",
|
||||
"Long press a setting to add it. Long press again to remove it.",
|
||||
);
|
||||
empty.appendChild(emptyTitle);
|
||||
empty.appendChild(emptyDesc);
|
||||
itemsBox.appendChild(empty);
|
||||
itemsBox.dataset.renderedGroup = group;
|
||||
requestAnimationFrame(resetSettingItemsViewport);
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((p, index) => {
|
||||
const name = p.name;
|
||||
const originGroup = entries[index]?.group || group;
|
||||
if (!(name in UNIT_INDEX)) UNIT_INDEX[name] = 0;
|
||||
|
||||
const title = formatItemText(p, "title", "etitle", "");
|
||||
@@ -1200,7 +1436,8 @@ async function renderItems(group, options = {}) {
|
||||
el.className = animateItems ? "setting ui-stagger-item" : "setting";
|
||||
if (animateItems) el.style.setProperty("--i", String(index));
|
||||
el.dataset.settingName = name;
|
||||
el.dataset.settingGroup = group;
|
||||
el.dataset.settingGroup = originGroup;
|
||||
el.classList.toggle("is-favorite", isSettingFavorite(name));
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "settingTop";
|
||||
@@ -1208,7 +1445,10 @@ async function renderItems(group, options = {}) {
|
||||
const left = document.createElement("div");
|
||||
left.className = "setting-copy";
|
||||
left.innerHTML = `
|
||||
${settingMarqueeHtml(title, "title")}
|
||||
<div class="setting-title-row">
|
||||
${settingMarqueeHtml(title, "title")}
|
||||
${renderSettingFavoriteMark(name)}
|
||||
</div>
|
||||
${settingMarqueeHtml(name, "name")}
|
||||
<div class="muted mt-sm">
|
||||
min=${p.min}, max=${p.max}, default=${p.default}
|
||||
@@ -1283,6 +1523,7 @@ async function renderItems(group, options = {}) {
|
||||
await setParam(name, next);
|
||||
val.textContent = String(next);
|
||||
cacheSettingValue(name, next, group);
|
||||
if (originGroup !== group) cacheSettingValue(name, next, originGroup);
|
||||
} catch (e) {
|
||||
showAppToast((UI_STRINGS[LANG].set_failed || "set failed: ") + e.message, { tone: "error" });
|
||||
}
|
||||
@@ -1349,6 +1590,68 @@ async function renderItems(group, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function bindSettingFavoriteLongPress() {
|
||||
const itemsBox = document.getElementById("items");
|
||||
if (!itemsBox || itemsBox.dataset.favoriteLongPressBound === "1") return;
|
||||
itemsBox.dataset.favoriteLongPressBound = "1";
|
||||
|
||||
let press = null;
|
||||
|
||||
function clearPress() {
|
||||
if (!press) return;
|
||||
if (press.timer) clearTimeout(press.timer);
|
||||
press.row?.classList.remove("is-longpressing");
|
||||
press = null;
|
||||
}
|
||||
|
||||
function isIgnoredFavoritePressTarget(target) {
|
||||
return Boolean(target?.closest?.(".ctrl, button, input, select, textarea, a"));
|
||||
}
|
||||
|
||||
itemsBox.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== undefined && event.button !== 0) return;
|
||||
const row = event.target.closest(".setting[data-setting-name]");
|
||||
if (!row || !itemsBox.contains(row) || isIgnoredFavoritePressTarget(event.target)) return;
|
||||
|
||||
clearPress();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
press = {
|
||||
pointerId: event.pointerId,
|
||||
row,
|
||||
startX,
|
||||
startY,
|
||||
fired: false,
|
||||
timer: window.setTimeout(() => {
|
||||
if (!press || press.row !== row) return;
|
||||
press.fired = true;
|
||||
row.classList.remove("is-longpressing");
|
||||
toggleSettingFavorite(row.dataset.settingName).catch(() => {});
|
||||
}, SETTING_FAVORITES_LONG_PRESS_MS),
|
||||
};
|
||||
row.classList.add("is-longpressing");
|
||||
}, { passive: true });
|
||||
|
||||
itemsBox.addEventListener("pointermove", (event) => {
|
||||
if (!press || press.pointerId !== event.pointerId) return;
|
||||
const dx = Math.abs(event.clientX - press.startX);
|
||||
const dy = Math.abs(event.clientY - press.startY);
|
||||
if (dx > SETTING_FAVORITES_MOVE_TOLERANCE || dy > SETTING_FAVORITES_MOVE_TOLERANCE) {
|
||||
clearPress();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
itemsBox.addEventListener("pointerup", clearPress, { passive: true });
|
||||
itemsBox.addEventListener("pointercancel", clearPress, { passive: true });
|
||||
itemsBox.addEventListener("pointerleave", clearPress, { passive: true });
|
||||
itemsBox.addEventListener("contextmenu", (event) => {
|
||||
if (!event.target.closest(".setting[data-setting-name]")) return;
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
bindSettingFavoriteLongPress();
|
||||
|
||||
async function syncSettingViewportLayout(options = {}) {
|
||||
if (CURRENT_PAGE !== "setting" || !SETTINGS) return;
|
||||
settingViewportLayoutSignature = getSettingViewportLayoutSignature();
|
||||
@@ -1415,6 +1718,41 @@ function scheduleSettingViewportLayoutSync(force = false) {
|
||||
}, 80);
|
||||
}
|
||||
|
||||
window.addEventListener("carrot:paramsrestored", (event) => {
|
||||
const values = event.detail?.values;
|
||||
if (!values || typeof values !== "object") return;
|
||||
const changedNames = new Set(Object.keys(values));
|
||||
Object.entries(values).forEach(([name, value]) => cacheSettingValue(name, value));
|
||||
applyRestoredSettingValuesToRenderedItems(values);
|
||||
for (const [group, cachedGroup] of settingGroupValueCache.entries()) {
|
||||
if (!cachedGroup?.values) continue;
|
||||
let touched = false;
|
||||
changedNames.forEach((name) => {
|
||||
if (name in cachedGroup.values) {
|
||||
cachedGroup.values[name] = values[name];
|
||||
touched = true;
|
||||
}
|
||||
});
|
||||
if (touched) cachedGroup.loadedAt = Date.now();
|
||||
}
|
||||
|
||||
if (!CURRENT_GROUP || !isCarrotSettingTabActive()) return;
|
||||
const currentNames = new Set(getSettingGroupParamNames(CURRENT_GROUP));
|
||||
const affectsCurrentGroup = [...changedNames].some((name) => currentNames.has(name));
|
||||
if (!affectsCurrentGroup) return;
|
||||
if (settingRestoreRefreshTimer) clearTimeout(settingRestoreRefreshTimer);
|
||||
const currentTop = getSettingItemsScrollTop();
|
||||
settingRestoreRefreshTimer = window.setTimeout(() => {
|
||||
settingRestoreRefreshTimer = null;
|
||||
renderItems(CURRENT_GROUP, {
|
||||
forceValues: true,
|
||||
scrollMode: "restore",
|
||||
scrollTop: currentTop,
|
||||
animateItems: false,
|
||||
}).catch(() => {});
|
||||
}, 60);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
scheduleSettingViewportLayoutSync(false);
|
||||
requestAnimationFrame(() => syncSettingMarqueeOverflow(document.getElementById("items") || document));
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
let entryFocusToken = 0;
|
||||
let entryFocusTimer = null;
|
||||
let relativeTimeTimer = null;
|
||||
let collapseRenderTimer = null;
|
||||
let collapsingNotificationId = "";
|
||||
let collapsingUntil = 0;
|
||||
let collapseHost = null;
|
||||
const detailScrollState = new Map();
|
||||
let lastRenderSignature = "";
|
||||
|
||||
function uiText(key, fallback, vars = null) {
|
||||
@@ -461,6 +466,108 @@
|
||||
return Boolean(global.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches);
|
||||
}
|
||||
|
||||
function parseTimeMs(value) {
|
||||
const text = String(value || "").trim().split(",")[0].trim();
|
||||
if (!text) return 0;
|
||||
const amount = Number.parseFloat(text);
|
||||
if (!Number.isFinite(amount)) return 0;
|
||||
return text.endsWith("ms") ? amount : amount * 1000;
|
||||
}
|
||||
|
||||
function detailAnimationMs(node) {
|
||||
if (prefersReducedMotion()) return 0;
|
||||
try {
|
||||
const styles = global.getComputedStyle?.(node);
|
||||
const duration = parseTimeMs(styles?.getPropertyValue("--tools-detail-duration"));
|
||||
return Math.max(180, duration || 320) + 40;
|
||||
} catch {
|
||||
return 360;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCollapseRenderTimer() {
|
||||
if (!collapseRenderTimer) return;
|
||||
global.clearTimeout(collapseRenderTimer);
|
||||
collapseRenderTimer = null;
|
||||
collapsingNotificationId = "";
|
||||
collapsingUntil = 0;
|
||||
collapseHost = null;
|
||||
}
|
||||
|
||||
function isCollapseInProgress(out) {
|
||||
return Boolean(
|
||||
collapseRenderTimer &&
|
||||
collapsingNotificationId &&
|
||||
Date.now() < collapsingUntil &&
|
||||
(!collapseHost || !out || collapseHost === out)
|
||||
);
|
||||
}
|
||||
|
||||
function scrollDetailToLatest(out, id) {
|
||||
if (!id) return;
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, id);
|
||||
const detail = card?.querySelector?.(".tools-console-log__detail");
|
||||
if (!detail) return;
|
||||
detail.scrollTop = detail.scrollHeight;
|
||||
detailScrollState.set(id, {
|
||||
scrollTop: detail.scrollTop,
|
||||
scrollHeight: detail.scrollHeight,
|
||||
clientHeight: detail.clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
function rememberDetailScroll(id, detail) {
|
||||
if (!id || !detail) return;
|
||||
detailScrollState.set(id, {
|
||||
scrollTop: detail.scrollTop,
|
||||
scrollHeight: detail.scrollHeight,
|
||||
clientHeight: detail.clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDetailScroll(out, id) {
|
||||
if (!id) return;
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, id);
|
||||
const detail = card?.querySelector?.(".tools-console-log__detail");
|
||||
const saved = detailScrollState.get(id);
|
||||
if (!detail || !saved) return;
|
||||
const max = Math.max(0, detail.scrollHeight - detail.clientHeight);
|
||||
detail.scrollTop = Math.min(Math.max(0, saved.scrollTop || 0), max);
|
||||
}
|
||||
|
||||
function bindDetailScroller(detail, entry) {
|
||||
if (!detail || detail.dataset.toolsDetailScrollBound === "1") return;
|
||||
detail.dataset.toolsDetailScrollBound = "1";
|
||||
detail.addEventListener("scroll", () => rememberDetailScroll(entry.id, detail), { passive: true });
|
||||
detail.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
|
||||
detail.addEventListener("touchmove", (event) => event.stopPropagation(), { passive: true });
|
||||
}
|
||||
|
||||
function setMeasuredDetailHeight(card) {
|
||||
const wrap = card?.querySelector?.(".tools-console-log__detailWrap");
|
||||
if (!wrap) return;
|
||||
wrap.style.maxHeight = `${wrap.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function animateDetailCollapse(card) {
|
||||
const wrap = card?.querySelector?.(".tools-console-log__detailWrap");
|
||||
if (!wrap) return;
|
||||
wrap.style.maxHeight = `${wrap.scrollHeight}px`;
|
||||
wrap.style.opacity = "1";
|
||||
wrap.style.transform = "translateY(0)";
|
||||
wrap.getBoundingClientRect();
|
||||
card.classList.add("is-collapsing");
|
||||
card.classList.remove("is-expanded");
|
||||
card.setAttribute("aria-expanded", "false");
|
||||
global.requestAnimationFrame(() => {
|
||||
wrap.style.maxHeight = "0px";
|
||||
wrap.style.opacity = "0";
|
||||
wrap.style.transform = "translateY(-6px)";
|
||||
});
|
||||
}
|
||||
|
||||
function getLogScroller(out) {
|
||||
if (!out) return null;
|
||||
const mode = out.dataset.toolsNotificationMode || getMode();
|
||||
@@ -562,7 +669,7 @@
|
||||
scroller.scrollTop = clampScrollTop(scroller, nextTop);
|
||||
}
|
||||
|
||||
function scrollEntryIntoView(out, focus, phase = "settled") {
|
||||
function scrollEntryIntoView(out, focus, phase = "settled", opts = {}) {
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, focus?.id);
|
||||
if (!scroller || !card) return;
|
||||
@@ -582,15 +689,19 @@
|
||||
const viewportHeight = Math.max(scrollerRect.height - topInset - bottomInset, 1);
|
||||
const viewTop = scrollerRect.top + topInset;
|
||||
const viewBottom = scrollerRect.bottom - bottomInset;
|
||||
const targetRect = phase === "opening" ? headRect : cardRect;
|
||||
const heightDelta = Math.max(0, Number(opts.expandedHeightDelta) || 0);
|
||||
const effectiveCard = heightDelta > 0
|
||||
? { top: cardRect.top, bottom: cardRect.bottom + heightDelta, height: cardRect.height + heightDelta }
|
||||
: cardRect;
|
||||
const targetRect = phase === "opening" ? headRect : effectiveCard;
|
||||
let delta = 0;
|
||||
|
||||
if (phase === "opening" && targetRect.top >= viewTop && targetRect.bottom <= viewBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focus?.expanded && phase === "settled" && cardRect.height >= viewportHeight) {
|
||||
delta = cardRect.top - viewTop;
|
||||
if (focus?.expanded && phase !== "opening" && effectiveCard.height >= viewportHeight) {
|
||||
delta = effectiveCard.top - viewTop;
|
||||
} else if (targetRect.top < viewTop) {
|
||||
delta = targetRect.top - viewTop;
|
||||
} else if (targetRect.bottom > viewBottom) {
|
||||
@@ -606,6 +717,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function predictedExpandedHeightDelta(card) {
|
||||
const wrap = card?.querySelector?.(".tools-console-log__detailWrap");
|
||||
if (!wrap) return 0;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
return Math.max(0, wrap.scrollHeight - rect.height);
|
||||
}
|
||||
|
||||
function scheduleEntryFocus(out, focus) {
|
||||
if (!focus?.id) return;
|
||||
entryFocusToken += 1;
|
||||
@@ -617,8 +735,21 @@
|
||||
const mode = getMode();
|
||||
global.requestAnimationFrame(() => {
|
||||
if (token !== entryFocusToken) return;
|
||||
if (focus.expanded && !prefersReducedMotion()) {
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, focus.id);
|
||||
const heightDelta = predictedExpandedHeightDelta(card);
|
||||
scrollEntryIntoView(out, focus, "concurrent", { expandedHeightDelta: heightDelta });
|
||||
const settleDelay = mode === MODE.PORTRAIT ? 380 : 360;
|
||||
entryFocusTimer = global.setTimeout(() => {
|
||||
entryFocusTimer = null;
|
||||
if (token !== entryFocusToken) return;
|
||||
scrollEntryIntoView(out, focus, "settled");
|
||||
}, settleDelay);
|
||||
return;
|
||||
}
|
||||
scrollEntryIntoView(out, focus, "opening");
|
||||
const delay = focus.expanded && !prefersReducedMotion()
|
||||
const delay = focus.expanded
|
||||
? (mode === MODE.PORTRAIT ? 380 : 360)
|
||||
: (mode === MODE.PORTRAIT ? 180 : 160);
|
||||
entryFocusTimer = global.setTimeout(() => {
|
||||
@@ -632,9 +763,6 @@
|
||||
function renderDetail(entry) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "tools-console-log__detailWrap";
|
||||
wrap.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
const inner = document.createElement("span");
|
||||
inner.className = "tools-console-log__detailInner";
|
||||
@@ -647,6 +775,7 @@
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "tools-console-log__detail";
|
||||
detail.textContent = entry.text;
|
||||
bindDetailScroller(detail, entry);
|
||||
inner.appendChild(detail);
|
||||
wrap.appendChild(inner);
|
||||
|
||||
@@ -700,6 +829,26 @@
|
||||
}
|
||||
const nextExpanded = !expanded;
|
||||
pendingEntryFocus = captureEntryInteraction(context.out, entry.id, nextExpanded, keyboard);
|
||||
|
||||
if (!nextExpanded) {
|
||||
clearCollapseRenderTimer();
|
||||
activeNotificationId = "";
|
||||
detailScrollState.delete(entry.id);
|
||||
collapsingNotificationId = entry.id;
|
||||
collapseHost = context.out;
|
||||
animateDetailCollapse(card);
|
||||
collapsingUntil = Date.now() + detailAnimationMs(card);
|
||||
collapseRenderTimer = global.setTimeout(() => {
|
||||
collapseRenderTimer = null;
|
||||
collapsingNotificationId = "";
|
||||
collapsingUntil = 0;
|
||||
collapseHost = null;
|
||||
render(context.out, context.model.state, context.options, { force: true });
|
||||
}, detailAnimationMs(card));
|
||||
return;
|
||||
}
|
||||
|
||||
clearCollapseRenderTimer();
|
||||
activeNotificationId = nextExpanded ? entry.id : "";
|
||||
render(context.out, context.model.state, context.options);
|
||||
};
|
||||
@@ -774,14 +923,21 @@
|
||||
const scrollAnchor = renderOptions.preserveScroll === false || interactionFocus ? null : captureScrollAnchor(out);
|
||||
const mode = getMode();
|
||||
const model = buildModel(state);
|
||||
lastState = model.state;
|
||||
lastOptions = options;
|
||||
syncHostMode(out, mode);
|
||||
|
||||
if (!renderOptions.force && isCollapseInProgress(out)) {
|
||||
updateRelativeTimeLabels(out, model.entries);
|
||||
scheduleRelativeTimeRefresh(model.entries);
|
||||
return;
|
||||
}
|
||||
|
||||
normalizeActiveEntry(model);
|
||||
const signature = renderSignature(model, mode);
|
||||
const canPatchExisting = !interactionFocus && signature === lastRenderSignature && out.childElementCount > 0;
|
||||
const context = { out, mode, model, options };
|
||||
|
||||
lastState = model.state;
|
||||
lastOptions = options;
|
||||
syncHostMode(out, mode);
|
||||
if (canPatchExisting) {
|
||||
updateRelativeTimeLabels(out, model.entries);
|
||||
scheduleRelativeTimeRefresh(model.entries);
|
||||
@@ -797,7 +953,22 @@
|
||||
scheduleRelativeTimeRefresh(model.entries);
|
||||
if (interactionFocus) {
|
||||
pendingEntryFocus = null;
|
||||
if (interactionFocus.expanded) {
|
||||
global.requestAnimationFrame(() => {
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, interactionFocus.id);
|
||||
setMeasuredDetailHeight(card);
|
||||
scrollDetailToLatest(out, interactionFocus.id);
|
||||
});
|
||||
}
|
||||
scheduleEntryFocus(out, interactionFocus);
|
||||
} else if (activeNotificationId) {
|
||||
global.requestAnimationFrame(() => {
|
||||
const scroller = getLogScroller(out);
|
||||
const card = findCardById(scroller, activeNotificationId);
|
||||
setMeasuredDetailHeight(card);
|
||||
restoreDetailScroll(out, activeNotificationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +993,7 @@
|
||||
render,
|
||||
resetDetail() {
|
||||
activeNotificationId = "";
|
||||
detailScrollState.clear();
|
||||
},
|
||||
syncMode(out = lastHost) {
|
||||
if (out) syncHostMode(out);
|
||||
|
||||
557
selfdrive/carrot/web/js/pages/tools_settings_qr.js
Normal file
557
selfdrive/carrot/web/js/pages/tools_settings_qr.js
Normal file
@@ -0,0 +1,557 @@
|
||||
"use strict";
|
||||
|
||||
let toolsQrCameraStream = null;
|
||||
let toolsQrScanTimer = null;
|
||||
let toolsQrScanFinishTimer = null;
|
||||
let toolsQrAlignedFrames = 0;
|
||||
|
||||
function toolsQrText(key, fallback, vars = null) {
|
||||
return typeof getUIText === "function" ? getUIText(key, fallback, vars) : fallback;
|
||||
}
|
||||
|
||||
function toolsQrEscape(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function toolsQrSetDialogClass(enabled) {
|
||||
if (typeof appDialog === "undefined" || !appDialog) return;
|
||||
appDialog.classList.toggle("app-dialog--tools-qr", Boolean(enabled));
|
||||
}
|
||||
|
||||
function toolsQrToast(key, fallback, vars = null, options = null) {
|
||||
const message = toolsQrText(key, fallback, vars);
|
||||
if (typeof showAppToast === "function") showAppToast(message, options || undefined);
|
||||
return message;
|
||||
}
|
||||
|
||||
function toolsQrStopCamera() {
|
||||
if (toolsQrScanTimer) {
|
||||
cancelAnimationFrame(toolsQrScanTimer);
|
||||
toolsQrScanTimer = null;
|
||||
}
|
||||
if (toolsQrScanFinishTimer) {
|
||||
clearTimeout(toolsQrScanFinishTimer);
|
||||
toolsQrScanFinishTimer = null;
|
||||
}
|
||||
toolsQrAlignedFrames = 0;
|
||||
if (toolsQrCameraStream) {
|
||||
toolsQrCameraStream.getTracks().forEach((track) => track.stop());
|
||||
toolsQrCameraStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toolsQrSetCameraState(cameraWrap, state) {
|
||||
if (!cameraWrap) return;
|
||||
cameraWrap.dataset.scanState = state || "idle";
|
||||
}
|
||||
|
||||
function toolsQrGuideRect(width, height) {
|
||||
const side = Math.min(width, height) * 0.74;
|
||||
return {
|
||||
left: (width - side) / 2,
|
||||
top: (height - side) / 2,
|
||||
right: (width + side) / 2,
|
||||
bottom: (height + side) / 2,
|
||||
width: side,
|
||||
height: side,
|
||||
};
|
||||
}
|
||||
|
||||
function toolsQrCodeBounds(code) {
|
||||
const points = [
|
||||
code?.location?.topLeftCorner,
|
||||
code?.location?.topRightCorner,
|
||||
code?.location?.bottomRightCorner,
|
||||
code?.location?.bottomLeftCorner,
|
||||
].filter((point) => Number.isFinite(Number(point?.x)) && Number.isFinite(Number(point?.y)));
|
||||
if (points.length < 4) return null;
|
||||
const xs = points.map((point) => Number(point.x));
|
||||
const ys = points.map((point) => Number(point.y));
|
||||
const left = Math.min(...xs);
|
||||
const right = Math.max(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const bottom = Math.max(...ys);
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
centerX: (left + right) / 2,
|
||||
centerY: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function toolsQrIsCodeAligned(code, width, height) {
|
||||
const guide = toolsQrGuideRect(width, height);
|
||||
const bounds = toolsQrCodeBounds(code);
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return false;
|
||||
const slackX = guide.width * 0.18;
|
||||
const slackY = guide.height * 0.18;
|
||||
const centerInGuide =
|
||||
bounds.centerX >= guide.left &&
|
||||
bounds.centerX <= guide.right &&
|
||||
bounds.centerY >= guide.top &&
|
||||
bounds.centerY <= guide.bottom;
|
||||
const mostlyInside =
|
||||
bounds.left >= guide.left - slackX &&
|
||||
bounds.right <= guide.right + slackX &&
|
||||
bounds.top >= guide.top - slackY &&
|
||||
bounds.bottom <= guide.bottom + slackY;
|
||||
const readableSize =
|
||||
bounds.width >= guide.width * 0.34 &&
|
||||
bounds.height >= guide.height * 0.34 &&
|
||||
bounds.width <= guide.width * 1.18 &&
|
||||
bounds.height <= guide.height * 1.18;
|
||||
return centerInGuide && mostlyInside && readableSize;
|
||||
}
|
||||
|
||||
function toolsQrMake(payload) {
|
||||
if (typeof qrcode !== "function") {
|
||||
throw new Error("QR library not loaded");
|
||||
}
|
||||
const qr = qrcode(0, "L");
|
||||
const text = String(payload || "");
|
||||
qr.addData(payload, text.startsWith("CQR3:") || text.startsWith("CQR4:") ? "Alphanumeric" : "Byte");
|
||||
qr.make();
|
||||
return qr;
|
||||
}
|
||||
|
||||
function toolsQrMakeSvg(payload) {
|
||||
return toolsQrMake(payload).createSvgTag(2, 4);
|
||||
}
|
||||
|
||||
function toolsQrMakePngDataUrl(payload) {
|
||||
const qr = toolsQrMake(payload);
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const cellSize = 4;
|
||||
const margin = 4;
|
||||
const size = (moduleCount + margin * 2) * cellSize;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = "#000000";
|
||||
for (let row = 0; row < moduleCount; row += 1) {
|
||||
for (let col = 0; col < moduleCount; col += 1) {
|
||||
if (qr.isDark(row, col)) {
|
||||
ctx.fillRect((col + margin) * cellSize, (row + margin) * cellSize, cellSize, cellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function toolsQrSafeFilePart(value) {
|
||||
const text = String(value || "").trim() || "carrot";
|
||||
return text
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 64) || "carrot";
|
||||
}
|
||||
|
||||
function toolsQrDateStamp(date = new Date()) {
|
||||
const yyyy = String(date.getFullYear());
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}`;
|
||||
}
|
||||
|
||||
async function toolsQrBackupFileName() {
|
||||
try {
|
||||
const values = await bulkGet(["GitBranch"]);
|
||||
return `${toolsQrSafeFilePart(values.GitBranch)}-${toolsQrDateStamp()}.png`;
|
||||
} catch (e) {
|
||||
return `carrot-${toolsQrDateStamp()}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
async function toolsQrEnsureDependency() {
|
||||
const status = await getJson("/api/params_qr_dependency");
|
||||
console.info("[carrot][qr-dependency]", status);
|
||||
if (status.installed) return status;
|
||||
|
||||
toolsQrToast("qr_configuring", "Configuring QR feature...");
|
||||
const result = await postJson("/api/params_qr_dependency/ensure", {});
|
||||
console.info("[carrot][qr-dependency]", result);
|
||||
if (!result.ok || !result.installed) {
|
||||
throw new Error(result.error || toolsQrText("qr_config_failed", "QR feature could not be configured."));
|
||||
}
|
||||
toolsQrToast("qr_config_done", "QR feature is ready.");
|
||||
return result;
|
||||
}
|
||||
|
||||
function toolsQrDownloadDataUrl(dataUrl, filename) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function toolsQrDecodeImageElement(img) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const scale = Math.min(1, 1600 / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height, 1));
|
||||
canvas.width = Math.max(1, Math.floor((img.naturalWidth || img.width) * scale));
|
||||
canvas.height = Math.max(1, Math.floor((img.naturalHeight || img.height) * scale));
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const code = typeof jsQR === "function" ? jsQR(imageData.data, canvas.width, canvas.height) : null;
|
||||
return code?.data || "";
|
||||
}
|
||||
|
||||
function toolsQrPreviewImageFile(file, preview, statusNode) {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const decoded = toolsQrDecodeImageElement(img);
|
||||
if (decoded) preview(decoded);
|
||||
else statusNode.textContent = toolsQrText("qr_restore_decode_failed", "QR code was not found.");
|
||||
};
|
||||
img.onerror = () => {
|
||||
statusNode.textContent = toolsQrText("qr_restore_decode_failed", "QR code was not found.");
|
||||
};
|
||||
img.src = String(reader.result || "");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function toolsQrSummaryHtml(summary = {}) {
|
||||
const item = (key, labelKey, fallback) => `
|
||||
<span class="tools-qr-chip">
|
||||
<strong>${Number(summary[key] || 0)}</strong>
|
||||
<span>${toolsQrEscape(toolsQrText(labelKey, fallback))}</span>
|
||||
</span>
|
||||
`;
|
||||
return `
|
||||
<div class="tools-qr-summary">
|
||||
${item("changed", "qr_restore_changed", "changed")}
|
||||
${item("same", "qr_restore_same", "same")}
|
||||
${item("skipped", "qr_restore_skipped", "skipped")}
|
||||
${item("invalid", "qr_restore_invalid", "invalid")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function toolsQrDiffHtml(preview) {
|
||||
const entries = Array.isArray(preview?.entries) ? preview.entries : [];
|
||||
const changed = entries.filter((entry) => entry.apply || entry.status === "changed");
|
||||
const shown = changed.slice(0, 80);
|
||||
const hiddenCount = Math.max(0, changed.length - shown.length);
|
||||
const currentLabel = toolsQrText("qr_restore_current_value", "Current");
|
||||
const backupLabel = toolsQrText("qr_restore_backup_value", "Restore");
|
||||
const changedLabel = toolsQrText("qr_restore_changed", "Changed");
|
||||
const rows = shown.map((entry) => `
|
||||
<div class="tools-qr-diff__row">
|
||||
<div class="tools-qr-diff__head">
|
||||
<div class="tools-qr-diff__key">${toolsQrEscape(entry.key)}</div>
|
||||
<span class="tools-qr-diff__status">${toolsQrEscape(changedLabel)}</span>
|
||||
</div>
|
||||
<div class="tools-qr-diff__compare">
|
||||
<div class="tools-qr-diff__value tools-qr-diff__value--old">
|
||||
<span>${toolsQrEscape(currentLabel)}</span>
|
||||
<code>${toolsQrEscape(entry.current)}</code>
|
||||
</div>
|
||||
<div class="tools-qr-diff__arrow" aria-hidden="true">></div>
|
||||
<div class="tools-qr-diff__value tools-qr-diff__value--new">
|
||||
<span>${toolsQrEscape(backupLabel)}</span>
|
||||
<code>${toolsQrEscape(entry.value)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
if (!changed.length) {
|
||||
return `
|
||||
${toolsQrSummaryHtml(preview?.summary)}
|
||||
<div class="tools-qr-empty">${toolsQrEscape(toolsQrText("qr_restore_no_changes", "No changes to apply."))}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
${toolsQrSummaryHtml(preview?.summary)}
|
||||
<div class="tools-qr-diff__list">${rows}</div>
|
||||
${hiddenCount ? `<div class="tools-qr-more">${toolsQrEscape(toolsQrText("qr_restore_more", "{count} more changes hidden", { count: hiddenCount }))}</div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
async function toolsQrShowBackup() {
|
||||
try {
|
||||
await toolsQrEnsureDependency();
|
||||
const j = await getJson("/api/params_qr_backup");
|
||||
const format = j.format || String(j.payload || "").split(/[.:]/, 1)[0] || "unknown";
|
||||
console.info("[carrot][qr-backup]", {
|
||||
format,
|
||||
version: j.version,
|
||||
count: j.count,
|
||||
payloadChars: j.payload_chars,
|
||||
rawBytes: j.json_bytes,
|
||||
compressedBytes: j.compressed_bytes,
|
||||
ecc: j.ecc,
|
||||
});
|
||||
const svg = toolsQrMakeSvg(j.payload);
|
||||
const pngDataUrl = toolsQrMakePngDataUrl(j.payload);
|
||||
const fileName = await toolsQrBackupFileName();
|
||||
const title = toolsQrText("qr_backup_title", "QR Backup");
|
||||
const html = `
|
||||
<div class="tools-qr-backup">
|
||||
<div class="tools-qr-code" aria-label="${toolsQrEscape(title)}">${svg}</div>
|
||||
</div>
|
||||
`;
|
||||
const promise = openAppDialog({
|
||||
mode: "confirm",
|
||||
title,
|
||||
html: true,
|
||||
messageHtml: html,
|
||||
confirmLabel: toolsQrText("download", "Download"),
|
||||
cancelLabel: toolsQrText("cancel", "Cancel"),
|
||||
});
|
||||
toolsQrSetDialogClass(true);
|
||||
promise.finally(() => toolsQrSetDialogClass(false));
|
||||
const ok = await promise;
|
||||
if (ok) toolsQrDownloadDataUrl(pngDataUrl, fileName);
|
||||
} catch (e) {
|
||||
showError("qr backup", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toolsQrPreviewPayload(payload, diffNode, confirmButton, statusNode) {
|
||||
const trimmed = String(payload || "").trim();
|
||||
if (!trimmed) return;
|
||||
statusNode.textContent = toolsQrText("qr_restore_previewing", "Checking backup...");
|
||||
confirmButton.disabled = true;
|
||||
const j = await postJson("/api/params_restore_preview", { payload: trimmed });
|
||||
diffNode.innerHTML = toolsQrDiffHtml(j.preview);
|
||||
const selected = Number(j.preview?.summary?.selected || 0);
|
||||
confirmButton.disabled = selected <= 0;
|
||||
statusNode.textContent = selected > 0
|
||||
? toolsQrText("qr_restore_ready", "{count} changes ready", { count: selected })
|
||||
: toolsQrText("qr_restore_no_changes", "No changes to apply.");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function toolsQrBindRestoreDialog(state) {
|
||||
const imageBtn = document.getElementById("toolsQrImageBtn");
|
||||
const imageInput = document.getElementById("toolsQrImageInput");
|
||||
const cameraBtn = document.getElementById("toolsQrCameraBtn");
|
||||
const cameraWrap = document.getElementById("toolsQrCameraWrap");
|
||||
const video = document.getElementById("toolsQrVideo");
|
||||
const canvas = document.getElementById("toolsQrCanvas");
|
||||
const statusNode = document.getElementById("toolsQrStatus");
|
||||
const diffNode = document.getElementById("toolsQrDiff");
|
||||
const confirmButton = typeof appDialogConfirm !== "undefined" ? appDialogConfirm : null;
|
||||
if (!imageBtn || !imageInput || !cameraBtn || !cameraWrap || !video || !canvas || !statusNode || !diffNode || !confirmButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
confirmButton.disabled = true;
|
||||
const cameraAvailable = Boolean(window.isSecureContext && navigator.mediaDevices?.getUserMedia);
|
||||
if (!cameraAvailable) {
|
||||
cameraBtn.disabled = true;
|
||||
cameraBtn.classList.add("is-disabled");
|
||||
cameraBtn.textContent = toolsQrText("qr_restore_camera_disabled", "Camera unavailable");
|
||||
cameraBtn.setAttribute("aria-label", toolsQrText("qr_restore_https_required", "Open this page with HTTPS to use the camera."));
|
||||
cameraBtn.title = toolsQrText("qr_restore_https_required", "Open this page with HTTPS to use the camera.");
|
||||
}
|
||||
|
||||
const preview = async (payload) => {
|
||||
try {
|
||||
state.payload = await toolsQrPreviewPayload(payload, diffNode, confirmButton, statusNode);
|
||||
} catch (e) {
|
||||
state.payload = "";
|
||||
confirmButton.disabled = true;
|
||||
statusNode.textContent = e?.message || toolsQrText("qr_restore_preview_failed", "Failed to read backup.");
|
||||
diffNode.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
imageBtn.onclick = () => imageInput.click();
|
||||
imageInput.onchange = () => {
|
||||
toolsQrPreviewImageFile(imageInput.files?.[0], preview, statusNode);
|
||||
imageInput.value = "";
|
||||
};
|
||||
|
||||
cameraBtn.onclick = async () => {
|
||||
if (!cameraAvailable) return;
|
||||
if (toolsQrCameraStream) {
|
||||
toolsQrStopCamera();
|
||||
cameraWrap.hidden = true;
|
||||
toolsQrSetCameraState(cameraWrap, "idle");
|
||||
cameraBtn.textContent = toolsQrText("qr_restore_camera", "Camera");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!window.isSecureContext) {
|
||||
statusNode.textContent = toolsQrText("qr_restore_https_required", "Open this page with HTTPS to use the camera.");
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
statusNode.textContent = toolsQrText("qr_restore_camera_unsupported", "Camera stream is not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
toolsQrCameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = toolsQrCameraStream;
|
||||
video.setAttribute("playsinline", "true");
|
||||
await video.play();
|
||||
cameraWrap.hidden = false;
|
||||
toolsQrSetCameraState(cameraWrap, "searching");
|
||||
cameraBtn.textContent = toolsQrText("qr_restore_stop_camera", "Stop Camera");
|
||||
statusNode.textContent = toolsQrText("qr_restore_scan_hint", "Point the camera at the QR code.");
|
||||
|
||||
const scan = () => {
|
||||
if (!toolsQrCameraStream || video.readyState < 2) {
|
||||
toolsQrScanTimer = requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
canvas.width = video.videoWidth || 640;
|
||||
canvas.height = video.videoHeight || 480;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const code = typeof jsQR === "function" ? jsQR(imageData.data, canvas.width, canvas.height) : null;
|
||||
if (code?.data) {
|
||||
if (toolsQrIsCodeAligned(code, canvas.width, canvas.height)) {
|
||||
toolsQrAlignedFrames += 1;
|
||||
toolsQrSetCameraState(cameraWrap, "aligned");
|
||||
statusNode.textContent = toolsQrText("qr_restore_scan_aligned", "QR code detected. Hold still...");
|
||||
if (toolsQrAlignedFrames >= 2) {
|
||||
toolsQrSetCameraState(cameraWrap, "locked");
|
||||
statusNode.textContent = toolsQrText("qr_restore_scan_locked", "QR code captured.");
|
||||
toolsQrScanFinishTimer = window.setTimeout(() => {
|
||||
toolsQrScanFinishTimer = null;
|
||||
toolsQrStopCamera();
|
||||
cameraWrap.hidden = true;
|
||||
toolsQrSetCameraState(cameraWrap, "idle");
|
||||
cameraBtn.textContent = toolsQrText("qr_restore_camera", "Camera");
|
||||
preview(code.data);
|
||||
}, 160);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
toolsQrAlignedFrames = 0;
|
||||
toolsQrSetCameraState(cameraWrap, "detected");
|
||||
statusNode.textContent = toolsQrText("qr_restore_scan_detected", "Move the QR code into the guide box.");
|
||||
}
|
||||
} else {
|
||||
toolsQrAlignedFrames = 0;
|
||||
toolsQrSetCameraState(cameraWrap, "searching");
|
||||
}
|
||||
toolsQrScanTimer = requestAnimationFrame(scan);
|
||||
};
|
||||
toolsQrScanTimer = requestAnimationFrame(scan);
|
||||
} catch (e) {
|
||||
toolsQrStopCamera();
|
||||
cameraWrap.hidden = true;
|
||||
toolsQrSetCameraState(cameraWrap, "idle");
|
||||
cameraBtn.textContent = toolsQrText("qr_restore_camera", "Camera");
|
||||
statusNode.textContent = e?.message || toolsQrText("qr_restore_camera_failed", "Camera could not be opened.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function toolsQrShowRestore() {
|
||||
const state = { payload: "" };
|
||||
const html = `
|
||||
<div class="tools-qr-restore">
|
||||
<div class="tools-qr-actions">
|
||||
<button id="toolsQrImageBtn" class="btn tools-qr-action-btn" type="button">${toolsQrEscape(toolsQrText("qr_restore_upload", "Image"))}</button>
|
||||
<button id="toolsQrCameraBtn" class="btn tools-qr-action-btn tools-qr-action-btn--camera" type="button">${toolsQrEscape(toolsQrText("qr_restore_camera", "Camera"))}</button>
|
||||
</div>
|
||||
<input id="toolsQrImageInput" type="file" accept="image/*" hidden />
|
||||
<div id="toolsQrCameraWrap" class="tools-qr-camera" hidden>
|
||||
<video id="toolsQrVideo" muted playsinline></video>
|
||||
<div class="tools-qr-camera__overlay" aria-hidden="true">
|
||||
<div class="tools-qr-camera__guide">
|
||||
<span class="tools-qr-camera__corner tools-qr-camera__corner--tl"></span>
|
||||
<span class="tools-qr-camera__corner tools-qr-camera__corner--tr"></span>
|
||||
<span class="tools-qr-camera__corner tools-qr-camera__corner--bl"></span>
|
||||
<span class="tools-qr-camera__corner tools-qr-camera__corner--br"></span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="toolsQrCanvas" hidden></canvas>
|
||||
</div>
|
||||
<div id="toolsQrStatus" class="tools-qr-status">${toolsQrEscape(toolsQrText("qr_restore_hint", "Scan or select a QR backup image before restoring."))}</div>
|
||||
<div id="toolsQrDiff" class="tools-qr-diff"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const promise = openAppDialog({
|
||||
mode: "confirm",
|
||||
title: toolsQrText("qr_restore_title", "QR Restore"),
|
||||
html: true,
|
||||
messageHtml: html,
|
||||
confirmLabel: toolsQrText("qr_restore_apply", "Apply"),
|
||||
cancelLabel: toolsQrText("cancel", "Cancel"),
|
||||
});
|
||||
toolsQrSetDialogClass(true);
|
||||
window.setTimeout(() => toolsQrBindRestoreDialog(state), 0);
|
||||
const ok = await promise.finally(() => {
|
||||
toolsQrStopCamera();
|
||||
toolsQrSetDialogClass(false);
|
||||
});
|
||||
if (!ok || !state.payload) return;
|
||||
|
||||
try {
|
||||
const j = await postJson("/api/params_restore_json", { payload: state.payload });
|
||||
const count = Number(j.result?.ok_cnt || 0);
|
||||
const failed = new Set((j.result?.fails || []).map((entry) => String(entry?.key || "")).filter(Boolean));
|
||||
const restoredValues = {};
|
||||
(j.preview?.entries || []).forEach((entry) => {
|
||||
if (!entry?.apply || failed.has(String(entry.key))) return;
|
||||
restoredValues[entry.key] = entry.value;
|
||||
});
|
||||
if (Object.keys(restoredValues).length) {
|
||||
window.dispatchEvent(new CustomEvent("carrot:paramsrestored", {
|
||||
detail: { source: "qr_restore", values: restoredValues },
|
||||
}));
|
||||
Object.entries(restoredValues).forEach(([name, value]) => {
|
||||
window.dispatchEvent(new CustomEvent("carrot:paramchange", {
|
||||
detail: { name, value, source: "qr_restore" },
|
||||
}));
|
||||
});
|
||||
}
|
||||
toolsLogNotice(JSON.stringify(j.result, null, 2), { label: "qr restore", meta: false });
|
||||
if (typeof showAppToast === "function") {
|
||||
showAppToast(toolsQrText("qr_restore_applied", "{count} params restored", { count }));
|
||||
}
|
||||
if (count > 0 && await appConfirm(UI_STRINGS[LANG].restore_done_reboot || "Restore done.\nReboot now?", {
|
||||
title: UI_STRINGS[LANG].reboot || "Reboot",
|
||||
})) {
|
||||
await runTool("reboot");
|
||||
}
|
||||
} catch (e) {
|
||||
showError("qr restore", e);
|
||||
}
|
||||
}
|
||||
|
||||
function initToolsSettingsQr() {
|
||||
const bind = (id, fn) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.dataset.qrBound === "1") return;
|
||||
el.dataset.qrBound = "1";
|
||||
el.addEventListener("click", fn);
|
||||
};
|
||||
bind("btnQrBackupSettings", toolsQrShowBackup);
|
||||
bind("btnQrRestoreSettings", toolsQrShowRestore);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initToolsSettingsQr);
|
||||
if (document.readyState !== "loading") initToolsSettingsQr();
|
||||
@@ -60,7 +60,7 @@ async function postJson(url, bodyObj) {
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
return requestJson(url);
|
||||
return requestJson(url, { cache: "no-store" });
|
||||
}
|
||||
|
||||
function waitMs(ms) {
|
||||
|
||||
@@ -257,10 +257,12 @@ function renderUIText() {
|
||||
setText("btnDeleteLogs", "delete all logs");
|
||||
setText("btnRebuildAll", "Rebuild All");
|
||||
setText("btnReboot", "Reboot");
|
||||
setText("btnBackupSettings", "Backup");
|
||||
setText("btnRestoreSettings", "Restore");
|
||||
setText("btnCopySettings", "Copy");
|
||||
setText("btnViewSettings", "View");
|
||||
setText("btnBackupSettings", getUIText("backup", "Backup"));
|
||||
setText("btnRestoreSettings", getUIText("restore", "Restore"));
|
||||
setText("btnQrBackupSettings", getUIText("qr_backup", "QR Backup"));
|
||||
setText("btnQrRestoreSettings", getUIText("qr_restore", "QR Restore"));
|
||||
setText("btnCopySettings", getUIText("copy", "Copy"));
|
||||
setText("btnViewSettings", getUIText("view", "View"));
|
||||
setText("sysCmdTitle", getUIText("section_sys_cmd", "System Command"));
|
||||
setText("sysCmdHelp", getUIText("sys_cmd_help", "Allowed: pull, status, branch, log, git ..., df, free, uptime"));
|
||||
setText("outputTitle", getUIText("section_output", "Output"));
|
||||
|
||||
@@ -146,6 +146,8 @@ function openAppDialog(options = {}) {
|
||||
appDialogBody.style.flex = hasChoices ? "0 0 auto" : "1 1 auto";
|
||||
appDialogConfirm.textContent = confirmLabel;
|
||||
appDialogCancel.textContent = cancelLabel;
|
||||
appDialogConfirm.disabled = false;
|
||||
appDialogCancel.disabled = false;
|
||||
appDialogCancel.hidden = !showCancel;
|
||||
appDialogCancel.setAttribute("aria-hidden", showCancel ? "false" : "true");
|
||||
appDialogConfirm.hidden = isChoice;
|
||||
|
||||
@@ -213,6 +213,12 @@ window.CarrotTranslations.register("en", {
|
||||
setting_value_title: "Edit value",
|
||||
setting_value_prompt: "Enter value for {name}\nRange: {min} - {max}",
|
||||
setting_value_invalid: "Enter a valid number.",
|
||||
setting_favorites: "Favorites",
|
||||
setting_favorites_empty_title: "No favorites",
|
||||
setting_favorites_empty_desc: "Long press a setting to add it. Long press again to remove it.",
|
||||
setting_favorite_added: "Added to favorites",
|
||||
setting_favorite_removed: "Removed from favorites",
|
||||
setting_favorites_save_failed: "Failed to save favorites",
|
||||
branch_dom_missing: "Branch DOM elements missing.",
|
||||
fullscreen_not_supported: "Fullscreen not supported on this browser.",
|
||||
record: "Record",
|
||||
@@ -328,6 +334,12 @@ window.CarrotTranslations.register("en", {
|
||||
video_controls: "Video controls",
|
||||
rewind_5: "Back 5 seconds",
|
||||
forward_5: "Forward 5 seconds",
|
||||
pause: "Pause",
|
||||
ended: "End",
|
||||
muted: "Muted",
|
||||
fullscreen: "Fullscreen",
|
||||
fullscreen_exit: "Exit fullscreen",
|
||||
pip_exit: "Exit PiP",
|
||||
git_remote_title: "Change Repository",
|
||||
git_remote_prompt: "Current: {url}\n\nEnter new GitHub repository URL.\n(This will overwrite the current connection)",
|
||||
git_remote_fetching: "Fetching repository data.\nThis may take a few minutes for new repositories.\nPlease wait...",
|
||||
@@ -351,6 +363,43 @@ window.CarrotTranslations.register("en", {
|
||||
settings_not_loaded: "Settings not loaded",
|
||||
copy_settings_done: "{count} params copied",
|
||||
settings_title: "Settings ({count} params)",
|
||||
qr_backup: "QR Backup",
|
||||
qr_restore: "QR Restore",
|
||||
qr_backup_title: "QR Backup",
|
||||
qr_restore_title: "QR Restore",
|
||||
qr_backup_count: "{count} params",
|
||||
qr_backup_size: "{chars} chars",
|
||||
qr_configuring: "Configuring QR feature...",
|
||||
qr_config_done: "QR feature is ready.",
|
||||
qr_config_failed: "QR feature could not be configured.",
|
||||
qr_restore_upload: "Image",
|
||||
qr_restore_camera: "Camera",
|
||||
qr_restore_camera_disabled: "Camera unavailable",
|
||||
qr_restore_stop_camera: "Stop Camera",
|
||||
qr_restore_paste_placeholder: "Paste QR backup text",
|
||||
qr_restore_check: "Check",
|
||||
qr_restore_hint: "Scan with the camera or select a QR backup image before restoring.",
|
||||
qr_restore_scan_hint: "Point the camera at the QR code.",
|
||||
qr_restore_scan_detected: "Move the QR code into the guide box.",
|
||||
qr_restore_scan_aligned: "QR code aligned. Hold still...",
|
||||
qr_restore_scan_locked: "QR code captured.",
|
||||
qr_restore_decode_failed: "QR code was not found.",
|
||||
qr_restore_previewing: "Checking backup...",
|
||||
qr_restore_ready: "{count} changes ready",
|
||||
qr_restore_no_changes: "No changes to apply.",
|
||||
qr_restore_apply: "Apply",
|
||||
qr_restore_changed: "Changed",
|
||||
qr_restore_current_value: "Current",
|
||||
qr_restore_backup_value: "Restore",
|
||||
qr_restore_same: "Same",
|
||||
qr_restore_skipped: "Skipped",
|
||||
qr_restore_invalid: "Invalid",
|
||||
qr_restore_more: "{count} more changes hidden",
|
||||
qr_restore_applied: "{count} params restored",
|
||||
qr_restore_https_required: "Open this page with HTTPS to use the camera.",
|
||||
qr_restore_camera_unsupported: "Camera stream is not supported by this browser.",
|
||||
qr_restore_camera_failed: "Camera could not be opened.",
|
||||
qr_restore_preview_failed: "Failed to read backup.",
|
||||
empty_value: "(empty)",
|
||||
},
|
||||
actionLabels: {
|
||||
|
||||
@@ -213,6 +213,12 @@ window.CarrotTranslations.register("fr", {
|
||||
setting_value_title: "Modifier la valeur",
|
||||
setting_value_prompt: "Entrer la valeur pour {name}\nPlage : {min} - {max}",
|
||||
setting_value_invalid: "Entrez un nombre valide.",
|
||||
setting_favorites: "Favoris",
|
||||
setting_favorites_empty_title: "Aucun favori",
|
||||
setting_favorites_empty_desc: "Appuyez longuement sur un réglage pour l'ajouter. Appuyez longuement à nouveau pour le retirer.",
|
||||
setting_favorite_added: "Ajouté aux favoris",
|
||||
setting_favorite_removed: "Retiré des favoris",
|
||||
setting_favorites_save_failed: "Échec de l'enregistrement des favoris",
|
||||
branch_dom_missing: "Branch DOM elements missing.",
|
||||
fullscreen_not_supported: "Fullscreen not supported on this browser.",
|
||||
record: "Enregistrer",
|
||||
@@ -325,6 +331,12 @@ window.CarrotTranslations.register("fr", {
|
||||
video_controls: "Contrôles vidéo",
|
||||
rewind_5: "Reculer 5 s",
|
||||
forward_5: "Avancer 5 s",
|
||||
pause: "Pause",
|
||||
ended: "Fin",
|
||||
muted: "Muet",
|
||||
fullscreen: "Plein écran",
|
||||
fullscreen_exit: "Quitter plein écran",
|
||||
pip_exit: "Quitter PiP",
|
||||
git_remote_title: "Changer le dépôt",
|
||||
git_remote_prompt: "Actuel : {url}\n\nEntrez la nouvelle URL du dépôt GitHub.\n(Cela remplacera la connexion actuelle)",
|
||||
git_remote_fetching: "Récupération des données du dépôt.\nCela peut prendre quelques minutes pour un nouveau dépôt.\nVeuillez patienter...",
|
||||
@@ -348,6 +360,43 @@ window.CarrotTranslations.register("fr", {
|
||||
settings_not_loaded: "Réglages non chargés",
|
||||
copy_settings_done: "{count} Params copiés",
|
||||
settings_title: "Réglages ({count} Params)",
|
||||
qr_backup: "Sauvegarde QR",
|
||||
qr_restore: "Restauration QR",
|
||||
qr_backup_title: "Sauvegarde QR",
|
||||
qr_restore_title: "Restauration QR",
|
||||
qr_backup_count: "{count} Params",
|
||||
qr_backup_size: "{chars} caractères",
|
||||
qr_configuring: "Configuration de la fonction QR...",
|
||||
qr_config_done: "La fonction QR est prête.",
|
||||
qr_config_failed: "La fonction QR n'a pas pu être configurée.",
|
||||
qr_restore_upload: "Image",
|
||||
qr_restore_camera: "Caméra",
|
||||
qr_restore_camera_disabled: "Caméra indisponible",
|
||||
qr_restore_stop_camera: "Arrêter caméra",
|
||||
qr_restore_paste_placeholder: "Coller le texte de sauvegarde QR",
|
||||
qr_restore_check: "Vérifier",
|
||||
qr_restore_hint: "Scannez avec la caméra ou choisissez une image QR avant de restaurer.",
|
||||
qr_restore_scan_hint: "Pointez la caméra vers le QR code.",
|
||||
qr_restore_scan_detected: "Placez le QR code dans le cadre de guidage.",
|
||||
qr_restore_scan_aligned: "QR code aligné. Restez immobile...",
|
||||
qr_restore_scan_locked: "QR code capturé.",
|
||||
qr_restore_decode_failed: "QR code introuvable.",
|
||||
qr_restore_previewing: "Vérification de la sauvegarde...",
|
||||
qr_restore_ready: "{count} modifications prêtes",
|
||||
qr_restore_no_changes: "Aucune modification à appliquer.",
|
||||
qr_restore_apply: "Appliquer",
|
||||
qr_restore_changed: "Modifié",
|
||||
qr_restore_current_value: "Actuel",
|
||||
qr_restore_backup_value: "Restaurer",
|
||||
qr_restore_same: "Identique",
|
||||
qr_restore_skipped: "Ignoré",
|
||||
qr_restore_invalid: "Invalide",
|
||||
qr_restore_more: "{count} modifications supplémentaires masquées",
|
||||
qr_restore_applied: "{count} Params restaurés",
|
||||
qr_restore_https_required: "Ouvrez cette page en HTTPS pour utiliser la caméra.",
|
||||
qr_restore_camera_unsupported: "Le flux caméra n'est pas pris en charge par ce navigateur.",
|
||||
qr_restore_camera_failed: "Impossible d'ouvrir la caméra.",
|
||||
qr_restore_preview_failed: "Impossible de lire la sauvegarde.",
|
||||
empty_value: "(vide)",
|
||||
},
|
||||
actionLabels: {
|
||||
|
||||
@@ -213,6 +213,12 @@ window.CarrotTranslations.register("ja", {
|
||||
setting_value_title: "値を編集",
|
||||
setting_value_prompt: "{name} の値を入力してください\n範囲: {min} - {max}",
|
||||
setting_value_invalid: "有効な数値を入力してください。",
|
||||
setting_favorites: "お気に入り",
|
||||
setting_favorites_empty_title: "お気に入りなし",
|
||||
setting_favorites_empty_desc: "設定項目を長押しすると追加され、もう一度長押しすると削除されます。",
|
||||
setting_favorite_added: "お気に入りに追加しました",
|
||||
setting_favorite_removed: "お気に入りから削除しました",
|
||||
setting_favorites_save_failed: "お気に入りの保存に失敗しました",
|
||||
branch_dom_missing: "Branch DOM elements missing.",
|
||||
fullscreen_not_supported: "Fullscreen not supported on this browser.",
|
||||
record: "録画",
|
||||
@@ -325,6 +331,12 @@ window.CarrotTranslations.register("ja", {
|
||||
video_controls: "動画操作",
|
||||
rewind_5: "5秒戻る",
|
||||
forward_5: "5秒進む",
|
||||
pause: "一時停止",
|
||||
ended: "終了",
|
||||
muted: "ミュート",
|
||||
fullscreen: "全画面",
|
||||
fullscreen_exit: "全画面解除",
|
||||
pip_exit: "PiP 解除",
|
||||
git_remote_title: "リポジトリ変更",
|
||||
git_remote_prompt: "現在: {url}\n\n新しい GitHub リポジトリ URL を入力してください。\n(現在の接続を上書きします)",
|
||||
git_remote_fetching: "リポジトリ情報を取得しています。\n新しいリポジトリでは数分かかる場合があります。\nお待ちください...",
|
||||
@@ -348,6 +360,43 @@ window.CarrotTranslations.register("ja", {
|
||||
settings_not_loaded: "設定を読み込めません",
|
||||
copy_settings_done: "{count} 個の Params をコピーしました",
|
||||
settings_title: "設定 ({count} 個の Params)",
|
||||
qr_backup: "QR バックアップ",
|
||||
qr_restore: "QR 復元",
|
||||
qr_backup_title: "QR バックアップ",
|
||||
qr_restore_title: "QR 復元",
|
||||
qr_backup_count: "{count} 個の Params",
|
||||
qr_backup_size: "{chars} 文字",
|
||||
qr_configuring: "QR 機能を構成中です...",
|
||||
qr_config_done: "QR 機能の構成が完了しました。",
|
||||
qr_config_failed: "QR 機能を構成できませんでした。",
|
||||
qr_restore_upload: "画像",
|
||||
qr_restore_camera: "カメラ",
|
||||
qr_restore_camera_disabled: "カメラ使用不可",
|
||||
qr_restore_stop_camera: "カメラ停止",
|
||||
qr_restore_paste_placeholder: "QR バックアップ文字列を貼り付け",
|
||||
qr_restore_check: "確認",
|
||||
qr_restore_hint: "カメラでスキャンするか、QR バックアップ画像を選択して復元してください。",
|
||||
qr_restore_scan_hint: "カメラを QR コードに向けてください。",
|
||||
qr_restore_scan_detected: "QR コードをガイド枠内に合わせてください。",
|
||||
qr_restore_scan_aligned: "QR コードが合いました。そのままお待ちください。",
|
||||
qr_restore_scan_locked: "QR コードを読み取りました。",
|
||||
qr_restore_decode_failed: "QR コードが見つかりません。",
|
||||
qr_restore_previewing: "バックアップを確認中...",
|
||||
qr_restore_ready: "{count} 件の変更を適用できます",
|
||||
qr_restore_no_changes: "適用する変更はありません。",
|
||||
qr_restore_apply: "適用",
|
||||
qr_restore_changed: "変更",
|
||||
qr_restore_current_value: "現在",
|
||||
qr_restore_backup_value: "復元",
|
||||
qr_restore_same: "同一",
|
||||
qr_restore_skipped: "スキップ",
|
||||
qr_restore_invalid: "無効",
|
||||
qr_restore_more: "他 {count} 件の変更があります",
|
||||
qr_restore_applied: "{count} 個の Params を復元しました",
|
||||
qr_restore_https_required: "カメラを使うには HTTPS で開いてください。",
|
||||
qr_restore_camera_unsupported: "このブラウザはリアルタイムカメラに対応していません。",
|
||||
qr_restore_camera_failed: "カメラを開けません。",
|
||||
qr_restore_preview_failed: "バックアップを読み取れません。",
|
||||
empty_value: "(空)",
|
||||
},
|
||||
actionLabels: {
|
||||
|
||||
@@ -213,6 +213,12 @@ window.CarrotTranslations.register("ko", {
|
||||
setting_value_title: "값 수정",
|
||||
setting_value_prompt: "{name} 값을 입력하세요\n범위: {min} - {max}",
|
||||
setting_value_invalid: "올바른 숫자를 입력하세요.",
|
||||
setting_favorites: "즐겨찾기",
|
||||
setting_favorites_empty_title: "즐겨찾기 없음",
|
||||
setting_favorites_empty_desc: "설정 항목을 길게 누르면 추가되고, 다시 길게 누르면 삭제됩니다.",
|
||||
setting_favorite_added: "즐겨찾기에 추가됨",
|
||||
setting_favorite_removed: "즐겨찾기에서 삭제됨",
|
||||
setting_favorites_save_failed: "즐겨찾기 저장 실패",
|
||||
branch_dom_missing: "브랜치 DOM 요소를 찾을 수 없습니다.",
|
||||
fullscreen_not_supported: "이 브라우저는 전체화면을 지원하지 않습니다.",
|
||||
record: "녹화",
|
||||
@@ -325,6 +331,12 @@ window.CarrotTranslations.register("ko", {
|
||||
video_controls: "영상 제어",
|
||||
rewind_5: "5초 뒤로",
|
||||
forward_5: "5초 앞으로",
|
||||
pause: "일시정지",
|
||||
ended: "끝",
|
||||
muted: "음소거",
|
||||
fullscreen: "전체화면",
|
||||
fullscreen_exit: "전체화면 해제",
|
||||
pip_exit: "PiP 해제",
|
||||
git_remote_title: "저장소 변경",
|
||||
git_remote_prompt: "현재: {url}\n\n새 GitHub 저장소 URL을 입력하세요.\n(현재 연결을 덮어씁니다)",
|
||||
git_remote_fetching: "저장소 데이터를 가져오는 중입니다.\n새 저장소는 몇 분 걸릴 수 있습니다.\n잠시 기다려주세요...",
|
||||
@@ -348,6 +360,43 @@ window.CarrotTranslations.register("ko", {
|
||||
settings_not_loaded: "설정을 불러오지 못했습니다",
|
||||
copy_settings_done: "{count}개 Params 복사됨",
|
||||
settings_title: "설정 ({count}개 Params)",
|
||||
qr_backup: "QR 백업",
|
||||
qr_restore: "QR 복원",
|
||||
qr_backup_title: "QR 백업",
|
||||
qr_restore_title: "QR 복원",
|
||||
qr_backup_count: "{count}개 Params",
|
||||
qr_backup_size: "{chars}자",
|
||||
qr_configuring: "QR 기능 구성 중입니다...",
|
||||
qr_config_done: "QR 기능 구성이 완료되었습니다.",
|
||||
qr_config_failed: "QR 기능 구성을 완료하지 못했습니다.",
|
||||
qr_restore_upload: "이미지",
|
||||
qr_restore_camera: "카메라",
|
||||
qr_restore_camera_disabled: "카메라 사용 불가",
|
||||
qr_restore_stop_camera: "카메라 중지",
|
||||
qr_restore_paste_placeholder: "QR 백업 텍스트 붙여넣기",
|
||||
qr_restore_check: "확인",
|
||||
qr_restore_hint: "카메라로 스캔하거나 QR 이미지를 선택해 복원하세요.",
|
||||
qr_restore_scan_hint: "카메라를 QR 코드에 맞춰주세요.",
|
||||
qr_restore_scan_detected: "QR을 가이드 박스 안으로 맞춰주세요.",
|
||||
qr_restore_scan_aligned: "QR이 맞춰졌습니다. 잠시 고정하세요.",
|
||||
qr_restore_scan_locked: "QR을 인식했습니다.",
|
||||
qr_restore_decode_failed: "QR 코드를 찾지 못했습니다.",
|
||||
qr_restore_previewing: "백업 확인 중...",
|
||||
qr_restore_ready: "{count}개 변경 준비됨",
|
||||
qr_restore_no_changes: "적용할 변경사항이 없습니다.",
|
||||
qr_restore_apply: "적용",
|
||||
qr_restore_changed: "변경",
|
||||
qr_restore_current_value: "현재",
|
||||
qr_restore_backup_value: "복원",
|
||||
qr_restore_same: "동일",
|
||||
qr_restore_skipped: "건너뜀",
|
||||
qr_restore_invalid: "오류",
|
||||
qr_restore_more: "{count}개 변경 더 있음",
|
||||
qr_restore_applied: "{count}개 Params 복원됨",
|
||||
qr_restore_https_required: "카메라를 사용하려면 HTTPS로 접속하세요.",
|
||||
qr_restore_camera_unsupported: "이 브라우저는 실시간 카메라를 지원하지 않습니다.",
|
||||
qr_restore_camera_failed: "카메라를 열 수 없습니다.",
|
||||
qr_restore_preview_failed: "백업을 읽지 못했습니다.",
|
||||
empty_value: "(비어 있음)",
|
||||
},
|
||||
actionLabels: {
|
||||
|
||||
@@ -213,6 +213,12 @@ window.CarrotTranslations.register("zh", {
|
||||
setting_value_title: "编辑值",
|
||||
setting_value_prompt: "请输入 {name} 的值\n范围: {min} - {max}",
|
||||
setting_value_invalid: "请输入有效数字。",
|
||||
setting_favorites: "收藏",
|
||||
setting_favorites_empty_title: "暂无收藏",
|
||||
setting_favorites_empty_desc: "长按设置项可添加收藏,再次长按可移除。",
|
||||
setting_favorite_added: "已添加到收藏",
|
||||
setting_favorite_removed: "已从收藏中移除",
|
||||
setting_favorites_save_failed: "保存收藏失败",
|
||||
branch_dom_missing: "找不到分支 DOM 元素。",
|
||||
fullscreen_not_supported: "此浏览器不支持全屏。",
|
||||
record: "录制",
|
||||
@@ -325,6 +331,12 @@ window.CarrotTranslations.register("zh", {
|
||||
video_controls: "视频控制",
|
||||
rewind_5: "后退 5 秒",
|
||||
forward_5: "前进 5 秒",
|
||||
pause: "暂停",
|
||||
ended: "结束",
|
||||
muted: "静音",
|
||||
fullscreen: "全屏",
|
||||
fullscreen_exit: "退出全屏",
|
||||
pip_exit: "退出画中画",
|
||||
git_remote_title: "更改仓库",
|
||||
git_remote_prompt: "当前: {url}\n\n请输入新的 GitHub 仓库 URL。\n(这将覆盖当前连接)",
|
||||
git_remote_fetching: "正在获取仓库数据。\n新仓库可能需要几分钟。\n请稍候...",
|
||||
@@ -348,6 +360,43 @@ window.CarrotTranslations.register("zh", {
|
||||
settings_not_loaded: "设置未加载",
|
||||
copy_settings_done: "已复制 {count} 个 Params",
|
||||
settings_title: "设置({count} 个 Params)",
|
||||
qr_backup: "QR 备份",
|
||||
qr_restore: "QR 恢复",
|
||||
qr_backup_title: "QR 备份",
|
||||
qr_restore_title: "QR 恢复",
|
||||
qr_backup_count: "{count} 个 Params",
|
||||
qr_backup_size: "{chars} 字符",
|
||||
qr_configuring: "正在配置 QR 功能...",
|
||||
qr_config_done: "QR 功能配置完成。",
|
||||
qr_config_failed: "无法完成 QR 功能配置。",
|
||||
qr_restore_upload: "图片",
|
||||
qr_restore_camera: "相机",
|
||||
qr_restore_camera_disabled: "相机不可用",
|
||||
qr_restore_stop_camera: "停止相机",
|
||||
qr_restore_paste_placeholder: "粘贴 QR 备份文本",
|
||||
qr_restore_check: "检查",
|
||||
qr_restore_hint: "使用相机扫描或选择 QR 备份图片后再恢复。",
|
||||
qr_restore_scan_hint: "请将相机对准 QR 码。",
|
||||
qr_restore_scan_detected: "请将 QR 码移入引导框内。",
|
||||
qr_restore_scan_aligned: "QR 码已对齐,请保持不动。",
|
||||
qr_restore_scan_locked: "QR 码已识别。",
|
||||
qr_restore_decode_failed: "未找到 QR 码。",
|
||||
qr_restore_previewing: "正在检查备份...",
|
||||
qr_restore_ready: "{count} 项更改可应用",
|
||||
qr_restore_no_changes: "没有可应用的更改。",
|
||||
qr_restore_apply: "应用",
|
||||
qr_restore_changed: "更改",
|
||||
qr_restore_current_value: "当前",
|
||||
qr_restore_backup_value: "备份",
|
||||
qr_restore_same: "相同",
|
||||
qr_restore_skipped: "跳过",
|
||||
qr_restore_invalid: "无效",
|
||||
qr_restore_more: "还有 {count} 项更改未显示",
|
||||
qr_restore_applied: "已恢复 {count} 个 Params",
|
||||
qr_restore_https_required: "请使用 HTTPS 打开此页面以使用相机。",
|
||||
qr_restore_camera_unsupported: "此浏览器不支持实时相机。",
|
||||
qr_restore_camera_failed: "无法打开相机。",
|
||||
qr_restore_preview_failed: "无法读取备份。",
|
||||
empty_value: "(空)",
|
||||
},
|
||||
actionLabels: {
|
||||
|
||||
10102
selfdrive/carrot/web/js/vendor/jsQR.js
vendored
Normal file
10102
selfdrive/carrot/web/js/vendor/jsQR.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2
selfdrive/carrot/web/js/vendor/plyr.min.js
vendored
Normal file
2
selfdrive/carrot/web/js/vendor/plyr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2297
selfdrive/carrot/web/js/vendor/qrcode-generator.js
vendored
Normal file
2297
selfdrive/carrot/web/js/vendor/qrcode-generator.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user