From 8bcf4c7abf412a7bf08d37641c7d853c0f66d6f4 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:17:20 -0700 Subject: [PATCH] Tweaky Tweaky --- .../components/recordings/dashcam_routes.css | 84 ++++++--- .../components/recordings/dashcam_routes.js | 163 ++++++++++------- .../recordings/dashcam_routes_helpers.js | 8 +- .../the_galaxy/tests/test_dashcam_routes.py | 166 ++++++++++++++++-- .../tests/test_dashcam_routes_helpers.py | 23 ++- starpilot/system/the_galaxy/the_galaxy.py | 32 ++-- starpilot/system/the_galaxy/utilities.py | 29 ++- 7 files changed, 382 insertions(+), 123 deletions(-) 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 dfb94fe97..5f0c27a36 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1244,13 +1244,43 @@ display: none; } -.dashcam-segment-bar { +.dashcam-player-title-row { + align-items: center; + display: flex; + gap: var(--gap-xs); +} + +.dashcam-title-rename { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-size-base); + line-height: 1; + padding: 0.25rem; +} + +.dashcam-title-rename:hover { + color: var(--text-color); +} + +/* Cameras left, segments centred, actions right - the side columns stay equal so the + segment controls sit in the true centre regardless of how wide either side is. */ +.dashcam-player-toolbar { align-items: center; background: var(--sidebar-bg); border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: grid; + gap: var(--gap-sm); + grid-template-columns: 1fr auto 1fr; + padding: 0.65rem var(--padding-lg); +} + +.dashcam-segment-bar { + align-items: center; display: flex; gap: var(--gap-xs); - padding: 0.65rem var(--padding-lg); + justify-self: center; } .dashcam-segment-bar .segment-step { @@ -1284,18 +1314,30 @@ .dashcam-camera-selector { gap: var(--gap-xs); - padding: var(--padding-base) var(--padding-lg) 0; + justify-self: start; } -.dashcam-camera-selector button, -.dashcam-player-actions button { +.dashcam-camera-selector button { 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.7rem; +} + +.dashcam-player-actions button { + 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); - padding: 0.65rem 1rem; + justify-content: center; + padding: 0.35rem 0.6rem; } .dashcam-camera-selector button.active { @@ -1304,18 +1346,21 @@ } .dashcam-player-actions { - flex-wrap: wrap; - gap: var(--gap-sm); - padding: var(--padding-base) var(--padding-lg) var(--padding-lg); + gap: var(--gap-xs); + justify-self: end; } -.dashcam-player-actions .action-download { +.dashcam-player-actions .action-download:not(:disabled) { background: var(--color-confirm); } +.dashcam-player-actions button:disabled { + cursor: default; + opacity: 0.4; +} + .dashcam-player-actions .action-delete { background: var(--danger-bg); - margin-left: auto; } /* Responsive Adaptations */ @@ -1385,20 +1430,19 @@ } .dashcam-player-header, - .dashcam-segment-bar, - .dashcam-camera-selector, - .dashcam-player-actions { + .dashcam-player-toolbar { padding-left: var(--padding-base); padding-right: var(--padding-base); } - .dashcam-camera-selector, - .dashcam-player-actions { - display: grid; - grid-template-columns: repeat(2, 1fr); + /* Too narrow for three columns: stack them and centre each row. */ + .dashcam-player-toolbar { + grid-template-columns: 1fr; + justify-items: center; } - .dashcam-player-actions .action-delete { - margin-left: 0; + .dashcam-camera-selector, + .dashcam-player-actions { + justify-self: center; } } 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 f61909077..7ef1048f1 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -7,6 +7,7 @@ import { computeRouteStats, formatApproxDuration, getSegmentOptions, + shouldUpgradeFromHeight, supportsLowQuality, groupRoutesForView, MAX_RENDERED_ROUTES, @@ -33,10 +34,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 +const FULL_QUALITY_RETRIES = 2 +const FULL_QUALITY_RETRY_MS = 1000 +// Only ask for the full stream once the viewer settles, so scrubbing queues no remuxes. +const FULL_QUALITY_SETTLE_MS = 1200 function routeLabel(route) { return route.displayName || route.displayDate || route.name @@ -289,28 +290,35 @@ async function openOverlay(route) { overlay.innerHTML = ` ` document.body.appendChild(overlay) @@ -328,8 +336,10 @@ async function openOverlay(route) { let current = 0 let selectedCamera = null let logsData = null + let showingPreview = false + let wantsPlayback = true let qualityToken = 0 - let warmedSegment = null + let upgradeController = null let upgradeTimer = null const setPlayerMessage = (message, isError = false) => { @@ -365,66 +375,71 @@ async function openOverlay(route) { video.load() } - // Full-res needs a device-side remux, so wait for it behind playback rather than in front. + const cancelUpgrade = () => { + qualityToken += 1 + clearTimeout(upgradeTimer) + upgradeTimer = null + upgradeController?.abort() + upgradeController = null + } + + // Prepare the real stream behind the playing preview. Only replace the media source + // after the server confirms the remux is ready, so slow device work never blanks it. const requestFullQuality = (segmentUrl, camera, attempt = 0) => { const token = ++qualityToken + upgradeController?.abort() + const controller = new AbortController() + upgradeController = controller const fullUrl = cameraVideoUrl(segmentUrl, camera) const stillCurrent = () => - token === qualityToken && segments[current] === segmentUrl && selectedCamera === camera && !!overlay - fetch(fullUrl, { method: "HEAD" }) + token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && !!overlay + + fetch(fullUrl, { method: "HEAD", signal: controller.signal }) .then(response => { if (!stillCurrent()) return + if (upgradeController === controller) upgradeController = null if (response.ok) { + showingPreview = false 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(() => { + upgradeTimer = setTimeout(() => { if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1) }, FULL_QUALITY_RETRY_MS) } }) - .catch(() => {}) + .catch(error => { + if (upgradeController === controller) upgradeController = null + if (error?.name !== "AbortError") console.error("Could not prepare full-quality route video:", error) + }) } - overlay._cancelUpgrade = () => { - qualityToken += 1 - clearTimeout(upgradeTimer) - } - - const upgradeToFullQuality = (segmentUrl, camera) => { - qualityToken += 1 - clearTimeout(upgradeTimer) + const scheduleUpgrade = (segmentUrl, camera) => { + cancelUpgrade() upgradeTimer = setTimeout(() => { - if (segments[current] === segmentUrl && selectedCamera === camera && overlay) { - requestFullQuality(segmentUrl, camera) - } + if (!showingPreview || segments[current] !== segmentUrl || selectedCamera !== camera) return + 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 loadSegment = (autoplay, { message, preview } = {}) => { + const segmentUrl = segments[current] + const camera = selectedCamera + if (!segmentUrl || !camera) return + cancelUpgrade() + wantsPlayback = autoplay + showingPreview = preview === undefined ? supportsLowQuality(camera) : preview + setPlayerMessage(message || "Loading video…") + video.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) + video.load() + if (autoplay) video.play().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() + loadSegment(autoplay) } const goToSegment = index => { if (!segments.length) return @@ -435,9 +450,10 @@ async function openOverlay(route) { } const keepPlaying = video.ended || (!video.paused && !video.error) current = target - warmedSegment = null playCurrentSegment(keepPlaying) } + overlay._cancelUpgrade = cancelUpgrade + const closeOnEscape = event => { if (document.querySelector(".route-logs-dialog")) return if (event.key === "Escape") { @@ -472,10 +488,27 @@ async function openOverlay(route) { link.remove() } + video.addEventListener("loadedmetadata", () => { + if (!showingPreview) return + if (shouldUpgradeFromHeight(video.videoHeight)) { + scheduleUpgrade(segments[current], selectedCamera) + } else { + showingPreview = false + } + }) video.addEventListener("loadeddata", () => setPlayerMessage("")) video.addEventListener("playing", () => setPlayerMessage("")) video.addEventListener("waiting", () => setPlayerMessage("Loading video…")) - video.addEventListener("error", () => setPlayerMessage("This segment could not be played.", true)) + video.addEventListener("error", () => { + // A dead preview drops through to the real stream rather than showing an error. + if (showingPreview) { + showingPreview = false + cancelUpgrade() + loadSegment(wantsPlayback, { preview: false }) + return + } + setPlayerMessage("This segment could not be played.", true) + }) video.addEventListener("ended", () => goToSegment(current + 1)) prevSegmentButton.onclick = () => goToSegment(current - 1) @@ -487,12 +520,16 @@ async function openOverlay(route) { if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return selectedCamera = button.dataset.camera cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) - 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) + 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 (_) {} + } + }, { once: true }) + loadSegment(shouldResume, { message: "Switching camera…" }) }) } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js index f84c9415b..6262ad80f 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -195,7 +195,13 @@ export function cameraVideoUrl(segmentUrl, camera, quality) { return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url } -// qcamera.ts only exists for the road camera, so the instant-start tier is forward-only. +// loggerd only writes qcamera.ts alongside the road camera. export function supportsLowQuality(camera) { return camera === "forward" } + +// qcamera is 526x330. Only a positively taller frame proves the real stream is already +// playing; an unknown height upgrades rather than stranding the viewer on the preview. +export function shouldUpgradeFromHeight(height) { + return !(Number.isFinite(height) && height > 400) +} diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py index 77228c0a9..3c025d381 100644 --- a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone import io import os from pathlib import Path +import subprocess import threading import time @@ -480,35 +481,162 @@ def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path assert metadata.get_json()["total_duration"] == 180 -def test_low_quality_serves_qcamera_without_touching_ffmpeg(monkeypatch, tmp_path): +def test_low_quality_serves_the_wrapped_qcamera_preview(monkeypatch, tmp_path): + """qcamera.ts is tiny, but it still needs the mp4 wrap - MPEG-TS will not play in a