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) {
@@ -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`
-
{ state.selectedRoute = route }}">
+ { state.selectedRoute = route }}">
-
@@ -581,11 +692,9 @@ export function RouteRecordings() {
`)}
-
- ` : html`
-