Co-authored-by: jominki354 <jomin354@gmail.com>
This commit is contained in:
jominki354
2026-05-04 11:51:28 +09:00
committed by ajouatom
parent 498a11c5bb
commit 833d0c35be
32 changed files with 16225 additions and 316 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View 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)

View File

@@ -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,
}

View 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)

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
}

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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";

View File

@@ -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]");

View File

@@ -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));

View File

@@ -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);

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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">&gt;</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();

View File

@@ -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) {

View File

@@ -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"));

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff