diff --git a/selfdrive/carrot/README.md b/selfdrive/carrot/README.md index de2193342d..7a9b5583f4 100644 --- a/selfdrive/carrot/README.md +++ b/selfdrive/carrot/README.md @@ -11,6 +11,7 @@ Anyone can modify. Refer to the structure below. ``` server/ +├── __init__.py ├── app.py composition root: middleware, lifecycle, make_app() ├── config.py constants (paths, URLs, tmux session, etc.) ├── live_runtime/ cereal SubMaster broker for /api/live_runtime @@ -30,6 +31,7 @@ server/ │ ├── time_sync.py browser → system time sync │ ├── device_info.py focused calibration + network helpers for Device tab │ ├── setting_favorites.py CarrotPilot setting favorites state +│ ├── setting_profiles.py CarrotPilot setting profile CRUD + import/export │ ├── web_settings.py device/server-backed Web Settings state │ └── tmux.py tmux session helpers └── features/ HTTP entry points (one feature per file/folder) @@ -39,6 +41,7 @@ server/ ├── settings.py /api/settings ├── params.py /api/params_*, /download/params_backup.json ├── setting_favorites.py /api/setting_favorites + ├── setting_profiles.py /api/setting_profiles, profile import/export ├── web_settings.py /api/web_settings ├── ssh_keys.py /api/ssh_keys ├── cars.py /api/cars @@ -83,12 +86,20 @@ web/ │ ├── layout.css page container, swipe, headings, sections │ ├── components.css dialog, toast, buttons, setting items, transitions │ ├── responsive.css desktop + mobile media queries (loads last) +│ ├── vendor/ +│ │ └── plyr.css Plyr video player styles │ └── pages/ │ ├── logs.css Logs/Dashcam page │ ├── drive.css WebRTC video + Carrot stage -│ ├── settings.css Settings page styles (includes device tab) │ ├── terminal.css Terminal page styles -│ └── tools.css Tools page styles +│ ├── settings/ Settings page styles, split for readability +│ │ ├── base.css page base, car entry, FAB menu (open/close anim) +│ │ ├── panels.css search panel, group list, profile sections, toolbar +│ │ └── device.css Device tab, settings-diff dialog, subnav, responsive +│ └── tools/ Tools page styles, split by feature +│ ├── base.css page base, meta/lang, Web Settings dialog +│ ├── qr.css QR code dialog +│ └── main.css groups, progress, notifications/console, responsive └── js/ ├── app.js bootstrap: popstate, initial showPage() ├── shared/ cross-page modules @@ -97,6 +108,7 @@ web/ │ ├── utils.js escapeHtml, clamp, copyToClipboard, quick link │ ├── i18n.js bootstrapped LANG, getUIText, renderUIText, setWebLanguage │ ├── api.js bulkGet, setParam, postJson, getJson, waitMs + │ ├── setting_diff.js setting-diff dialog helpers (used by settings + tools) │ ├── activity.js cross-page activity badges + beforeunload guard │ └── ui/ │ ├── dialog.js appAlert/Confirm/Prompt + toast @@ -112,17 +124,28 @@ web/ │ ├── setting_device_actions.js Device action/dialog handlers │ ├── setting_device.js Device tab coordinator and state │ ├── tools_web_settings.js server-backed Web Settings dialog + │ ├── tools_notifications.js Tools-tab notification preview/composer + │ ├── tools_settings_qr.js Settings QR import/export │ ├── tools.js tools page + initToolsPage + action runners │ ├── branch.js branch picker modal + Branch page - │ ├── logs.js Dashcam + Screen Recording lists - │ └── terminal.js tmux WebSocket client + │ ├── logs/ Logs page, split by tab + │ │ ├── shared.js tab state, scroll persistence, lazy-image observer, + │ │ │ video player, bind/init + │ │ ├── dashcam.js Dashcam tab: virtual route+segment list, upload subsystem + │ │ └── screenrecord.js Screen Recording tab: virtual list, lazy thumbs + │ ├── terminal.js tmux WebSocket client + │ └── vision_background.js static background for non-realtime pages ├── translations/ ko/en/zh/ja/fr + registry.js - └── realtime - app_realtime.js live runtime/raw stream wiring + HUD payload bridge - home_drive.js Carrot Vision renderer and overlay canvas - hud_card.js adaptive driving HUD card - raw_capnp.js raw capnp decoders for HUD/overlay state - raw_capnp_worker.js worker entry for raw capnp decoding + ├── realtime/ realtime stream stack (loaded together) + │ ├── hud_card.js adaptive driving HUD card + │ ├── raw_capnp.js raw capnp decoders for HUD/overlay state + │ ├── raw_capnp_worker.js worker entry for raw capnp decoding + │ ├── vision_state.js shared vision/HUD state + │ ├── vision_rtc.js WebRTC vision stream client + │ ├── vision_raw.js raw WebSocket vision client + decoder worker bridge + │ ├── app_realtime.js live runtime/raw stream wiring + HUD payload bridge + │ └── home_drive.js Carrot Vision renderer and overlay canvas + └── vendor/ third-party libraries (Plyr, jsQR, qrcode-generator) ``` ### Settings page tab structure @@ -137,8 +160,457 @@ The Setting page has two top-level tabs: Device tab adapts to hardware via `DeviceType` param (`tici`/`mici`/`tizi`). Load order (set in `index.html`): -`tokens → layout_tokens → hud_card → base → layout → pages/* → components → responsive` -then JS: -`translations → hud_card → shared/* → shared/ui/* → pages/* → realtime/* → app.js` -CSS files merge byte-identical with the previous single `app.css` if concatenated in the order above. JS scripts share the same global realm — top-level `let`/`const` are visible across files. +CSS: +``` +tokens → layout_tokens → hud_card → base → layout → components + → pages/logs → pages/terminal + → pages/settings/base → pages/settings/panels → pages/settings/device + → pages/tools/base → pages/tools/qr → pages/tools/main + → pages/drive → responsive → vendor/plyr +``` + +JS: +``` +vendor/* → translations → shared/* → shared/ui/* → pages/* → pages/logs/* → realtime/* → app +``` + +CSS files merge byte-identical with the previous single `settings.css` and `tools.css` if concatenated in the order above. JS scripts share the same global realm — top-level `let`/`const` are visible across files (so the logs split files all see the shared `logsActiveTab`, `dashcamState`, `screenrecordState`, etc.). + +### Recovery server (standalone) + +``` +recovery/ +├── __init__.py +└── server.py port 6999, minimal self-contained recovery UI +``` + +--- + +## Design System Reference + +Everything below is a working contract. **When in doubt, copy the pattern.** +Don't invent new motion durations, shadow stacks, z-index numbers, or focus +ring colors — use the tokens. New components should compose existing +primitives before adding their own. + +All tokens live in [css/tokens.css](web/css/tokens.css) with usage comments next to each group. + +### Color (Material 3 dark) + +#### Surface & text + +| Token | Use for | +|---|---| +| `--md-surface` | page background | +| `--md-surface-cont` | cards, list rows | +| `--md-surface-cont-l` | slightly recessed (input field background) | +| `--md-surface-cont-h` | raised surfaces (dialog sheet, popover) | +| `--md-surface-cont-hh` | nested raised (chip on a card) | +| `--md-surface-bright` | highlight surface (selected row hover) | +| `--md-on-surface` | primary text | +| `--md-on-surface-var` | secondary text, captions | +| `--md-outline` / `--md-outline-var` | borders, dividers | + +#### Brand & state surfaces + +| Token | Use for | +|---|---| +| `--md-primary` | accent (Carrot orange) | +| `--md-on-primary` | text on a primary-filled surface | +| `--md-action-filled` / `--md-on-action-filled` | primary action button (filled variant) | +| `--md-primary-state-soft` / `-state` / `-state-strong` | hover/pressed surfaces tinted by primary | + +#### Semantic status + +Use these — don't hardcode greens, ambers, blues. Each family has a base color, a `-strong` accent, a `-cont` (container surface for chips/badges), and an `-on-*-cont` (text on that container). + +| Family | Base | When | +|---|---|---| +| Success | `--md-success` (`#8fdc9b`) | confirmation, "OK", restored states | +| Warning | `--md-warning` (`#ffc94a`) | non-critical alerts, slow network, "may take a while" | +| Info | `--md-info` (`#7dd3fc`) | informational hints, "did you know" | +| Error | `--md-error` (`#ff9d94`) | soft errors — form validation, failed toast | +| Danger | `--md-danger` (`#ff8a80`) | alarm-level — active hazard, irreversible destructive | + +**Example — semantic chip:** +```html +Saved +Slow network +Recording +``` + +`prefers-contrast: more` shifts surface and outline tokens automatically — don't override per-component. + +### Motion + +| Duration | Value | Use for | +|---|---|---| +| `--motion-instant` | 80ms | state flicks (toggle on/off colour) | +| `--motion-quick` | 140ms | hover, small togglers, taps | +| `--motion-base` | 180ms | default for most things | +| `--motion-medium` | 240ms | FAB menus, sheets entering | +| `--motion-long` | 380ms | page transitions, large slides | + +| Easing | Use for | +|---|---| +| `--ease-standard` | symmetric in/out (default) | +| `--ease-emphasized` | enter / open / expand (decelerates onto place) | +| `--ease-emphasized-accelerate` | exit / close / dismiss (accelerates away) | +| `--ease-linear` | crossfades, progress bars only | + +**Pattern — asymmetric open/close (preferred):** +```css +.menu { transition: opacity var(--motion-quick) var(--ease-emphasized-accelerate); } +.menu.is-open { transition: opacity var(--motion-medium) var(--ease-emphasized); } +``` + +For a worked example see the Setting FAB menu in [css/pages/settings/base.css](web/css/pages/settings/base.css) (`.setting-fab-actions`). + +### State layer (Material 3) + +```css +--state-hover: 0.08; +--state-focus: 0.12; +--state-pressed: 0.12; +--state-dragged: 0.16; +``` + +**Pattern A — use the `.state-layer` helper:** position any interactive element relative, add the class, and it overlays a primary-tinted layer that intensifies on hover/focus/press. +```html + +``` + +**Pattern B — manual color-mix** (when you need control over which color tints): +```css +.row:hover { + background: color-mix(in srgb, var(--md-primary) var(--state-hover-pct), transparent); +} +``` + +### Elevation + +5 levels, dark-tuned, picked smallest-first. + +| Token | Use for | +|---|---| +| `--shadow-1` | hovered/lifted controls | +| `--shadow-2` | default cards, FAB | +| `--shadow-3` | popovers, dropdowns | +| `--shadow-4` | dialogs, sheets | +| `--shadow-5` | fullscreen modals, video player, pickers | + +For brand-tinted elevation (orange FAB) compose with the base shadow rather than re-encoding a coloured shadow inline: +```css +box-shadow: var(--shadow-3), 0 0 0 1px var(--md-primary); +``` + +### Z-index scale + +Use these tokens for cross-component layering. Local stacking inside one component (1/2/3) can stay as raw numbers. + +| Token | Value | Use for | +|---|---|---| +| `--z-base` | 1 | in-flow content | +| `--z-sticky` | 50 | sticky headers, subnav | +| `--z-rail` | 100 | side nav rail (landscape) | +| `--z-nav` | 120 | bottom nav bar | +| `--z-fab` | 130 | FAB / FAB menus | +| `--z-popover` | 150 | dropdowns, tooltips | +| `--z-modal` | 170 | dialogs, sheets, pickers | +| `--z-toast` | 200 | transient toast layer | +| `--z-overlay` | 220 | fullscreen overlays | + +### Focus & reduced motion (global) + +[base.css](web/css/base.css) sets: +- One global `:focus-visible` ring using `--focus-ring-*` tokens — covers every interactive element. Override only when shape requires it. +- `@media (prefers-reduced-motion: reduce)` collapses every animation/transition to 0.001ms so things still *snap* into state without movement. + +Both rules are intentionally broad. Don't recreate them per component. + +### Shared primitives ([components.css](web/css/components.css)) + +These exist so pages don't reinvent the same chip / icon button / loading +skeleton / empty state over and over. Compose them before writing new CSS. + +| Class | Variants | Use for | +|---|---|---| +| `.btn` | `--filled`, `--danger`, `.smallBtn` | text buttons | +| `.icon-btn` | `--circle`, `--ghost`, `--sm`, `--lg` | icon-only buttons (36×36 default) | +| `.chip` | `--accent`, `--danger`, `--success`, `--warning`, `--info` | status tags, counts, labels | +| `.skeleton` | `--circle` | loading placeholders (shimmer) | +| `.empty-state` | `__title`, `__message`, `__action` | "no items" cards in lists | +| `.state-layer` | — | M3 state overlay on any interactive surface | +| `.ui-stagger-item` | (uses CSS var `--i`) | sequenced list reveal animation | +| `.ui-dropdown-menu` | `__button`, `__panel`, `__item`, `--primary`, `--danger` | dropdown menus | +| `.ui-action-grid` | `--quick` | button grids (Tools quick actions) | +| `.app-dialog` | `__sheet`, `__title`, `__body`, `__actions` | dialogs (use the JS API instead) | +| `.app-toast` | `is-error`, `is-success`, `is-hint` | toasts (use `showAppToast` instead) | +| `.visually-hidden` | — | screen-reader-only text | + +#### Worked examples + +**Icon-only button** — picks up the global focus ring automatically: +```html + +``` + +**Status chip:** +```html +3 segments +Connected +Recording +``` + +**Loading skeleton** — animates a shimmer; respects reduced motion: +```html +
+
+``` + +**Empty state:** +```html +
+
No items
+
Try changing filters.
+ +
+``` + +**Staggered list reveal** — the animation runs once on append. Set `--i` +per item; the delay caps at 420 ms so long lists don't drag: +```js +items.forEach((el, i) => el.style.setProperty('--i', i)); +items.forEach((el) => el.classList.add('ui-stagger-item')); +``` + +**Accessible icon-only close (with hidden label):** +```html + +``` + +### Shared keyframes + +Named animations available via `animation: …`: + +| Keyframe | Where | Used by | +|---|---|---| +| `uiStaggerIn` | components.css | `.ui-stagger-item` — slide-up + fade-in | +| `skeleton-shimmer` | components.css | `.skeleton::after` — horizontal sweep | + +Per-feature animations (e.g. `dashcam-segment-append`, `tools-detail-open`, +`record-blink`) live in the relevant page CSS and use a +`-` name. Promote one to a shared keyframe only when a +new primitive will use it. + +### Naming conventions + +- **BEM-ish** is the working style. + - Block: `.app-dialog`, `.dashcam-route-card`. + - Element: `.app-dialog__title`, `.dashcam-route-card__head`. + - Modifier: `.btn--filled`, `.chip--success`, `.icon-btn--circle`. +- **State classes** (toggled at runtime): `.is-open`, `.is-active`, + `.is-loading`, `.is-error`, `.is-collapsed`, `.is-visible`. Always + `is-` prefixed. +- **Behavior vs. style separation:** + - `[data-action="play"]` → wired in JS via event delegation. + - `.is-active` → read by CSS only. + - Don't put `[data-action="…"]` in CSS selectors. +- **JS globals.** Top-level `let`/`const` are visible across every file + in load order (no modules). For state, prefer namespaced names — + `dashcamState`, `screenrecordState`, `settingFabMenuOpen`. Grep + before claiming a new name; collisions are real. +- **Translations.** `getUIText("key", "English fallback", { count })`. + Always provide the English fallback inline — it's the source string. + When adding a new key, update all five locale files in + [`web/js/translations/`](web/js/translations/). + +### JS UI utilities + +`shared/ui/` — call these instead of building your own modal/toast: + +| Function | Source | Use for | +|---|---|---| +| `appAlert`, `appConfirm`, `appPrompt`, `openAppDialog` | [dialog.js](web/js/shared/ui/dialog.js) | All modal text dialogs and choice sheets | +| `showAppToast(message, { tone, duration })` | dialog.js | Transient feedback. Tones: `default`, `error`, `success`, `hint` | +| `syncModalBodyLock` | dialog.js | Call after manually showing/hiding a sheet to lock body scroll | +| `createFocusTrap(container, opts)` | [focus_trap.js](web/js/shared/ui/focus_trap.js) | Required for any new modal/overlay (a11y) | +| `showPage`, page transition helpers | [navigation.js](web/js/shared/ui/navigation.js) | Page-level navigation | +| Viewport metrics, `--app-vv-height` | [viewport.js](web/js/shared/ui/viewport.js) | Responsive math against the real viewport | +| `escapeHtml`, `clamp`, `copyToClipboard` | [utils.js](web/js/shared/utils.js) | Always escape interpolated text in template literals | +| `getJson`, `postJson`, `bulkGet`, `setParam` | [api.js](web/js/shared/api.js) | Backend access (don't use raw `fetch`) | + +**Modal pattern with focus trap** — required for any new dialog/overlay +to remain keyboard-accessible: +```js +const overlay = document.createElement("div"); +overlay.className = "my-overlay"; +overlay.setAttribute("role", "dialog"); +overlay.setAttribute("aria-modal", "true"); +overlay.innerHTML = `
`; +document.body.appendChild(overlay); + +const trap = createFocusTrap(overlay, { + initialFocus: ".my-overlay__primary", // selector or element + escape: () => close(), // optional Esc handler +}); +trap.activate(); + +function close() { + trap.deactivate(); // restores focus to whoever had it before open + overlay.remove(); + syncModalBodyLock(); +} +``` + +Page-change broadcasting: +```js +window.addEventListener("carrot:pagechange", (ev) => { /* ev.detail.page */ }); +``` + +Language-change broadcasting (re-render translated strings): +```js +window.addEventListener("carrot:languagechange", () => { /* re-render */ }); +``` + +### Page lifecycle + +- The current page is on `body[data-page="…"]`. Listen for + `carrot:pagechange` to **clean up everything you started**: timers, + observers, WebSockets, scroll listeners. Mirror the + [`handleLogsPageChange`](web/js/pages/logs/shared.js) pattern. +- For lists with more than ~30 items: build a virtual window with + top/bottom spacers, not a flat render. The canonical pattern is in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js) — `dashcamWindowFor` + computes the visible slice and `patchDashcamWindow` patches the DOM. +- For "load more" sentinels at list ends: use `IntersectionObserver` + with the scroll container as `root`. See + `ensureDashcamSegmentLoaderObserver` in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js). **Never poll + `getBoundingClientRect()` from a scroll handler** — it forces a + layout per frame and kills scroll smoothness. +- For lazy images: reuse `hydrateLogsLazyImages` / + `loadLogsLazyImage` in [`logs/shared.js`](web/js/pages/logs/shared.js). + +### Accessibility checklist for new UI + +1. Every interactive element is focusable and reachable by keyboard (Tab / Shift+Tab). +2. Icon-only buttons have an `aria-label`. +3. Dialogs use `role="dialog" aria-modal="true"` and a `.app-dialog__title` for the accessible name. +4. The global `:focus-visible` ring is visible — don't `outline: none` without a replacement. +5. Color is never the *only* signal (status chips include a label, error rows have an icon). +6. Animations respect the global `prefers-reduced-motion` guard — don't bypass it. +7. Tap targets are ≥ 44×44px (Material 3 / WCAG). + +### Anti-patterns (common mistakes) + +| Don't | Do | Why | +|---|---|---| +| `transition: opacity 0.2s ease;` | `transition: opacity var(--motion-base) var(--ease-standard);` | Consistent motion + reduced-motion guard hooks in | +| `z-index: 170;` | `z-index: var(--z-modal);` | Magic numbers drift; tokens describe intent | +| `color: #8fdc9b;` | `color: var(--md-success);` | Hardcoded greens fragment the palette | +| `box-shadow: 0 18px 40px rgba(0,0,0,.34);` | `box-shadow: var(--shadow-4);` | Same elevation everywhere = clear hierarchy | +| `confirm("…")`, `alert("…")` | `await appConfirm("…")`, `showAppToast("…")` | Native dialogs ignore theming and block the page | +| `outline: none;` (then no replacement) | leave the global ring, or replace with an equivalent | Keyboard users lose all feedback | +| `` (no label) | add `aria-label="…"` or a `.visually-hidden` text | Screen readers can't announce the button | +| `addEventListener("touchmove", h)` | `addEventListener("touchmove", h, { passive: true })` | Non-passive `touchmove` blocks the compositor — every scroll jumps | +| `setInterval(check, 16)` polling rects | `IntersectionObserver` on the sentinel | rAF-rate rect polling kills scrolling on mobile | +| `\`
${userText}
\`` | `\`
${escapeHtml(userText)}
\`` | Template literals are interpolated raw; XSS risk | +| `fetch("/api/...")` | `getJson("/api/...")` / `postJson(...)` | Consistent error handling, JSON parsing, auth headers | + +### Failure modes specific to this codebase + +- **Scroll jank from non-passive listeners.** Any `touchmove` listener + that isn't `passive: true` makes the entire scroll path go through + the main thread. Even if you never call `preventDefault()`, the + browser must wait for JS to decide. The dashcam segment list had this + bug and removing the guard fixed it. +- **`[hidden]` kills transitions.** `[hidden] { display: none }` removes + the element from layout — there's nothing for a transition to animate + from/to. To animate close, remove `is-open` first to start the + transition, then set `hidden` after the duration completes (use + `--motion-medium` as the timer). +- **`replaceChildren` resets nested `scrollTop`.** When a virtual list + re-renders, child scroll containers lose their position. Capture + before replacing, restore after — see + `rememberVisibleDashcamSegmentScrolls` / + `restoreVisibleDashcamSegmentScrolls` in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js). +- **Asymmetric container padding.** Padding cascades to every child. + If a list looks lopsided, check the parent's `padding` first. The + fix in dashcam was `padding: 12px 28px 12px 12px` → `padding: 12px 14px`. +- **Hardcoded scrollbar gutter.** On touch, scrollbars overlay and + don't take space — `padding-right: 2px` to "make room" creates + visible asymmetry on devices that show the scrollbar. Either + `scrollbar-width: none` (hide on touch) or symmetric + `padding-inline`. +- **Loader height change.** A loader that grows from 16 px to 22 px on + state change shoves the list above it. Keep the loader at a fixed + height and fade in the indicator with opacity instead. + +### When adding a new page + +1. Add `` for `css/pages/.css` in load order (after `components.css`). +2. Add ` - - - - + + + + @@ -619,6 +623,7 @@

Home

+ @@ -629,21 +634,23 @@

Home

- + - - + + - - + + + + - - - - - + + + + + - + diff --git a/selfdrive/carrot/web/js/pages/branch.js b/selfdrive/carrot/web/js/pages/branch.js index e363d0cfd8..0df27a77ff 100644 --- a/selfdrive/carrot/web/js/pages/branch.js +++ b/selfdrive/carrot/web/js/pages/branch.js @@ -365,50 +365,5 @@ async function onSelectBranch(item) { } } -/* ---------- Logs / Dashcam ---------- */ -const dashcamState = { - initialized: false, - loading: false, - routes: [], - 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"; -const logsScrollTops = { dashcam: 0, screen: 0 }; -let logsLazyImageObserver = null; +// Logs page state and helpers moved to js/pages/logs/{shared,dashcam,screenrecord}.js diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs/dashcam.js similarity index 53% rename from selfdrive/carrot/web/js/pages/logs.js rename to selfdrive/carrot/web/js/pages/logs/dashcam.js index 2fecaa6e62..bd7fcbb389 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs/dashcam.js @@ -1,53 +1,46 @@ "use strict"; -// Logs page — Dashcam (route+segment listing, FFmpeg thumb/preview, FTP upload) -// + Screen Recording listing/playback. Tab switching between the two. +// Logs page — Dashcam tab. +// Route + segment virtual listing, FFmpeg thumb/preview lazy load, +// segment selection, FTP upload (with cancel/resume), segment menu, player. const DASHCAM_UPLOAD_JOB_STORAGE_KEY = "carrot_dashcam_upload_job_id"; -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; +const DASHCAM_ROUTE_PAGE_MIN = 10; +const DASHCAM_ROUTE_PAGE_MAX = 40; +const DASHCAM_ROUTE_PAGE_VIEWPORTS = 3; +const DASHCAM_SEGMENT_PAGE_SIZE = 10; +const DASHCAM_LOAD_AHEAD_VIEWPORTS = 1.5; +const DASHCAM_ROUTE_WINDOW_OVERSCAN_VIEWPORTS = 1.25; let dashcamUploadActiveJobId = null; let dashcamUploadResumePromise = null; -function isLogsPageActive() { - return CURRENT_PAGE === "logs"; -} - -function getLogsScroller(tab = logsActiveTab) { - return document.getElementById(tab === "screen" ? "screenrecordVideos" : "dashcamRoutes"); -} - -function saveLogsScrollTop(tab = logsActiveTab) { - const scroller = getLogsScroller(tab); - if (!scroller) return; - logsScrollTops[tab === "screen" ? "screen" : "dashcam"] = scroller.scrollTop || 0; -} - -function restoreLogsScrollTop(tab = logsActiveTab, options = {}) { - const scroller = getLogsScroller(tab); - if (!scroller) return; - const key = tab === "screen" ? "screen" : "dashcam"; - const nextTop = options.reset === true ? 0 : (logsScrollTops[key] || 0); - if (CURRENT_PAGE === "logs") { - window.scrollTo(0, 0); - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - } - requestAnimationFrame(() => { - if (!isLogsPageActive()) return; - scroller.scrollTop = nextTop; - requestAnimationFrame(() => { - if (!isLogsPageActive()) return; - scroller.scrollTop = nextTop; - if (key === "dashcam" && typeof scheduleDashcamWindowRender === "function") scheduleDashcamWindowRender(); - if (key === "screen" && typeof scheduleScreenrecordWindowRender === "function") scheduleScreenrecordWindowRender(); - }); - }); -} +const dashcamState = { + initialized: false, + loading: false, + routes: [], + expanded: new Set(), + selected: new Set(), + refreshTimer: null, + loadingMore: false, + loadingSegments: new Set(), + segmentScrollTops: Object.create(null), + 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: "", +}; function dashcamSegmentIndex(segment) { const parts = String(segment || "").split("--"); @@ -63,29 +56,6 @@ function dashcamApiPath(kind, segment) { return `/api/dashcam/${kind}/${encodeURIComponent(segment)}`; } -function formatRelativeEpoch(epochSeconds) { - const epoch = Number(epochSeconds || 0); - if (!Number.isFinite(epoch) || epoch <= 0) return ""; - const delta = Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(epoch)); - if (delta < 60) return getUIText("just_now", "just now"); - if (delta < 3600) return getUIText("minutes_ago", "{count} min ago", { count: Math.floor(delta / 60) }); - if (delta < 86400) return getUIText("hours_ago", "{count} hr ago", { count: Math.floor(delta / 3600) }); - return getUIText("days_ago", "{count} days ago", { count: Math.floor(delta / 86400) }); -} - -function localizeRelativeLabel(label) { - const text = String(label || "").trim(); - if (!text) return ""; - if (/^(방금\s*전|just\s*now)$/i.test(text)) return getUIText("just_now", "just now"); - const minuteMatch = text.match(/^(\d+)\s*(?:분\s*전|min(?:ute)?s?\s*ago)$/i); - if (minuteMatch) return getUIText("minutes_ago", "{count} min ago", { count: minuteMatch[1] }); - const hourMatch = text.match(/^(\d+)\s*(?:시간\s*전|hr?s?\s*ago|hour?s?\s*ago)$/i); - if (hourMatch) return getUIText("hours_ago", "{count} hr ago", { count: hourMatch[1] }); - const dayMatch = text.match(/^(\d+)\s*(?:일\s*전|day?s?\s*ago)$/i); - if (dayMatch) return getUIText("days_ago", "{count} days ago", { count: dayMatch[1] }); - return text; -} - function setDashcamStatus(message, tone = "") { const status = document.getElementById("dashcamStatus"); if (!status) return; @@ -99,42 +69,16 @@ function setDashcamMeta(message) { if (meta) meta.textContent = message; } -function setScreenrecordStatus(message, tone = "") { - const status = document.getElementById("screenrecordStatus"); - if (!status) return; - status.textContent = message || ""; - status.hidden = !message; - status.classList.toggle("is-error", tone === "error"); -} - -function formatLogBytes(bytes) { - const n = Number(bytes) || 0; - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; - return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} - -function screenrecordApiPath(kind, fileId) { - return `/api/screenrecord/${kind}/${encodeURIComponent(fileId)}`; -} - function dashcamRoutesSignature(routes) { return (routes || []).map((entry) => [ entry.route || "", + entry.segmentCount || 0, + entry.segmentsNextOffset ?? "", + entry.segmentsHasMore ? "1" : "0", ...(entry.segmentFolders || []), ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); } -function screenrecordVideosSignature(videos) { - return (videos || []).map((video) => [ - video.id || "", - video.name || "", - video.modifiedLabel || "", - video.size || 0, - ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); -} - function dashcamDefaultRouteHeight() { return isCompactLandscapeMode() ? 210 : 310; } @@ -156,6 +100,16 @@ function dashcamRouteHeightFor(route) { return Math.max(120, fallback); } +function dashcamRoutePageSize(scroller = document.getElementById("dashcamRoutes")) { + const rowHeight = Math.max(120, dashcamRouteHeightFor("")); + const viewportHeight = Math.max(rowHeight, scroller?.clientHeight || window.innerHeight || rowHeight); + const visibleRows = Math.max(1, Math.ceil(viewportHeight / rowHeight)); + return Math.max( + DASHCAM_ROUTE_PAGE_MIN, + Math.min(DASHCAM_ROUTE_PAGE_MAX, visibleRows * DASHCAM_ROUTE_PAGE_VIEWPORTS) + ); +} + function dashcamRouteGap(host) { const styles = window.getComputedStyle?.(host); return Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0; @@ -166,7 +120,8 @@ function dashcamWindowFor(host, 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 rowHeight = Math.max(120, dashcamRouteHeightFor("")); + const overscanPx = Math.max(rowHeight * 2, viewportHeight * DASHCAM_ROUTE_WINDOW_OVERSCAN_VIEWPORTS); const minTop = Math.max(0, scrollTop - overscanPx); const maxBottom = scrollTop + viewportHeight + overscanPx; const gap = dashcamRouteGap(host); @@ -190,7 +145,7 @@ function dashcamWindowFor(host, routes) { 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)); + const minEnd = Math.min(count, Math.max(end + Math.ceil(overscanPx / rowHeight), start + 1)); while (end < minEnd) { endHeight += dashcamRouteHeightFor(list[end]?.route) + (end > 0 ? gap : 0); end += 1; @@ -205,184 +160,70 @@ function dashcamWindowFor(host, routes) { 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; + return remaining <= Math.max(360, scroller.clientHeight * DASHCAM_LOAD_AHEAD_VIEWPORTS); } -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 cancelDashcamRouteRender() { + if (dashcamState.renderFrame) { + window.cancelAnimationFrame(dashcamState.renderFrame); + dashcamState.renderFrame = 0; + } } -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 setDashcamLoadingMoreUi(active) { + const host = document.getElementById("dashcamRoutes"); + if (!host) return; + host.classList.toggle("is-loading-more", Boolean(active)); } -function screenrecordSpacerNode(height, position) { +function dashcamSpacerNode(height, position) { if (height <= 0) return null; const node = document.createElement("div"); - node.className = "screenrecord-virtual-spacer"; + node.className = "dashcam-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 dashcamSegmentListRoute(list) { + return list?.closest?.("[data-route-card]")?.dataset.routeCard || ""; } -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 rememberDashcamSegmentScroll(list) { + const route = dashcamSegmentListRoute(list); + if (!route || !dashcamState.segmentScrollTops) return; + dashcamState.segmentScrollTops[route] = Math.max(0, list.scrollTop || 0); } -function setScreenrecordLoadingMoreUi(active) { - const host = document.getElementById("screenrecordVideos"); +function rememberVisibleDashcamSegmentScrolls(host = document.getElementById("dashcamRoutes")) { if (!host) return; - host.classList.toggle("is-loading-more", Boolean(active)); + host.querySelectorAll(".dashcam-segment-list").forEach((list) => rememberDashcamSegmentScroll(list)); } -function scheduleScreenrecordWindowRender() { - if (screenrecordState.renderFrame) return; - screenrecordState.renderFrame = requestAnimationFrame(() => { - screenrecordState.renderFrame = 0; - renderScreenrecordVideos({ preserve: true }); - }); -} - -function loadLogsLazyImage(img) { - if (!img) return; - const src = img.dataset?.src || ""; - if (!src) return; - img.src = src; - img.removeAttribute("data-src"); +function restoreDashcamSegmentScroll(list) { + const route = dashcamSegmentListRoute(list); + if (!route || !dashcamState.segmentScrollTops) return; + const nextTop = Number(dashcamState.segmentScrollTops[route]); + if (!Number.isFinite(nextTop) || nextTop <= 0) return; + list.scrollTop = nextTop; } -function hydrateLogsLazyImages(root) { - if (!isLogsPageActive()) return; - const scope = root || document; - const images = Array.from(scope.querySelectorAll?.("img[data-src]") || []); - if (!images.length) return; - - if (!("IntersectionObserver" in window)) { - images.forEach(loadLogsLazyImage); - return; - } - - if (!logsLazyImageObserver) { - logsLazyImageObserver = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - logsLazyImageObserver.unobserve(entry.target); - loadLogsLazyImage(entry.target); - }); - }, { root: null, rootMargin: "720px 0px", threshold: 0.01 }); - } - - images.forEach((img) => logsLazyImageObserver.observe(img)); -} - -function disconnectLogsLazyImages() { - if (!logsLazyImageObserver) return; - logsLazyImageObserver.disconnect(); - 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"; - return ``; -} - -function logsEmptyStateHtml(type = "dashcam") { - const isScreen = type === "screen"; - const title = isScreen - ? getUIText("screenrecord_empty_title", "No screen recordings") - : getUIText("dashcam_empty_title", "No dashcam records"); - - return ` -
-
${escapeHtml(title)}
-
`; -} - -function cancelDashcamRouteRender() { - if (dashcamState.renderFrame) { - window.cancelAnimationFrame(dashcamState.renderFrame); - dashcamState.renderFrame = 0; - } -} - -function setDashcamLoadingMoreUi(active) { - const host = document.getElementById("dashcamRoutes"); +function restoreVisibleDashcamSegmentScrolls(host = document.getElementById("dashcamRoutes")) { if (!host) return; - host.classList.toggle("is-loading-more", Boolean(active)); -} - -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; + host.querySelectorAll(".dashcam-segment-list").forEach((list) => restoreDashcamSegmentScroll(list)); + requestAnimationFrame(() => { + if (!host.isConnected) return; + host.querySelectorAll(".dashcam-segment-list").forEach((list) => restoreDashcamSegmentScroll(list)); + }); } function dashcamRouteRenderKey(entry) { const route = String(entry?.route || ""); const selected = dashcamSelectedForRoute(entry || { segmentFolders: [] }).join(","); - const segments = Array.isArray(entry?.segmentFolders) ? entry.segmentFolders.join(",") : ""; + const segments = dashcamSegmentsForRoute(entry).join(","); return [ isCompactLandscapeMode() ? "landscape" : "portrait", dashcamState.expanded.has(route) ? "expanded" : "collapsed", @@ -391,6 +232,10 @@ function dashcamRouteRenderKey(entry) { entry?.dateLabel || "", entry?.latestModifiedEpoch || "", entry?.latestModifiedLabel || "", + dashcamSegmentCountForRoute(entry), + entry?.segmentsNextOffset ?? "", + entry?.segmentsHasMore ? "more" : "done", + dashcamState.loadingSegments?.has(route) ? "loading" : "idle", segments, selected, ].join("|"); @@ -416,6 +261,7 @@ function dashcamRouteNode(entry, index, existingCards, options = {}) { } function patchDashcamWindow(host, routes, view, options = {}) { + rememberVisibleDashcamSegmentScrolls(host); const existingCards = new Map( Array.from(host.querySelectorAll("[data-route-card]")) .map((node) => [node.dataset.routeCard || "", node]) @@ -431,7 +277,17 @@ function patchDashcamWindow(host, routes, view, options = {}) { }); if (bottomSpacer) frag.appendChild(bottomSpacer); unobserveLogsLazyImages(host); + unobserveDashcamSegmentLoaders(host); host.replaceChildren(frag); + restoreVisibleDashcamSegmentScrolls(host); +} + +function unobserveDashcamSegmentLoaders(host) { + if (!dashcamSegmentLoaderObserver || !host) return; + host.querySelectorAll?.("[data-segment-loader]").forEach((loader) => { + dashcamSegmentLoaderObserver.unobserve(loader); + delete loader.dataset.observed; + }); } function measureDashcamRouteHeights(host) { @@ -492,8 +348,148 @@ function maybeLoadMoreDashcamRoutes(scroller = document.getElementById("dashcamR loadDashcamRoutes({ silent: true, append: true }).catch(() => {}); } +function dashcamSegmentsForRoute(entry) { + return Array.isArray(entry?.segmentFolders) ? entry.segmentFolders : []; +} + +function dashcamSegmentCountForRoute(entry) { + const total = Number(entry?.segmentCount); + const loaded = dashcamSegmentsForRoute(entry).length; + return Number.isFinite(total) && total >= loaded ? total : loaded; +} + +function dashcamRouteHasMoreSegments(entry) { + return Boolean(entry?.segmentsHasMore) || dashcamSegmentsForRoute(entry).length < dashcamSegmentCountForRoute(entry); +} + +function dashcamSegmentNextOffset(entry) { + const next = Number(entry?.segmentsNextOffset); + if (Number.isFinite(next) && next >= 0) return next; + return dashcamSegmentsForRoute(entry).length; +} + +function mergeDashcamSegments(existing, incoming) { + const merged = []; + const seen = new Set(); + [...(existing || []), ...(incoming || [])].forEach((segment) => { + if (!segment || seen.has(segment)) return; + seen.add(segment); + merged.push(segment); + }); + return merged.sort((a, b) => dashcamSegmentIndex(a) - dashcamSegmentIndex(b)); +} + +function mergeDashcamRoutePage(entry, existing) { + if (!entry || !existing) return entry; + const route = String(entry.route || ""); + if (!route || route !== existing.route) return entry; + const incomingSegments = dashcamSegmentsForRoute(entry); + const existingSegments = dashcamSegmentsForRoute(existing); + if (existingSegments.length <= incomingSegments.length) return entry; + + const mergedSegments = mergeDashcamSegments(incomingSegments, existingSegments); + const total = Math.max(dashcamSegmentCountForRoute(entry), mergedSegments.length); + return { + ...entry, + segmentFolders: mergedSegments, + segmentCount: total, + segmentsNextOffset: mergedSegments.length < total + ? Math.max(dashcamSegmentNextOffset(entry), dashcamSegmentNextOffset(existing), mergedSegments.length) + : null, + segmentsHasMore: mergedSegments.length < total, + }; +} + +let dashcamSegmentLoaderObserver = null; +function ensureDashcamSegmentLoaderObserver(scroller) { + if (!scroller || !("IntersectionObserver" in window)) return null; + if (dashcamSegmentLoaderObserver && dashcamSegmentLoaderObserver._root === scroller) { + return dashcamSegmentLoaderObserver; + } + if (dashcamSegmentLoaderObserver) dashcamSegmentLoaderObserver.disconnect(); + dashcamSegmentLoaderObserver = new IntersectionObserver((entries) => { + if (!isLogsPageActive()) return; + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const route = entry.target.dataset?.route || ""; + if (!route || dashcamState.loadingSegments?.has(route)) return; + loadDashcamSegments(route).catch(() => {}); + }); + }, { root: scroller, rootMargin: "240px 0px", threshold: 0.01 }); + dashcamSegmentLoaderObserver._root = scroller; + return dashcamSegmentLoaderObserver; +} + +function maybeLoadVisibleDashcamSegments(scroller = document.getElementById("dashcamRoutes")) { + if (!scroller || !isLogsPageActive()) return; + const observer = ensureDashcamSegmentLoaderObserver(scroller); + if (observer) { + scroller.querySelectorAll("[data-segment-loader]").forEach((loader) => { + if (loader.dataset.observed === "1") return; + loader.dataset.observed = "1"; + observer.observe(loader); + }); + return; + } + // Fallback for environments without IntersectionObserver + const hostRect = scroller.getBoundingClientRect(); + scroller.querySelectorAll("[data-segment-loader]").forEach((loader) => { + const route = loader.dataset.route || ""; + if (!route || dashcamState.loadingSegments?.has(route)) return; + const rect = loader.getBoundingClientRect(); + if (rect.top <= hostRect.bottom + 160 && rect.bottom >= hostRect.top - 40) { + loadDashcamSegments(route).catch(() => {}); + } + }); +} + +let segmentListPersistFrame = 0; +const segmentListPersistQueue = new Set(); +function scheduleSegmentListScrollPersist(list) { + if (!list) return; + segmentListPersistQueue.add(list); + if (segmentListPersistFrame) return; + segmentListPersistFrame = requestAnimationFrame(() => { + segmentListPersistFrame = 0; + segmentListPersistQueue.forEach((node) => { + if (node.isConnected) rememberDashcamSegmentScroll(node); + }); + segmentListPersistQueue.clear(); + }); +} + function dashcamSelectedForRoute(entry) { - return (entry.segmentFolders || []).filter((segment) => dashcamState.selected.has(segment)); + return dashcamSegmentsForRoute(entry).filter((segment) => dashcamState.selected.has(segment)); +} + +function dashcamSegmentTileHtml(route, segment, segmentIndex, options = {}) { + const compactSegments = options.compact === true; + const animate = options.animate === true; + const routeAttr = escapeHtml(route); + const segAttr = escapeHtml(segment); + const checked = dashcamState.selected.has(segment) ? " checked" : ""; + const tileClass = [ + "dashcam-segment-tile", + compactSegments ? "dashcam-segment-tile--compact" : "", + animate ? "dashcam-segment-tile--append" : "", + ].filter(Boolean).join(" "); + const thumbClass = compactSegments ? "dashcam-segment-thumb dashcam-segment-thumb--compact" : "dashcam-segment-thumb"; + const checkClass = compactSegments ? "dashcam-segment-check dashcam-segment-check--compact" : "dashcam-segment-check"; + return `
+
+ + +
+
+
SEG ${dashcamSegmentIndex(segment)}
+
${segAttr}
+
+ +
`; } function dashcamRouteCardHtml(entry, index = 0, options = {}) { @@ -501,12 +497,19 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) { 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 segments = dashcamSegmentsForRoute(entry); + const segmentCount = dashcamSegmentCountForRoute(entry); + const loadedCount = segments.length; + const hasMoreSegments = dashcamRouteHasMoreSegments(entry); + const loadingSegments = dashcamState.loadingSegments?.has(route); const expanded = dashcamState.expanded.has(route); const compactSegments = isCompactLandscapeMode(); const shouldRenderSegments = expanded || compactSegments; const selected = dashcamSelectedForRoute(entry); const allSelected = segments.length > 0 && selected.length === segments.length; + const selectLabel = allSelected + ? getUIText(hasMoreSegments ? "deselect_loaded" : "deselect_all", "Deselect all") + : getUIText(hasMoreSegments ? "select_loaded" : "select_all", "Select all"); const representative = segments[0] || ""; const routeAttr = escapeHtml(route); const title = escapeHtml(entry.title || dashcamRouteTitle(route)); @@ -518,7 +521,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
- ${escapeHtml(getUIText("segment_count", "{count} segments", { count: segments.length }))} + ${escapeHtml(getUIText("segment_count", "{count} segments", { count: segmentCount }))} ${latest}
` : ""; const segmentList = shouldRenderSegments ? segments.map((segment, segmentIndex) => { - const segAttr = escapeHtml(segment); - const checked = dashcamState.selected.has(segment) ? " checked" : ""; - if (compactSegments) { - return `
-
- - -
-
-
SEG ${dashcamSegmentIndex(segment)}
-
${segAttr}
-
- -
`; - } - return `
-
- - -
-
-
SEG ${dashcamSegmentIndex(segment)}
-
${segAttr}
-
- -
`; + return dashcamSegmentTileHtml(route, segment, segmentIndex, { compact: compactSegments }); }).join("") : ""; + const segmentLoader = shouldRenderSegments && hasMoreSegments + ? `` + : ""; return `
${preview} @@ -583,10 +556,10 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
${escapeHtml(getUIText("selected_count", "{count} selected", { count: selected.length }))} - +
-
${segmentList}
+
${segmentList}${segmentLoader}
`; @@ -634,6 +607,7 @@ function renderDashcamRoutes(options = {}) { requestAnimationFrame(() => { if (!isLogsPageActive()) return; if (measureDashcamRouteHeights(host) && !dashcamState.scrollBusy) scheduleDashcamWindowRender(); + maybeLoadVisibleDashcamSegments(host); }); } @@ -660,6 +634,7 @@ function renderDashcamRoute(route) { requestAnimationFrame(() => { if (!isLogsPageActive()) return; if (measureDashcamRouteHeights(host)) scheduleDashcamWindowRender(); + maybeLoadVisibleDashcamSegments(host); }); return true; } @@ -674,9 +649,10 @@ function updateDashcamRouteSelectionUi(route) { .find((node) => node.dataset.routeCard === route); if (!card) return false; - const segments = Array.isArray(entry.segmentFolders) ? entry.segmentFolders : []; + const segments = dashcamSegmentsForRoute(entry); const selected = dashcamSelectedForRoute(entry); const allSelected = segments.length > 0 && selected.length === segments.length; + const hasMoreSegments = dashcamRouteHasMoreSegments(entry); const countEl = card.querySelector(".dashcam-selection-count"); if (countEl) countEl.textContent = getUIText("selected_count", "{count} selected", { count: selected.length }); @@ -684,7 +660,9 @@ function updateDashcamRouteSelectionUi(route) { const selectBtn = card.querySelector('[data-action="select-route"]'); if (selectBtn) { selectBtn.dataset.selected = allSelected ? "1" : "0"; - selectBtn.textContent = allSelected ? getUIText("deselect_all", "Deselect all") : getUIText("select_all", "Select all"); + selectBtn.textContent = allSelected + ? getUIText(hasMoreSegments ? "deselect_loaded" : "deselect_all", "Deselect all") + : getUIText(hasMoreSegments ? "select_loaded" : "select_all", "Select all"); } const uploadBtn = card.querySelector('[data-action="upload-selected"]'); @@ -699,6 +677,100 @@ function updateDashcamRouteSelectionUi(route) { return true; } +function updateDashcamSegmentLoaderUi(route, loading = false) { + const host = document.getElementById("dashcamRoutes"); + const entry = (dashcamState.routes || []).find((item) => item.route === route); + if (!host || !entry) return false; + const card = Array.from(host.querySelectorAll("[data-route-card]")) + .find((node) => node.dataset.routeCard === route); + if (!card) return false; + const loader = card.querySelector("[data-segment-loader]"); + if (!loader) return false; + if (!dashcamRouteHasMoreSegments(entry)) { + loader.remove(); + return true; + } + loader.classList.toggle("is-loading", Boolean(loading)); + return true; +} + +function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { + if (!newSegments.length) return false; + const host = document.getElementById("dashcamRoutes"); + const entry = (dashcamState.routes || []).find((item) => item.route === route); + if (!host || !entry) return false; + const card = Array.from(host.querySelectorAll("[data-route-card]")) + .find((node) => node.dataset.routeCard === route); + const list = card?.querySelector(".dashcam-segment-list"); + if (!card || !list) return false; + + const scrollTop = list.scrollTop; + const wasScrollable = list.scrollHeight > list.clientHeight + 1; + const wasNearBottom = wasScrollable && (list.scrollHeight - list.scrollTop - list.clientHeight <= 48); + const isScrolling = Boolean(dashcamState.scrollBusy); + rememberDashcamSegmentScroll(list); + const compact = isCompactLandscapeMode(); + const template = document.createElement("template"); + // Don't animate tile entry while the user is actively scrolling — the + // simultaneous animation + scrollTop adjustment is what causes "shake". + const animate = !isScrolling; + template.innerHTML = newSegments + .map((segment, offset) => dashcamSegmentTileHtml(route, segment, startIndex + offset, { compact, animate })) + .join(""); + const loader = list.querySelector("[data-segment-loader]"); + list.insertBefore(template.content, loader || null); + hydrateLogsLazyImages(list); + updateDashcamRouteSelectionUi(route); + updateDashcamSegmentLoaderUi(route, false); + // Pin to bottom only when the user wasn't actively scrolling — otherwise + // setting scrollTop fights inertia and produces a visible jump/shake. + if (wasNearBottom && !isScrolling) { + const nextTop = Math.max(0, list.scrollHeight - list.clientHeight); + list.scrollTop = nextTop; + rememberDashcamSegmentScroll(list); + } else { + // Preserve current position; browser will keep inertia smooth. + list.scrollTop = scrollTop; + rememberDashcamSegmentScroll(list); + } + card.dataset.renderKey = dashcamRouteRenderKey(entry); + return true; +} + +async function loadDashcamSegments(route) { + if (!route || dashcamState.loadingSegments?.has(route)) return; + const entry = (dashcamState.routes || []).find((item) => item.route === route); + if (!entry || !dashcamRouteHasMoreSegments(entry)) return; + const previousCount = dashcamSegmentsForRoute(entry).length; + dashcamState.loadingSegments.add(route); + markDashcamScrollBusy({ renderOnIdle: false }); + updateDashcamSegmentLoaderUi(route, true); + + try { + const offset = dashcamSegmentNextOffset(entry); + const json = await getJson(`/api/dashcam/segments/${encodeURIComponent(route)}?offset=${offset}&limit=${DASHCAM_SEGMENT_PAGE_SIZE}`); + const current = (dashcamState.routes || []).find((item) => item.route === route); + if (!current) return; + const incoming = Array.isArray(json.segments) ? json.segments : []; + const existing = new Set(dashcamSegmentsForRoute(current)); + const appended = incoming.filter((segment) => segment && !existing.has(segment)); + current.segmentFolders = mergeDashcamSegments(dashcamSegmentsForRoute(current), incoming); + current.segmentCount = Number.isFinite(Number(json.total)) ? Number(json.total) : dashcamSegmentCountForRoute(current); + current.segmentsNextOffset = json.nextOffset == null ? current.segmentFolders.length : Number(json.nextOffset) || current.segmentFolders.length; + current.segmentsHasMore = Boolean(json.hasMore); + dashcamState.signature = dashcamRoutesSignature(dashcamState.routes); + if (appended.length && appendDashcamSegmentsToRoute(route, appended, previousCount)) return; + } catch (e) { + if (isLogsPageActive()) { + showAppToast(e.message || getUIText("dashcam_load_failed", "Failed to load dashcam list"), { tone: "error" }); + } + } finally { + dashcamState.loadingSegments.delete(route); + updateDashcamSegmentLoaderUi(route, false); + requestAnimationFrame(() => maybeLoadVisibleDashcamSegments()); + } +} + async function loadDashcamRoutes({ silent = false, append = false } = {}) { if (append && (!dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore)) return; const seq = ++dashcamState.loadSeq; @@ -714,8 +786,9 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { try { 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}`); + const routePageSize = dashcamRoutePageSize(); + const limit = append ? routePageSize : Math.max(routePageSize, currentCount || 0); + const json = await getJson(`/api/dashcam/routes?offset=${offset}&limit=${limit}&segment_limit=${DASHCAM_SEGMENT_PAGE_SIZE}`); if (seq !== dashcamState.loadSeq) { if (append) { dashcamState.loadingMore = false; @@ -730,7 +803,9 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { return; } const incoming = Array.isArray(json.routes) ? json.routes : []; - const routes = append ? dashcamState.routes.concat(incoming) : incoming; + const existingRoutes = new Map((dashcamState.routes || []).map((entry) => [entry.route, entry])); + const nextIncoming = append ? incoming : incoming.map((entry) => mergeDashcamRoutePage(entry, existingRoutes.get(entry.route))); + const routes = append ? dashcamState.routes.concat(nextIncoming) : nextIncoming; const nextSignature = dashcamRoutesSignature(routes); if (silent && nextSignature === dashcamState.signature) { dashcamState.loading = false; @@ -742,12 +817,15 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { return; } const validRoutes = new Set(routes.map((entry) => entry.route)); - const validSegments = new Set(routes.flatMap((entry) => entry.segmentFolders || [])); + const validSegments = new Set(routes.flatMap((entry) => dashcamSegmentsForRoute(entry))); 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.segmentScrollTops = Object.fromEntries( + Object.entries(dashcamState.segmentScrollTops || {}).filter(([route]) => validRoutes.has(route)) + ); dashcamState.routes = routes; dashcamState.signature = nextSignature; dashcamState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : routes.length; @@ -756,9 +834,12 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { dashcamState.loading = false; dashcamState.loadingMore = false; setDashcamLoadingMoreUi(false); - renderDashcamRoutes({ animate: !silent }); + renderDashcamRoutes({ animate: append || !silent }); if (!silent && logsScrollTops.dashcam === 0) restoreLogsScrollTop("dashcam", { reset: true }); - requestAnimationFrame(() => maybeLoadMoreDashcamRoutes()); + requestAnimationFrame(() => { + maybeLoadMoreDashcamRoutes(); + maybeLoadVisibleDashcamSegments(); + }); } catch (e) { if (seq !== dashcamState.loadSeq) { if (append) { @@ -782,101 +863,23 @@ function startDashcamAutoRefresh() { dashcamState.refreshTimer = window.setInterval(() => { if (CURRENT_PAGE !== "logs" || dashcamState.scrollBusy) return; if (logsActiveTab === "screen") loadScreenrecordVideos({ silent: true }).catch(() => {}); - else if (!dashcamState.loading && !dashcamState.loadingMore) loadDashcamRoutes({ silent: true }).catch(() => {}); + else if (!dashcamState.loading && !dashcamState.loadingMore && !dashcamState.loadingSegments?.size) { + loadDashcamRoutes({ silent: true }).catch(() => {}); + } }, 10000); } -function markDashcamScrollBusy() { +function markDashcamScrollBusy(options = {}) { + const renderOnIdle = options.renderOnIdle !== false; dashcamState.scrollBusy = true; if (dashcamState.scrollTimer) window.clearTimeout(dashcamState.scrollTimer); dashcamState.scrollTimer = window.setTimeout(() => { dashcamState.scrollBusy = false; - if (isLogsPageActive() && logsActiveTab === "dashcam") scheduleDashcamWindowRender(); - }, 380); -} - -function openLogsVideoPlayer(title, src, options = {}) { - const overlay = document.createElement("div"); - const kind = String(options.kind || "video").replace(/[^a-z0-9_-]/gi, ""); - overlay.className = `dashcam-player-overlay dashcam-player-overlay--${kind}`; - overlay.innerHTML = ``; - 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 = () => { - 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); - document.body.appendChild(overlay); - requestAnimationFrame(() => { - overlay.classList.add("is-open"); - 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", () => { - 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; + if (renderOnIdle && isLogsPageActive() && logsActiveTab === "dashcam") { + const host = getLogsScroller("dashcam"); + if (dashcamWindowNeedsRender(host)) scheduleDashcamWindowRender(); } - }); + }, 380); } function openDashcamPlayer(route, segment) { @@ -887,11 +890,6 @@ function openDashcamPlayer(route, segment) { ); } -function openScreenrecordPlayer(id, name) { - if (!id) return; - openLogsVideoPlayer(name || getUIText("logs_screenrecord", "Screen Record"), screenrecordApiPath("video", id), { kind: "screenrecord" }); -} - function dashcamUploadStats(items) { const list = Array.isArray(items) ? items : []; return list.reduce((stats, item) => { @@ -1265,323 +1263,3 @@ async function showDashcamSegmentMenu(route, segment) { window.open(dashcamApiPath(`download/${encodeURIComponent(segment)}`, kind), "_blank", "noopener"); } } - -function screenrecordVideoRowHtml(video, index = 0) { - const id = escapeHtml(video.id || ""); - const name = escapeHtml(video.name || "-"); - const date = escapeHtml(formatRelativeEpoch(video.modifiedEpoch) || localizeRelativeLabel(video.modifiedLabel || video.relativeModifiedLabel) || "-"); - const size = escapeHtml(formatLogBytes(video.size)); - const ext = escapeHtml((video.ext || "video").toUpperCase()); - return `
- -
-
${name}
-
- ${date} - ${size} - ${ext} -
-
- -
`; -} - -function renderScreenrecordVideos(options = {}) { - const host = document.getElementById("screenrecordVideos"); - if (!host) return; - if (!isLogsPageActive()) return; - const preserve = options.preserve === true; - const videos = screenrecordState.videos || []; - if (screenrecordState.loading && !videos.length) { - setScreenrecordStatus(""); - host.innerHTML = logsLoadingSkeletonHtml("screen"); - host.dataset.signature = ""; - host.dataset.renderCount = "0"; - return; - } - if (!videos.length) { - host.innerHTML = logsEmptyStateHtml("screen"); - host.dataset.signature = ""; - host.dataset.renderCount = "0"; - setScreenrecordStatus(""); - return; - } - setScreenrecordStatus(""); - 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; - } - 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, append = false } = {}) { - if (append && (!screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore)) return; - const seq = ++screenrecordState.loadSeq; - if (append) { - screenrecordState.loadingMore = true; - setScreenrecordLoadingMoreUi(true); - } else if (!silent) { - screenrecordState.loading = true; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - renderScreenrecordVideos(); - } - try { - 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 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; - 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" }); - } - } -} - -function activateLogsTab(tab, options = {}) { - const nextTab = tab === "screen" ? "screen" : "dashcam"; - const shouldLoad = options.load !== false; - if (nextTab !== logsActiveTab) saveLogsScrollTop(logsActiveTab); - logsActiveTab = nextTab; - const dashTab = document.getElementById("logsTabDashcam"); - const screenTab = document.getElementById("logsTabScreen"); - const dashPanel = document.getElementById("logsDashcamPanel"); - const screenPanel = document.getElementById("logsScreenPanel"); - - dashTab?.classList.toggle("is-active", logsActiveTab === "dashcam"); - screenTab?.classList.toggle("is-active", logsActiveTab === "screen"); - dashTab?.setAttribute("aria-selected", logsActiveTab === "dashcam" ? "true" : "false"); - screenTab?.setAttribute("aria-selected", logsActiveTab === "screen" ? "true" : "false"); - if (dashPanel) dashPanel.hidden = logsActiveTab !== "dashcam"; - if (screenPanel) screenPanel.hidden = logsActiveTab !== "screen"; - - if (shouldLoad) { - if (logsActiveTab === "screen" && !screenrecordState.initialized) { - screenrecordState.initialized = true; - loadScreenrecordVideos().catch(() => {}); - } else if (logsActiveTab === "screen") { - renderScreenrecordVideos(); - loadScreenrecordVideos({ silent: true }).catch(() => {}); - } else if (dashcamState.initialized) { - loadDashcamRoutes({ silent: true }).catch(() => {}); - } - } - if (options.restoreScroll !== false) restoreLogsScrollTop(logsActiveTab); -} - -function handleLogsPageChange(event) { - const page = event?.detail?.page || ""; - if (page === "logs") return; - saveLogsScrollTop(logsActiveTab); - cancelDashcamRouteRender(); - dashcamState.loadSeq += 1; - screenrecordState.loadSeq += 1; - dashcamState.loading = false; - dashcamState.loadingMore = false; - setDashcamLoadingMoreUi(false); - screenrecordState.loading = false; - dashcamState.scrollBusy = false; - if (dashcamState.scrollTimer) { - window.clearTimeout(dashcamState.scrollTimer); - dashcamState.scrollTimer = null; - } - if (dashcamState.layoutTimer) { - window.clearTimeout(dashcamState.layoutTimer); - dashcamState.layoutTimer = null; - } - disconnectLogsLazyImages(); -} - -function bindLogsPage() { - const dashTab = document.getElementById("logsTabDashcam"); - const screenTab = document.getElementById("logsTabScreen"); - const routesHost = document.getElementById("dashcamRoutes"); - const screenHost = document.getElementById("screenrecordVideos"); - - 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"); - if (screenHost) screenHost.dataset.signature = ""; - - if (isLogsPageActive()) { - renderDashcamRoutes({ animate: false }); - if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ animate: false }); - } - }); - window.addEventListener("resize", () => { - if (CURRENT_PAGE !== "logs") return; - if (dashcamState.layoutTimer) window.clearTimeout(dashcamState.layoutTimer); - dashcamState.layoutTimer = window.setTimeout(() => { - dashcamState.layoutTimer = null; - if (!isLogsPageActive()) return; - const nextLandscape = isCompactLandscapeMode(); - 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 }); - } - - if (dashTab && dashTab.dataset.bound !== "1") { - dashTab.dataset.bound = "1"; - dashTab.addEventListener("click", () => activateLogsTab("dashcam")); - } - - if (screenTab && screenTab.dataset.bound !== "1") { - screenTab.dataset.bound = "1"; - screenTab.addEventListener("click", () => activateLogsTab("screen")); - } - - if (routesHost && routesHost.dataset.bound !== "1") { - routesHost.dataset.bound = "1"; - 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]"); - if (!actionEl) return; - const action = actionEl.dataset.action; - const route = actionEl.dataset.route || ""; - const segment = actionEl.dataset.segment || ""; - 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); - } else if (action === "segment-menu") { - ev.stopPropagation(); - showDashcamSegmentMenu(route, segment).catch(() => {}); - } else if (action === "select-route") { - const entry = dashcamState.routes.find((item) => item.route === route); - if (!entry) return; - const shouldClear = actionEl.dataset.selected === "1"; - for (const item of entry.segmentFolders || []) { - if (shouldClear) dashcamState.selected.delete(item); - else dashcamState.selected.add(item); - } - if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); - } else if (action === "upload-selected") { - const entry = dashcamState.routes.find((item) => item.route === route); - const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); - uploadDashcamSegments(targets).catch(() => {}); - } - }); - routesHost.addEventListener("change", (ev) => { - const input = ev.target; - if (!input?.matches?.('input[data-action="select-segment"]')) return; - const segment = input.dataset.segment || ""; - if (input.checked) dashcamState.selected.add(segment); - else dashcamState.selected.delete(segment); - const route = input.closest("[data-route-card]")?.dataset.routeCard || ""; - if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); - }); - } - - if (screenHost && screenHost.dataset.bound !== "1") { - screenHost.dataset.bound = "1"; - 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]"); - if (!actionEl) return; - if (actionEl.dataset.action === "download-screenrecord") { - const id = actionEl.dataset.id || ""; - if (id) window.open(screenrecordApiPath("download", id), "_blank", "noopener"); - } else if (actionEl.dataset.action === "play-screenrecord") { - openScreenrecordPlayer(actionEl.dataset.id || "", actionEl.dataset.name || ""); - } - }); - } -} - -function initLogsPage() { - bindLogsPage(); - activateLogsTab(logsActiveTab, { load: false }); - startDashcamAutoRefresh(); - resumeDashcamUploadJobIfNeeded().catch(() => {}); - if (logsActiveTab === "screen") { - if (!screenrecordState.initialized) { - screenrecordState.initialized = true; - loadScreenrecordVideos().catch(() => {}); - } else { - renderScreenrecordVideos({ preserve: true }); - loadScreenrecordVideos({ silent: true }).catch(() => {}); - } - } else if (!dashcamState.initialized) { - dashcamState.initialized = true; - loadDashcamRoutes().catch(() => {}); - } else { - renderDashcamRoutes({ animate: false, preserve: true }); - loadDashcamRoutes({ silent: true }).catch(() => {}); - } -} diff --git a/selfdrive/carrot/web/js/pages/logs/screenrecord.js b/selfdrive/carrot/web/js/pages/logs/screenrecord.js new file mode 100644 index 0000000000..eee6f40bf4 --- /dev/null +++ b/selfdrive/carrot/web/js/pages/logs/screenrecord.js @@ -0,0 +1,247 @@ +"use strict"; + +// Logs page — Screen Recording tab. +// Virtual list of saved screen recordings with lazy thumbnails, paged loading, +// and a download/playback action row. + +const SCREENRECORD_PAGE_SIZE = 40; +const SCREENRECORD_LOAD_AHEAD_PX = 720; +const SCREENRECORD_WINDOW_OVERSCAN = 8; + +const screenrecordState = { + initialized: false, + loading: false, + loadingMore: false, + loadSeq: 0, + videos: [], + rowHeight: 80, + windowStart: 0, + windowEnd: 0, + total: 0, + nextOffset: 0, + hasMore: false, + signature: "", + renderFrame: 0, +}; + +function setScreenrecordStatus(message, tone = "") { + const status = document.getElementById("screenrecordStatus"); + if (!status) return; + status.textContent = message || ""; + status.hidden = !message; + status.classList.toggle("is-error", tone === "error"); +} + +function screenrecordApiPath(kind, fileId) { + return `/api/screenrecord/${kind}/${encodeURIComponent(fileId)}`; +} + +function screenrecordVideosSignature(videos) { + return (videos || []).map((video) => [ + video.id || "", + video.name || "", + video.modifiedLabel || "", + video.size || 0, + ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); +} + +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 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 openScreenrecordPlayer(id, name) { + if (!id) return; + openLogsVideoPlayer(name || getUIText("logs_screenrecord", "Screen Record"), screenrecordApiPath("video", id), { kind: "screenrecord" }); +} + +function screenrecordVideoRowHtml(video, index = 0) { + const id = escapeHtml(video.id || ""); + const name = escapeHtml(video.name || "-"); + const date = escapeHtml(formatRelativeEpoch(video.modifiedEpoch) || localizeRelativeLabel(video.modifiedLabel || video.relativeModifiedLabel) || "-"); + const size = escapeHtml(formatLogBytes(video.size)); + const ext = escapeHtml((video.ext || "video").toUpperCase()); + return `
+ +
+
${name}
+
+ ${date} + ${size} + ${ext} +
+
+ +
`; +} + +function renderScreenrecordVideos(options = {}) { + const host = document.getElementById("screenrecordVideos"); + if (!host) return; + if (!isLogsPageActive()) return; + const preserve = options.preserve === true; + const videos = screenrecordState.videos || []; + if (screenrecordState.loading && !videos.length) { + setScreenrecordStatus(""); + host.innerHTML = logsLoadingSkeletonHtml("screen"); + host.dataset.signature = ""; + host.dataset.renderCount = "0"; + return; + } + if (!videos.length) { + host.innerHTML = logsEmptyStateHtml("screen"); + host.dataset.signature = ""; + host.dataset.renderCount = "0"; + setScreenrecordStatus(""); + return; + } + setScreenrecordStatus(""); + 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; + } + 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, append = false } = {}) { + if (append && (!screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore)) return; + const seq = ++screenrecordState.loadSeq; + if (append) { + screenrecordState.loadingMore = true; + setScreenrecordLoadingMoreUi(true); + } else if (!silent) { + screenrecordState.loading = true; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + renderScreenrecordVideos(); + } + try { + 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 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; + 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" }); + } + } +} diff --git a/selfdrive/carrot/web/js/pages/logs/shared.js b/selfdrive/carrot/web/js/pages/logs/shared.js new file mode 100644 index 0000000000..77bdf32a96 --- /dev/null +++ b/selfdrive/carrot/web/js/pages/logs/shared.js @@ -0,0 +1,436 @@ +"use strict"; + +// Logs page — shared infra used by both the Dashcam and Screen Recording tabs. +// Owns: tab state, scroll persistence, lazy-image observer, generic helpers, +// the video player, and page bind/init/teardown. + +let logsActiveTab = "dashcam"; +const logsScrollTops = { dashcam: 0, screen: 0 }; +let logsLazyImageObserver = null; + +function isLogsPageActive() { + return CURRENT_PAGE === "logs"; +} + +function getLogsScroller(tab = logsActiveTab) { + return document.getElementById(tab === "screen" ? "screenrecordVideos" : "dashcamRoutes"); +} + +function saveLogsScrollTop(tab = logsActiveTab) { + const scroller = getLogsScroller(tab); + if (!scroller) return; + logsScrollTops[tab === "screen" ? "screen" : "dashcam"] = scroller.scrollTop || 0; +} + +function restoreLogsScrollTop(tab = logsActiveTab, options = {}) { + const scroller = getLogsScroller(tab); + if (!scroller) return; + const key = tab === "screen" ? "screen" : "dashcam"; + const nextTop = options.reset === true ? 0 : (logsScrollTops[key] || 0); + if (CURRENT_PAGE === "logs") { + window.scrollTo(0, 0); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + } + requestAnimationFrame(() => { + if (!isLogsPageActive()) return; + scroller.scrollTop = nextTop; + requestAnimationFrame(() => { + if (!isLogsPageActive()) return; + scroller.scrollTop = nextTop; + if (key === "dashcam" && typeof scheduleDashcamWindowRender === "function") scheduleDashcamWindowRender(); + if (key === "screen" && typeof scheduleScreenrecordWindowRender === "function") scheduleScreenrecordWindowRender(); + }); + }); +} + +function formatRelativeEpoch(epochSeconds) { + const epoch = Number(epochSeconds || 0); + if (!Number.isFinite(epoch) || epoch <= 0) return ""; + const delta = Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(epoch)); + if (delta < 60) return getUIText("just_now", "just now"); + if (delta < 3600) return getUIText("minutes_ago", "{count} min ago", { count: Math.floor(delta / 60) }); + if (delta < 86400) return getUIText("hours_ago", "{count} hr ago", { count: Math.floor(delta / 3600) }); + return getUIText("days_ago", "{count} days ago", { count: Math.floor(delta / 86400) }); +} + +function localizeRelativeLabel(label) { + const text = String(label || "").trim(); + if (!text) return ""; + if (/^(방금\s*전|just\s*now)$/i.test(text)) return getUIText("just_now", "just now"); + const minuteMatch = text.match(/^(\d+)\s*(?:분\s*전|min(?:ute)?s?\s*ago)$/i); + if (minuteMatch) return getUIText("minutes_ago", "{count} min ago", { count: minuteMatch[1] }); + const hourMatch = text.match(/^(\d+)\s*(?:시간\s*전|hr?s?\s*ago|hour?s?\s*ago)$/i); + if (hourMatch) return getUIText("hours_ago", "{count} hr ago", { count: hourMatch[1] }); + const dayMatch = text.match(/^(\d+)\s*(?:일\s*전|day?s?\s*ago)$/i); + if (dayMatch) return getUIText("days_ago", "{count} days ago", { count: dayMatch[1] }); + return text; +} + +function formatLogBytes(bytes) { + const n = Number(bytes) || 0; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function loadLogsLazyImage(img) { + if (!img) return; + const src = img.dataset?.src || ""; + if (!src) return; + img.src = src; + img.removeAttribute("data-src"); +} + +function hydrateLogsLazyImages(root) { + if (!isLogsPageActive()) return; + const scope = root || document; + const images = Array.from(scope.querySelectorAll?.("img[data-src]") || []); + if (!images.length) return; + + if (!("IntersectionObserver" in window)) { + images.forEach(loadLogsLazyImage); + return; + } + + if (!logsLazyImageObserver) { + logsLazyImageObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + logsLazyImageObserver.unobserve(entry.target); + loadLogsLazyImage(entry.target); + }); + }, { root: null, rootMargin: "720px 0px", threshold: 0.01 }); + } + + images.forEach((img) => logsLazyImageObserver.observe(img)); +} + +function disconnectLogsLazyImages() { + if (!logsLazyImageObserver) return; + logsLazyImageObserver.disconnect(); + 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"; + return ``; +} + +function logsEmptyStateHtml(type = "dashcam") { + const isScreen = type === "screen"; + const title = isScreen + ? getUIText("screenrecord_empty_title", "No screen recordings") + : getUIText("dashcam_empty_title", "No dashcam records"); + + return ` +
+
${escapeHtml(title)}
+
`; +} + +function openLogsVideoPlayer(title, src, options = {}) { + const overlay = document.createElement("div"); + const kind = String(options.kind || "video").replace(/[^a-z0-9_-]/gi, ""); + overlay.className = `dashcam-player-overlay dashcam-player-overlay--${kind}`; + overlay.innerHTML = ``; + 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 = () => { + 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); + document.body.appendChild(overlay); + requestAnimationFrame(() => { + overlay.classList.add("is-open"); + 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", () => { + 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; + } + }); +} + +function activateLogsTab(tab, options = {}) { + const nextTab = tab === "screen" ? "screen" : "dashcam"; + const shouldLoad = options.load !== false; + if (nextTab !== logsActiveTab) saveLogsScrollTop(logsActiveTab); + logsActiveTab = nextTab; + const dashTab = document.getElementById("logsTabDashcam"); + const screenTab = document.getElementById("logsTabScreen"); + const dashPanel = document.getElementById("logsDashcamPanel"); + const screenPanel = document.getElementById("logsScreenPanel"); + + dashTab?.classList.toggle("is-active", logsActiveTab === "dashcam"); + screenTab?.classList.toggle("is-active", logsActiveTab === "screen"); + dashTab?.setAttribute("aria-selected", logsActiveTab === "dashcam" ? "true" : "false"); + screenTab?.setAttribute("aria-selected", logsActiveTab === "screen" ? "true" : "false"); + if (dashPanel) dashPanel.hidden = logsActiveTab !== "dashcam"; + if (screenPanel) screenPanel.hidden = logsActiveTab !== "screen"; + + if (shouldLoad) { + if (logsActiveTab === "screen" && !screenrecordState.initialized) { + screenrecordState.initialized = true; + loadScreenrecordVideos().catch(() => {}); + } else if (logsActiveTab === "screen") { + renderScreenrecordVideos(); + loadScreenrecordVideos({ silent: true }).catch(() => {}); + } else if (dashcamState.initialized) { + loadDashcamRoutes({ silent: true }).catch(() => {}); + } + } + if (options.restoreScroll !== false) restoreLogsScrollTop(logsActiveTab); +} + +function handleLogsPageChange(event) { + const page = event?.detail?.page || ""; + if (page === "logs") return; + saveLogsScrollTop(logsActiveTab); + cancelDashcamRouteRender(); + dashcamState.loadSeq += 1; + screenrecordState.loadSeq += 1; + dashcamState.loading = false; + dashcamState.loadingMore = false; + dashcamState.loadingSegments?.clear?.(); + setDashcamLoadingMoreUi(false); + screenrecordState.loading = false; + dashcamState.scrollBusy = false; + if (dashcamState.scrollTimer) { + window.clearTimeout(dashcamState.scrollTimer); + dashcamState.scrollTimer = null; + } + if (dashcamState.layoutTimer) { + window.clearTimeout(dashcamState.layoutTimer); + dashcamState.layoutTimer = null; + } + disconnectLogsLazyImages(); +} + +function bindLogsPage() { + const dashTab = document.getElementById("logsTabDashcam"); + const screenTab = document.getElementById("logsTabScreen"); + const routesHost = document.getElementById("dashcamRoutes"); + const screenHost = document.getElementById("screenrecordVideos"); + + 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"); + if (screenHost) screenHost.dataset.signature = ""; + + if (isLogsPageActive()) { + renderDashcamRoutes({ animate: false }); + if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ animate: false }); + } + }); + window.addEventListener("resize", () => { + if (CURRENT_PAGE !== "logs") return; + if (dashcamState.layoutTimer) window.clearTimeout(dashcamState.layoutTimer); + dashcamState.layoutTimer = window.setTimeout(() => { + dashcamState.layoutTimer = null; + if (!isLogsPageActive()) return; + const nextLandscape = isCompactLandscapeMode(); + 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 }); + } + + if (dashTab && dashTab.dataset.bound !== "1") { + dashTab.dataset.bound = "1"; + dashTab.addEventListener("click", () => activateLogsTab("dashcam")); + } + + if (screenTab && screenTab.dataset.bound !== "1") { + screenTab.dataset.bound = "1"; + screenTab.addEventListener("click", () => activateLogsTab("screen")); + } + + if (routesHost && routesHost.dataset.bound !== "1") { + routesHost.dataset.bound = "1"; + routesHost.addEventListener("scroll", () => { + markDashcamScrollBusy(); + saveLogsScrollTop("dashcam"); + if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender(); + maybeLoadMoreDashcamRoutes(routesHost); + }, { passive: true }); + routesHost.addEventListener("scroll", (ev) => { + const segmentList = ev.target?.closest?.(".dashcam-segment-list"); + if (!segmentList || segmentList === routesHost) return; + scheduleSegmentListScrollPersist(segmentList); + }, { passive: true, capture: true }); + routesHost.addEventListener("click", (ev) => { + const actionEl = ev.target?.closest?.("[data-action]"); + if (!actionEl) return; + const action = actionEl.dataset.action; + const route = actionEl.dataset.route || ""; + const segment = actionEl.dataset.segment || ""; + 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); + } else if (action === "segment-menu") { + ev.stopPropagation(); + showDashcamSegmentMenu(route, segment).catch(() => {}); + } else if (action === "select-route") { + const entry = dashcamState.routes.find((item) => item.route === route); + if (!entry) return; + const shouldClear = actionEl.dataset.selected === "1"; + for (const item of dashcamSegmentsForRoute(entry)) { + if (shouldClear) dashcamState.selected.delete(item); + else dashcamState.selected.add(item); + } + if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); + } else if (action === "upload-selected") { + const entry = dashcamState.routes.find((item) => item.route === route); + const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); + uploadDashcamSegments(targets).catch(() => {}); + } + }); + routesHost.addEventListener("change", (ev) => { + const input = ev.target; + if (!input?.matches?.('input[data-action="select-segment"]')) return; + const segment = input.dataset.segment || ""; + if (input.checked) dashcamState.selected.add(segment); + else dashcamState.selected.delete(segment); + const route = input.closest("[data-route-card]")?.dataset.routeCard || ""; + if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); + }); + } + + if (screenHost && screenHost.dataset.bound !== "1") { + screenHost.dataset.bound = "1"; + 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]"); + if (!actionEl) return; + if (actionEl.dataset.action === "download-screenrecord") { + const id = actionEl.dataset.id || ""; + if (id) window.open(screenrecordApiPath("download", id), "_blank", "noopener"); + } else if (actionEl.dataset.action === "play-screenrecord") { + openScreenrecordPlayer(actionEl.dataset.id || "", actionEl.dataset.name || ""); + } + }); + } +} + +function initLogsPage() { + bindLogsPage(); + activateLogsTab(logsActiveTab, { load: false }); + startDashcamAutoRefresh(); + resumeDashcamUploadJobIfNeeded().catch(() => {}); + if (logsActiveTab === "screen") { + if (!screenrecordState.initialized) { + screenrecordState.initialized = true; + loadScreenrecordVideos().catch(() => {}); + } else { + renderScreenrecordVideos({ preserve: true }); + loadScreenrecordVideos({ silent: true }).catch(() => {}); + } + } else if (!dashcamState.initialized) { + dashcamState.initialized = true; + loadDashcamRoutes().catch(() => {}); + } else { + renderDashcamRoutes({ animate: false, preserve: true }); + loadDashcamRoutes({ silent: true }).catch(() => {}); + } +} diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index d4e994fb88..54ef9e4edd 100644 --- a/selfdrive/carrot/web/js/pages/setting.js +++ b/selfdrive/carrot/web/js/pages/setting.js @@ -1135,11 +1135,33 @@ function appendSettingProfileHeader(profile, container) { container.appendChild(panel); } +let settingFabMenuCloseTimer = null; +const SETTING_FAB_MENU_CLOSE_MS = 240; + function syncSettingFabMenuState() { + if (settingFabActions) { + if (settingFabMenuCloseTimer) { + window.clearTimeout(settingFabMenuCloseTimer); + settingFabMenuCloseTimer = null; + } + if (settingFabMenuOpen && settingFabActions.hidden) { + // Make the element renderable in its closed state first, then let the + // next style recalc apply the open class so the transition plays. + settingFabActions.hidden = false; + void settingFabActions.offsetWidth; // commit closed-state baseline + } + } if (settingFabMenu) settingFabMenu.classList.toggle("is-open", settingFabMenuOpen); if (settingFabActions) { - settingFabActions.hidden = !settingFabMenuOpen; settingFabActions.setAttribute("aria-hidden", settingFabMenuOpen ? "false" : "true"); + if (!settingFabMenuOpen && !settingFabActions.hidden) { + // Defer [hidden] until the close transition finishes — otherwise + // `display: none` snaps it away with no animation. + settingFabMenuCloseTimer = window.setTimeout(() => { + settingFabMenuCloseTimer = null; + if (!settingFabMenuOpen) settingFabActions.hidden = true; + }, SETTING_FAB_MENU_CLOSE_MS); + } } if (btnSettingSearch) { btnSettingSearch.classList.toggle("active", settingFabMenuOpen || Boolean(settingSearchPanel && !settingSearchPanel.hidden)); diff --git a/selfdrive/carrot/web/js/pages/setting_device.js b/selfdrive/carrot/web/js/pages/setting_device.js index 4085a68317..363ae3786f 100644 --- a/selfdrive/carrot/web/js/pages/setting_device.js +++ b/selfdrive/carrot/web/js/pages/setting_device.js @@ -229,6 +229,7 @@ async function renderDeviceTab(options = {}) { const animateGroups = options.animateGroups !== false; const animateItems = options.animateItems !== false; renderDeviceGroups({ animateGroups }); + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); if (!deviceTabLoaded) { deviceTabLoaded = true; loadDeviceParams("Device", true).then(() => { @@ -244,6 +245,7 @@ async function selectDeviceGroup(groupId) { CURRENT_DEVICE_GROUP = groupId || CURRENT_DEVICE_GROUP; renderDeviceGroups(); syncSettingTabState("device"); + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); await renderDeviceItems(CURRENT_DEVICE_GROUP, true, { animateItems: true }); } @@ -282,6 +284,7 @@ async function renderDeviceItems(groupId, showItemsScreen = true, options = {}) } bindDeviceTabEvents(itemsContainer); syncDeviceGroupActiveState(groupId); + syncDeviceGroupChrome(groupId); syncDeviceNetworkRefresh(); } @@ -373,12 +376,30 @@ function syncDeviceGroupActiveState(groupId = CURRENT_DEVICE_GROUP) { }); } +function syncDeviceGroupChrome(groupId = CURRENT_DEVICE_GROUP) { + const label = getDeviceGroupLabel(groupId); + const meta = document.getElementById("groupMeta"); + const itemCount = document.getElementById("deviceItems")?.children.length || 0; + if (meta && groupId) meta.textContent = `${groupId} / ${itemCount}`; + if (typeof settingTitle !== "undefined" && settingTitle) { + settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + label; + } + if (typeof itemsTitle !== "undefined" && itemsTitle) { + itemsTitle.textContent = label; + } +} + async function switchSettingTab(tab) { const nextTab = tab === "device" ? "device" : "carrot"; if (CURRENT_SETTING_TAB === nextTab) { syncSettingTabState(nextTab); - if (nextTab !== "device") stopDeviceNetworkRefresh(); - else syncDeviceNetworkRefresh(); + if (nextTab !== "device") { + stopDeviceNetworkRefresh(); + if (typeof syncSettingGroupChrome === "function") syncSettingGroupChrome(CURRENT_GROUP); + } else { + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); + syncDeviceNetworkRefresh(); + } return; } @@ -391,12 +412,26 @@ async function switchSettingTab(tab) { if (!(typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode()) && typeof showSettingScreen === "function") { showSettingScreen("groups", false); } + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); return; } + if (typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode() && typeof activateSettingGroup === "function") { + const targetGroup = CURRENT_GROUP || (typeof getLandscapeDefaultSettingGroup === "function" ? getLandscapeDefaultSettingGroup() : null); + if (targetGroup) { + await activateSettingGroup(targetGroup, false, { + animateGroups: false, + animateItems: false, + scrollMode: "restore", + }); + return; + } + } + if (typeof showSettingScreen === "function") { showSettingScreen("groups", false); } + if (typeof syncSettingGroupChrome === "function") syncSettingGroupChrome(CURRENT_GROUP); } if (settingTabDevice) { diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 80e5cf319c..1eec421e0f 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -140,7 +140,6 @@ function requestToolsMetaTickerSync() { function pulseToolsLogPanel() { const page = document.getElementById("pageTools"); if (!page || page.classList.contains("tools-log-expanded")) { - scrollToolsLogToBottom(); return; } page.classList.add("tools-log-attention"); @@ -148,10 +147,7 @@ function pulseToolsLogPanel() { toolsLogAttentionTimer = window.setTimeout(() => { page.classList.remove("tools-log-attention"); toolsLogAttentionTimer = null; - scrollToolsLogToBottom(); - scrollToolsLogToBottom(280); }, 3200); - scrollToolsLogToBottom(280); } function setToolsLogExpanded(expanded, options = {}) { @@ -170,9 +166,12 @@ function setToolsLogExpanded(expanded, options = {}) { window.clearTimeout(toolsLogAttentionTimer); toolsLogAttentionTimer = null; } + window.CarrotToolsNotifications?.focusLatest?.({ expand: true, smoothOnce: true, stableDetail: true }); + } + if (!window.CarrotToolsNotifications?.render) { + scrollToolsLogToBottom(); + scrollToolsLogToBottom(280); } - scrollToolsLogToBottom(); - scrollToolsLogToBottom(280); } function renderToolsOut() { @@ -189,13 +188,13 @@ function renderToolsOut() { }, { onClear: clearToolsNotificationHistory, onClose: () => setToolsLogExpanded(false), + autoFocusLatest: true, }); } else { out.textContent = currentText || historyText || " "; + requestAnimationFrame(() => scrollToolsLogToBottom()); + scrollToolsLogToBottom(280); } - - requestAnimationFrame(() => scrollToolsLogToBottom()); - scrollToolsLogToBottom(280); } async function clearToolsNotificationHistory() { @@ -754,10 +753,22 @@ async function syncDeviceLanguageOnce() { const values = await bulkGet(["LanguageSetting"]); const currentLang = String(values["LanguageSetting"] || "").trim(); + // Map browser language → web language code (ko/en/zh only) const browserLang = (navigator.language || navigator.userLanguage || "en").toLowerCase(); - let targetParam = "main_en"; - if (browserLang.startsWith("ko")) targetParam = "main_ko"; - else if (browserLang.startsWith("zh")) targetParam = browserLang.includes("tw") || browserLang.includes("hk") ? "main_zh-CHT" : "main_zh-CHS"; + const webLang = normalizeLangCode(browserLang); // returns "ko" | "en" | "zh" | "" + + // Map web language → device language code + const WEB_TO_DEVICE = { ko: "main_ko", en: "main_en", zh: "main_zh-CHS" }; + if (browserLang.startsWith("zh") && (browserLang.includes("tw") || browserLang.includes("hk"))) { + WEB_TO_DEVICE.zh = "main_zh-CHT"; + } + + // Only sync if browser language has BOTH a web pack AND a device translation + const deviceCodes = (window.CarrotDeviceLanguageOptions || []).map((o) => o.code); + let targetParam = "main_en"; // default fallback + if (webLang && WEB_TO_DEVICE[webLang] && deviceCodes.includes(WEB_TO_DEVICE[webLang])) { + targetParam = WEB_TO_DEVICE[webLang]; + } if (currentLang !== targetParam) { await setParam("LanguageSetting", targetParam); diff --git a/selfdrive/carrot/web/js/pages/tools_notifications.js b/selfdrive/carrot/web/js/pages/tools_notifications.js index 1ff1b5d1e0..8e23a33f1c 100644 --- a/selfdrive/carrot/web/js/pages/tools_notifications.js +++ b/selfdrive/carrot/web/js/pages/tools_notifications.js @@ -27,6 +27,7 @@ let collapseHost = null; const detailScrollState = new Map(); let lastRenderSignature = ""; + let lastAutoFocusedEntryId = ""; function uiText(key, fallback, vars = null) { return typeof getUIText === "function" ? getUIText(key, fallback, vars) : fallback; @@ -551,6 +552,17 @@ wrap.style.maxHeight = `${wrap.scrollHeight}px`; } + function stabilizeExpandedDetail(card) { + const wrap = card?.querySelector?.(".tools-console-log__detailWrap"); + if (!wrap) return; + wrap.style.transition = "none"; + wrap.style.animation = "none"; + wrap.style.opacity = "1"; + wrap.style.transform = "translateY(0)"; + setMeasuredDetailHeight(card); + wrap.getBoundingClientRect(); + } + function animateDetailCollapse(card) { const wrap = card?.querySelector?.(".tools-console-log__detailWrap"); if (!wrap) return; @@ -598,6 +610,62 @@ }); } + function isRunningEntry(entry) { + return String(entry?.status || "") === "running"; + } + + function canAutoFocusEntry(entry) { + return Boolean(entry) && !isRunningEntry(entry); + } + + function currentCards(out) { + const scroller = getLogScroller(out); + return scroller ? Array.from(scroller.querySelectorAll("[data-notification-id]")) : []; + } + + function canPatchExistingCards(out, model) { + const cards = currentCards(out); + return cards.length === model.entries.length && cards.every((card, index) => ( + card.dataset.notificationId === model.entries[index]?.id + )); + } + + function setNodeText(node, value) { + if (node && node.textContent !== value) node.textContent = value; + } + + function patchCard(card, entry, context) { + const expanded = activeNotificationId === entry.id; + card.classList.toggle("tools-console-log__current", entry.source === "current"); + card.classList.toggle("tools-console-log__history", entry.source === "history"); + card.classList.toggle("is-expanded", expanded); + card.dataset.toolsNotificationMode = context.mode; + card.setAttribute("aria-expanded", expanded ? "true" : "false"); + + setNodeText(card.querySelector(".tools-console-log__cardTitle"), entry.title); + const head = card.querySelector(".tools-console-log__cardHead"); + let time = card.querySelector(".tools-console-log__cardTime"); + if (entry.timeLabel) { + if (!time && head) { + time = document.createElement("span"); + time.className = "tools-console-log__cardTime"; + head.appendChild(time); + } + setNodeText(time, entry.timeLabel); + } else if (time) { + time.remove(); + } + setNodeText(card.querySelector(".tools-console-log__cardBody"), entry.summary); + setNodeText(card.querySelector(".tools-console-log__detail"), entry.text); + } + + function patchExistingCards(out, model, context) { + currentCards(out).forEach((card, index) => patchCard(card, model.entries[index], context)); + out.querySelectorAll(".tools-console-log__clearBtn").forEach((button) => { + button.disabled = !model.hasHistory; + }); + } + function updateRelativeTimeLabels(out, entries) { if (!out) return; const labels = new Map(entries.map((entry) => [entry.id, entry.timeLabel || ""])); @@ -613,6 +681,57 @@ if (!model.entries.some((entry) => entry.id === activeNotificationId)) activeNotificationId = ""; } + function latestEntry(model) { + if (!model?.entries?.length) return null; + let latest = null; + let latestTime = 0; + model.entries.forEach((entry) => { + const timestamp = Number(entry.timestamp || 0); + if (Number.isFinite(timestamp) && timestamp > 0 && timestamp >= latestTime) { + latest = entry; + latestTime = timestamp; + } + }); + if (latest) return latest; + return model.entries.slice().reverse().find((entry) => entry.source === "current") || model.entries[model.entries.length - 1]; + } + + function createEntryFocus(out, entryId, expanded = true, options = {}) { + const scroller = getLogScroller(out); + const card = findCardById(scroller, entryId); + const cardRect = card?.getBoundingClientRect?.(); + return { + id: entryId, + expanded, + keyboard: false, + instant: options.instant === true, + smoothOnce: options.smoothOnce === true, + stableDetail: options.stableDetail === true, + mode: out?.dataset?.toolsNotificationMode || getMode(), + scrollTop: scroller?.scrollTop || 0, + cardTop: cardRect ? cardRect.top : null, + }; + } + + function focusEntry(out, entryId, options = {}) { + if (!entryId) return ""; + const expanded = options.expand !== false; + clearCollapseRenderTimer(); + activeNotificationId = expanded ? entryId : ""; + pendingEntryFocus = createEntryFocus(out, entryId, expanded, options); + detailScrollState.delete(entryId); + return entryId; + } + + function focusLatestEntry(out = lastHost, options = {}) { + const model = buildModel(lastState); + const entry = latestEntry(model); + if (!entry) return ""; + const focusedId = focusEntry(out, entry.id, options); + if (out) render(out, model.state, lastOptions, { force: true, preserveScroll: false }); + return focusedId; + } + function captureScrollAnchor(out, anchorId = activeNotificationId) { const scroller = getLogScroller(out); if (!scroller) return null; @@ -710,8 +829,9 @@ if (Math.abs(delta) < 2) return; const target = clampScrollTop(scroller, scroller.scrollTop + delta); + const behavior = opts.behavior || (prefersReducedMotion() ? "auto" : "smooth"); try { - scroller.scrollTo({ top: target, behavior: prefersReducedMotion() ? "auto" : "smooth" }); + scroller.scrollTo({ top: target, behavior }); } catch { scroller.scrollTop = target; } @@ -735,6 +855,14 @@ const mode = getMode(); global.requestAnimationFrame(() => { if (token !== entryFocusToken) return; + if (focus.instant) { + scrollEntryIntoView(out, focus, "settled", { behavior: "auto" }); + return; + } + if (focus.smoothOnce) { + scrollEntryIntoView(out, focus, "settled"); + return; + } if (focus.expanded && !prefersReducedMotion()) { const scroller = getLogScroller(out); const card = findCardById(scroller, focus.id); @@ -919,8 +1047,8 @@ function render(out, state = {}, options = {}, renderOptions = {}) { if (!out) return; bindModeSync(); - const interactionFocus = pendingEntryFocus; - const scrollAnchor = renderOptions.preserveScroll === false || interactionFocus ? null : captureScrollAnchor(out); + let interactionFocus = pendingEntryFocus; + let scrollAnchor = renderOptions.preserveScroll === false || interactionFocus ? null : captureScrollAnchor(out); const mode = getMode(); const model = buildModel(state); lastState = model.state; @@ -934,21 +1062,50 @@ } normalizeActiveEntry(model); + const autoFocusLatest = options.autoFocusLatest === true && !renderOptions.skipAutoFocusLatest; + const latest = autoFocusLatest ? latestEntry(model) : null; + if (canAutoFocusEntry(latest) && latest.id !== lastAutoFocusedEntryId) { + lastAutoFocusedEntryId = latest.id; + focusEntry(out, latest.id, { expand: true, smoothOnce: true, stableDetail: true }); + interactionFocus = pendingEntryFocus; + scrollAnchor = null; + } const signature = renderSignature(model, mode); - const canPatchExisting = !interactionFocus && signature === lastRenderSignature && out.childElementCount > 0; const context = { out, mode, model, options }; - if (canPatchExisting) { + if (!interactionFocus && signature === lastRenderSignature && out.childElementCount > 0) { updateRelativeTimeLabels(out, model.entries); scheduleRelativeTimeRefresh(model.entries); return; } + if (!interactionFocus && out.childElementCount > 0 && canPatchExistingCards(out, model)) { + patchExistingCards(out, model, context); + lastRenderSignature = signature; + scheduleRelativeTimeRefresh(model.entries); + if (activeNotificationId) { + global.requestAnimationFrame(() => { + const scroller = getLogScroller(out); + const card = findCardById(scroller, activeNotificationId); + setMeasuredDetailHeight(card); + restoreDetailScroll(out, activeNotificationId); + }); + } + return; + } out.replaceChildren(mode === MODE.PORTRAIT ? renderPortraitCenter(context) : renderLandscapePanel(context)); lastRenderSignature = signature; if (interactionFocus) { restoreEntryInteraction(out, interactionFocus); + if (interactionFocus.instant || interactionFocus.stableDetail) { + const scroller = getLogScroller(out); + stabilizeExpandedDetail(findCardById(scroller, interactionFocus.id)); + } } else { restoreScrollAnchor(out, scrollAnchor); + if (activeNotificationId) { + const scroller = getLogScroller(out); + stabilizeExpandedDetail(findCardById(scroller, activeNotificationId)); + } } scheduleRelativeTimeRefresh(model.entries); if (interactionFocus) { @@ -993,8 +1150,12 @@ render, resetDetail() { activeNotificationId = ""; + lastAutoFocusedEntryId = ""; detailScrollState.clear(); }, + focusLatest(options = {}) { + return focusLatestEntry(options.out || lastHost, options); + }, syncMode(out = lastHost) { if (out) syncHostMode(out); }, diff --git a/selfdrive/carrot/web/js/app_realtime.js b/selfdrive/carrot/web/js/realtime/app_realtime.js similarity index 100% rename from selfdrive/carrot/web/js/app_realtime.js rename to selfdrive/carrot/web/js/realtime/app_realtime.js diff --git a/selfdrive/carrot/web/js/home_drive.js b/selfdrive/carrot/web/js/realtime/home_drive.js similarity index 100% rename from selfdrive/carrot/web/js/home_drive.js rename to selfdrive/carrot/web/js/realtime/home_drive.js diff --git a/selfdrive/carrot/web/js/hud_card.js b/selfdrive/carrot/web/js/realtime/hud_card.js similarity index 100% rename from selfdrive/carrot/web/js/hud_card.js rename to selfdrive/carrot/web/js/realtime/hud_card.js diff --git a/selfdrive/carrot/web/js/raw_capnp.js b/selfdrive/carrot/web/js/realtime/raw_capnp.js similarity index 100% rename from selfdrive/carrot/web/js/raw_capnp.js rename to selfdrive/carrot/web/js/realtime/raw_capnp.js diff --git a/selfdrive/carrot/web/js/raw_capnp_worker.js b/selfdrive/carrot/web/js/realtime/raw_capnp_worker.js similarity index 95% rename from selfdrive/carrot/web/js/raw_capnp_worker.js rename to selfdrive/carrot/web/js/realtime/raw_capnp_worker.js index 1271a407bf..4165e235e6 100644 --- a/selfdrive/carrot/web/js/raw_capnp_worker.js +++ b/selfdrive/carrot/web/js/realtime/raw_capnp_worker.js @@ -1,6 +1,6 @@ "use strict"; -self.importScripts("/js/raw_capnp.js"); +self.importScripts("/js/realtime/raw_capnp.js"); const rawCapnp = self.CarrotRawCapnp || null; diff --git a/selfdrive/carrot/web/js/vision_raw.js b/selfdrive/carrot/web/js/realtime/vision_raw.js similarity index 99% rename from selfdrive/carrot/web/js/vision_raw.js rename to selfdrive/carrot/web/js/realtime/vision_raw.js index 0b019f1ea7..1afd48243f 100644 --- a/selfdrive/carrot/web/js/vision_raw.js +++ b/selfdrive/carrot/web/js/realtime/vision_raw.js @@ -32,7 +32,7 @@ let RAW_HUD_MUX_DISABLED = false; let RAW_OVERLAY_MUX_WS = null; let RAW_OVERLAY_MUX_RETRY_T = null; let RAW_OVERLAY_MUX_DISABLED = false; -const RAW_DECODE_WORKER_URL = "/js/raw_capnp_worker.js"; +const RAW_DECODE_WORKER_URL = "/js/realtime/raw_capnp_worker.js"; let RAW_DECODE_WORKER = null; let RAW_DECODE_WORKER_FAILED = false; let RAW_DECODE_REQ_ID = 0; diff --git a/selfdrive/carrot/web/js/vision_rtc.js b/selfdrive/carrot/web/js/realtime/vision_rtc.js similarity index 100% rename from selfdrive/carrot/web/js/vision_rtc.js rename to selfdrive/carrot/web/js/realtime/vision_rtc.js diff --git a/selfdrive/carrot/web/js/vision_state.js b/selfdrive/carrot/web/js/realtime/vision_state.js similarity index 100% rename from selfdrive/carrot/web/js/vision_state.js rename to selfdrive/carrot/web/js/realtime/vision_state.js diff --git a/selfdrive/carrot/web/js/shared/i18n.js b/selfdrive/carrot/web/js/shared/i18n.js index 9359b049bf..8b6e7fe194 100644 --- a/selfdrive/carrot/web/js/shared/i18n.js +++ b/selfdrive/carrot/web/js/shared/i18n.js @@ -9,12 +9,17 @@ const DRIVE_MODES = TRANSLATION_REGISTRY.driveModes || {}; const CARROT_WEB_LANGUAGE_CODES = Object.freeze(["en", "ko", "zh"]); window.CARROT_WEB_LANGUAGE_CODES = CARROT_WEB_LANGUAGE_CODES; -window.CarrotDeviceLanguageOptions = Object.freeze([ - { code: "main_en", name: "English" }, - { code: "main_ko", name: "한국어" }, - { code: "main_zh-CHS", name: "简体中文" }, - { code: "main_zh-CHT", name: "繁體中文" }, -]); +// Device language options — loaded from the server bootstrap (languages.json). +// Falls back to a minimal set if the server data is unavailable. +window.CarrotDeviceLanguageOptions = Object.freeze( + (Array.isArray(window.__CARROT_BOOTSTRAP__?.deviceLanguages) && + window.__CARROT_BOOTSTRAP__.deviceLanguages.length > 0) + ? window.__CARROT_BOOTSTRAP__.deviceLanguages + : [ + { code: "main_en", name: "English" }, + { code: "main_ko", name: "한국어" }, + ] +); let LANG = "en"; diff --git a/selfdrive/carrot/web/js/shared/ui/focus_trap.js b/selfdrive/carrot/web/js/shared/ui/focus_trap.js new file mode 100644 index 0000000000..5eb23bfdbd --- /dev/null +++ b/selfdrive/carrot/web/js/shared/ui/focus_trap.js @@ -0,0 +1,144 @@ +"use strict"; + +// Focus trap for modal-like surfaces (dialogs, sheets, full-screen overlays). +// +// Why: +// When a modal opens, Tab/Shift+Tab should cycle inside it. Otherwise +// keyboard users can tab "behind" the modal into the obscured page, +// which is broken UX and a WCAG 2.1 violation (2.4.3, 2.4.11). +// +// Use: +// const trap = createFocusTrap(overlayEl, { initialFocus: btn }); +// trap.activate(); // call when overlay becomes visible +// trap.deactivate(); // call when overlay closes (restores prior focus) +// +// `initialFocus` (optional): element or selector to focus first. +// Defaults to the first focusable element inside the container. +// `returnFocus` (optional): override where focus goes on deactivate. +// Defaults to whichever element had focus when activate() was called. +// `escape` (optional): callback fired when Esc is pressed. +// If provided, Esc is intercepted and passed to this handler. +// If omitted, Esc is left for the host element to handle. +// +// Pairs with .app-dialog and similar surfaces. dialog.js can adopt it +// by calling createFocusTrap when opening a dialog and deactivate() on +// resolve. New overlays should use this rather than re-implementing. + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable]:not([contenteditable="false"])', + 'audio[controls]', + 'video[controls]', + 'iframe', + 'object', + 'embed', + 'summary', +].join(","); + +function getFocusableElements(root) { + if (!root) return []; + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter((el) => { + if (el.hasAttribute("inert")) return false; + if (el.getAttribute("aria-hidden") === "true") return false; + // Reject hidden elements (display:none or visibility:hidden, including ancestors). + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none") return false; + return true; + }); +} + +function resolveInitial(container, value) { + if (!value) return null; + if (typeof value === "string") return container.querySelector(value); + if (value instanceof Element) return value; + return null; +} + +function createFocusTrap(container, options = {}) { + if (!container) return { activate() {}, deactivate() {} }; + let active = false; + let restoreTo = null; + let keydownHandler = null; + + function onKeydown(ev) { + if (!active) return; + if (ev.key === "Escape" && typeof options.escape === "function") { + options.escape(ev); + return; + } + if (ev.key !== "Tab") return; + + const focusable = getFocusableElements(container); + if (!focusable.length) { + // Nothing focusable inside; keep focus on the container itself. + ev.preventDefault(); + container.focus?.(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeEl = document.activeElement; + + if (ev.shiftKey) { + if (activeEl === first || !container.contains(activeEl)) { + ev.preventDefault(); + last.focus(); + } + } else { + if (activeEl === last || !container.contains(activeEl)) { + ev.preventDefault(); + first.focus(); + } + } + } + + return { + activate() { + if (active) return; + active = true; + restoreTo = options.returnFocus || document.activeElement; + // Make the container focusable as a fallback target. + if (!container.hasAttribute("tabindex")) container.setAttribute("tabindex", "-1"); + + keydownHandler = onKeydown; + document.addEventListener("keydown", keydownHandler, true); + + const initial = resolveInitial(container, options.initialFocus) + || getFocusableElements(container)[0] + || container; + // Defer to next frame so the container's open transition can start + // before focus moves (some screen readers misannounce otherwise). + requestAnimationFrame(() => { + if (!active) return; + initial?.focus?.({ preventScroll: true }); + }); + }, + + deactivate() { + if (!active) return; + active = false; + if (keydownHandler) { + document.removeEventListener("keydown", keydownHandler, true); + keydownHandler = null; + } + const target = restoreTo; + restoreTo = null; + if (target && typeof target.focus === "function" && document.contains(target)) { + target.focus({ preventScroll: true }); + } + }, + + isActive() { + return active; + }, + }; +} diff --git a/selfdrive/carrot/web/js/translations/en.js b/selfdrive/carrot/web/js/translations/en.js index 1ccb968164..10eb9a51c1 100644 --- a/selfdrive/carrot/web/js/translations/en.js +++ b/selfdrive/carrot/web/js/translations/en.js @@ -345,6 +345,8 @@ window.CarrotTranslations.register("en", { selected_count: "{count} selected", select_all: "Select all", deselect_all: "Deselect all", + select_loaded: "Select loaded", + deselect_loaded: "Deselect loaded", upload_selected: "Upload selected", segment_count: "{count} segments", segment_menu: "Segment menu", diff --git a/selfdrive/carrot/web/js/translations/ko.js b/selfdrive/carrot/web/js/translations/ko.js index d1d72b3aa0..2860e444b9 100644 --- a/selfdrive/carrot/web/js/translations/ko.js +++ b/selfdrive/carrot/web/js/translations/ko.js @@ -343,6 +343,8 @@ window.CarrotTranslations.register("ko", { selected_count: "선택 {count}개", select_all: "전체 선택", deselect_all: "전체 해제", + select_loaded: "로드된 항목 선택", + deselect_loaded: "로드된 항목 해제", upload_selected: "선택 전송", segment_count: "세그먼트 {count}개", segment_menu: "세그먼트 메뉴", diff --git a/selfdrive/carrot/web/js/translations/zh.js b/selfdrive/carrot/web/js/translations/zh.js index 0f49a21a34..89006c4eb4 100644 --- a/selfdrive/carrot/web/js/translations/zh.js +++ b/selfdrive/carrot/web/js/translations/zh.js @@ -343,6 +343,8 @@ window.CarrotTranslations.register("zh", { selected_count: "已选 {count} 个", select_all: "全选", deselect_all: "取消全选", + select_loaded: "选择已加载", + deselect_loaded: "取消已加载", upload_selected: "发送所选", segment_count: "{count} 个片段", segment_menu: "片段菜单",