diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 3d3186b02..dfb94fe97 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1239,19 +1239,49 @@ } .dashcam-player-state[hidden], -.dashcam-segment-status[hidden], +.dashcam-segment-bar[hidden], .dashcam-camera-selector button[hidden] { display: none; } -.dashcam-segment-status { +.dashcam-segment-bar { + align-items: center; background: var(--sidebar-bg); border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); - color: var(--text-muted); - font-size: var(--font-size-sm); + display: flex; + gap: var(--gap-xs); padding: 0.65rem var(--padding-lg); } +.dashcam-segment-bar .segment-step { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: flex; + font-size: var(--font-size-base); + justify-content: center; + padding: 0.35rem 0.7rem; +} + +.dashcam-segment-bar .segment-step:disabled { + cursor: default; + opacity: 0.4; +} + +.dashcam-segment-bar .segment-select { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-sm); + padding: 0.35rem 0.6rem; +} + + .dashcam-camera-selector { gap: var(--gap-xs); padding: var(--padding-base) var(--padding-lg) 0; @@ -1355,7 +1385,7 @@ } .dashcam-player-header, - .dashcam-segment-status, + .dashcam-segment-bar, .dashcam-camera-selector, .dashcam-player-actions { padding-left: var(--padding-base); diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index 42abb0020..f61909077 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -6,8 +6,9 @@ import { cameraVideoUrl, computeRouteStats, formatApproxDuration, - getSegmentStatus, - groupRoutesByDate, + getSegmentOptions, + supportsLowQuality, + groupRoutesForView, MAX_RENDERED_ROUTES, normalizeRoute, } from "/assets/components/recordings/dashcam_routes_helpers.js" @@ -32,6 +33,10 @@ let routesRequestToken = 0 let seenRouteNames = new Set() let overlay = null const routeLogsCache = new Map() +const FULL_QUALITY_RETRIES = 3 +const FULL_QUALITY_RETRY_MS = 4000 +// Wait for the viewer to settle, so scrubbing never queues a remux per segment. +const FULL_QUALITY_SETTLE_MS = 1500 function routeLabel(route) { return route.displayName || route.displayDate || route.name @@ -119,8 +124,11 @@ function closeDialog(dialog) { } function replaceRoute(updatedRoute) { - state.routes = state.routes.map(route => route.name === updatedRoute.name ? updatedRoute : route) - if (state.selectedRoute?.name === updatedRoute.name) state.selectedRoute = updatedRoute + // Rows bind to this exact object, so replacing it would strand them on the stale one. + const existing = state.routes.find(route => route.name === updatedRoute.name) + if (existing) Object.assign(existing, updatedRoute) + const selected = state.selectedRoute + if (selected?.name === updatedRoute.name && selected !== existing) Object.assign(selected, updatedRoute) } async function deleteRoute(route) { @@ -285,10 +293,14 @@ async function openOverlay(route) {
- +
Loading route metadata…
- +
@@ -305,7 +317,10 @@ async function openOverlay(route) { const video = overlay.querySelector("video") const playerState = overlay.querySelector(".dashcam-player-state") - const statusStrip = overlay.querySelector(".dashcam-segment-status") + const segmentBar = overlay.querySelector(".dashcam-segment-bar") + const segmentSelect = overlay.querySelector(".segment-select") + const prevSegmentButton = overlay.querySelector(".action-prev-segment") + const nextSegmentButton = overlay.querySelector(".action-next-segment") const downloadButton = overlay.querySelector(".action-download") const logsButton = overlay.querySelector(".action-logs") const cameraButtons = [...overlay.querySelectorAll(".camera-button")] @@ -313,27 +328,130 @@ async function openOverlay(route) { let current = 0 let selectedCamera = null let logsData = null + let qualityToken = 0 + let warmedSegment = null + let upgradeTimer = null const setPlayerMessage = (message, isError = false) => { playerState.textContent = message playerState.hidden = !message playerState.classList.toggle("error", isError) } - const updateSegmentStatus = () => { - const status = getSegmentStatus(segments, current) - statusStrip.textContent = status - statusStrip.hidden = !status + const syncSegmentControls = () => { + segmentSelect.value = String(current) + segmentSelect.disabled = segments.length < 2 + prevSegmentButton.disabled = current <= 0 + nextSegmentButton.disabled = current >= segments.length - 1 } - const playCurrentSegment = () => { - if (!segments[current] || !selectedCamera) return - updateSegmentStatus() - setPlayerMessage("Loading video…") - video.src = cameraVideoUrl(segments[current], selectedCamera) + const buildSegmentPicker = () => { + segmentSelect.innerHTML = getSegmentOptions(segments) + .map(option => ``) + .join("") + segmentBar.hidden = !segments.length + } + const swapSource = (url, { message } = {}) => { + const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 + const shouldResume = !video.paused && !video.ended + video.addEventListener("loadedmetadata", () => { + if (playbackTime > 0) { + try { + video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + } catch (_) {} + } + if (shouldResume) video.play().catch(() => {}) + }, { once: true }) + if (message) setPlayerMessage(message) + video.src = url video.load() - video.play().catch(() => {}) + } + + // Full-res needs a device-side remux, so wait for it behind playback rather than in front. + const requestFullQuality = (segmentUrl, camera, attempt = 0) => { + const token = ++qualityToken + const fullUrl = cameraVideoUrl(segmentUrl, camera) + const stillCurrent = () => + token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay + fetch(fullUrl, { method: "HEAD" }) + .then(response => { + if (!stillCurrent()) return + if (response.ok) { + swapSource(fullUrl) + return + } + // 503 means the remux is queued behind another one; check back a few times. + if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { + setTimeout(() => { + if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1) + }, FULL_QUALITY_RETRY_MS) + } + }) + .catch(() => {}) + } + + overlay._cancelUpgrade = () => { + qualityToken += 1 + clearTimeout(upgradeTimer) + } + + const upgradeToFullQuality = (segmentUrl, camera) => { + qualityToken += 1 + clearTimeout(upgradeTimer) + upgradeTimer = setTimeout(() => { + if (segments[current] === segmentUrl && selectedCamera === camera && overlay) { + requestFullQuality(segmentUrl, camera) + } + }, FULL_QUALITY_SETTLE_MS) + } + + const warmNextSegment = () => { + const nextUrl = segments[current + 1] + if (!nextUrl || !selectedCamera || !supportsLowQuality(selectedCamera)) return + if (warmedSegment === nextUrl) return + warmedSegment = nextUrl + // Only the ffmpeg-free stream is warmed; never transcode a segment nobody watches. + fetch(cameraVideoUrl(nextUrl, selectedCamera, "low"), { method: "HEAD" }).catch(() => {}) + } + + const playCurrentSegment = (autoplay = true) => { + if (!segments[current] || !selectedCamera) return + syncSegmentControls() + setPlayerMessage("Loading video…") + const segmentUrl = segments[current] + const camera = selectedCamera + const useLowFirst = supportsLowQuality(camera) + qualityToken += 1 + video.src = cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined) + video.load() + if (autoplay) video.play().catch(() => {}) + if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) + warmNextSegment() + } + const goToSegment = index => { + if (!segments.length) return + const target = Math.min(Math.max(index, 0), segments.length - 1) + if (target === current) { + syncSegmentControls() + return + } + const keepPlaying = video.ended || (!video.paused && !video.error) + current = target + warmedSegment = null + playCurrentSegment(keepPlaying) } const closeOnEscape = event => { - if (event.key === "Escape" && !document.querySelector(".route-logs-dialog")) closeOverlay() + if (document.querySelector(".route-logs-dialog")) return + if (event.key === "Escape") { + closeOverlay() + return + } + if (!event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return + if (event.key === "ArrowLeft") { + event.preventDefault() + goToSegment(current - 1) + } else if (event.key === "ArrowRight") { + event.preventDefault() + goToSegment(current + 1) + } } overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() }) @@ -358,30 +476,23 @@ async function openOverlay(route) { video.addEventListener("playing", () => setPlayerMessage("")) video.addEventListener("waiting", () => setPlayerMessage("Loading video…")) video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true)) - video.addEventListener("ended", () => { - if (current + 1 >= segments.length) return - current += 1 - playCurrentSegment() - }) + video.addEventListener("ended", () => goToSegment(current + 1)) + + prevSegmentButton.onclick = () => goToSegment(current - 1) + nextSegmentButton.onclick = () => goToSegment(current + 1) + segmentSelect.onchange = () => goToSegment(Number(segmentSelect.value)) for (const button of cameraButtons) { button.addEventListener("click", () => { if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return - const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended selectedCamera = button.dataset.camera cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) - video.addEventListener("loadedmetadata", () => { - if (playbackTime > 0) { - try { - video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) - } catch (_) {} - } - if (shouldResume) video.play().catch(() => {}) - }, { once: true }) - setPlayerMessage("Switching camera…") - video.src = cameraVideoUrl(segments[current], selectedCamera) - video.load() + const segmentUrl = segments[current] + const camera = selectedCamera + const useLowFirst = supportsLowQuality(camera) + qualityToken += 1 + swapSource(cameraVideoUrl(segmentUrl, camera, useLowFirst ? "low" : undefined), { message: "Switching camera…" }) + if (useLowFirst) upgradeToFullQuality(segmentUrl, camera) }) } @@ -402,6 +513,7 @@ async function openOverlay(route) { button.classList.toggle("active", button.dataset.camera === selectedCamera) } downloadButton.disabled = false + buildSegmentPicker() playCurrentSegment() } catch (error) { cameraButtons.forEach(button => { button.disabled = true }) @@ -411,6 +523,7 @@ async function openOverlay(route) { function closeOverlay() { if (!overlay) return + overlay._cancelUpgrade?.() document.removeEventListener("keydown", overlay._closeOnEscape) overlay.remove() overlay = null @@ -521,8 +634,7 @@ export function RouteRecordings() { ${() => { const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) - const groups = groupRoutesByDate(view.visible) - const isGrid = state.viewMode === "grid" + const groups = groupRoutesForView(view.visible, state.sortOrder) return html`
@@ -540,13 +652,12 @@ export function RouteRecordings() {

${group.label}

${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}
- ${isGrid ? html` -
+ ${() => state.viewMode === "grid" ? html`
${group.routes.map(route => html` -
+
- ${route.name.split("--").slice(1).join("--") || route.name}
@@ -581,11 +692,9 @@ export function RouteRecordings() {
`)} -
- ` : html` -
+
` : html`
${group.routes.map(route => html` -
+
@@ -600,7 +709,7 @@ export function RouteRecordings() {
${formatApproxDuration(route.approxDurationSeconds)} ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"} - ${route.is_preserved ? html` Preserved` : ""} + ${() => route.is_preserved ? html` Preserved` : ""} ${route.name.split("--").slice(1).join("--") || route.name}
@@ -608,8 +717,8 @@ export function RouteRecordings() { -