From e19aed96da8af2bfc28379e7b86eb89797946f22 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:53:53 -0700 Subject: [PATCH] Try again for smooth swap --- .../components/recordings/dashcam_routes.css | 20 +- .../components/recordings/dashcam_routes.js | 290 +++++++++++++----- 2 files changed, 217 insertions(+), 93 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 750df7112..6bce9771a 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -1212,6 +1212,7 @@ .dashcam-video-shell { aspect-ratio: 16 / 9; background: #07080b; + overflow: hidden; position: relative; width: 100%; } @@ -1219,23 +1220,22 @@ .dashcam-player-overlay .dashcam-player .dashcam-video-shell video { border-radius: 0; height: 100%; + inset: 0; object-fit: contain; + position: absolute; width: 100%; } -.dashcam-transition-frame { - background: #07080b; - height: 100%; - inset: 0; - object-fit: contain; - pointer-events: none; - position: absolute; - width: 100%; +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video.active { + opacity: 1; + pointer-events: auto; z-index: 1; } -.dashcam-transition-frame[hidden] { - display: none; +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video.staging { + opacity: 0; + pointer-events: none; + z-index: 0; } .dashcam-player-state { 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 353c4df43..fbb68f093 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -300,8 +300,8 @@ async function openOverlay(route) {
- - + +
Loading route metadata…
@@ -324,8 +324,9 @@ async function openOverlay(route) { ` document.body.appendChild(overlay) - const video = overlay.querySelector("video") - const transitionFrame = overlay.querySelector(".dashcam-transition-frame") + const [videoA, videoB] = overlay.querySelectorAll(".dashcam-video") + let activeVideo = videoA + let stagingVideo = videoB const playerState = overlay.querySelector(".dashcam-player-state") const segmentBar = overlay.querySelector(".dashcam-segment-bar") const segmentSelect = overlay.querySelector(".segment-select") @@ -343,7 +344,7 @@ async function openOverlay(route) { let qualityToken = 0 let upgradeController = null let upgradeTimer = null - let transitionActive = false + let isUpgrading = false const setPlayerMessage = (message, isError = false) => { playerState.textContent = message @@ -363,59 +364,138 @@ async function openOverlay(route) { segmentBar.hidden = !segments.length } - const finishTransition = () => { - transitionActive = false - transitionFrame.hidden = true - } - const holdCurrentFrame = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || !video.videoWidth || !video.videoHeight) return false - const context = transitionFrame.getContext("2d") - if (!context) return false - transitionFrame.width = video.videoWidth - transitionFrame.height = video.videoHeight - try { - context.drawImage(video, 0, 0, transitionFrame.width, transitionFrame.height) - } catch (_) { - return false - } - transitionActive = true - transitionFrame.hidden = false - setPlayerMessage("") - return true - } - const swapSource = (url, { message, seamless = false } = {}) => { - const playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended - const heldFrame = seamless && holdCurrentFrame() - // Never trade a working preview for a black frame. The prepared full stream can - // be attempted again later, while the low-resolution video keeps playing. - if (seamless && !heldFrame) return false - if (heldFrame) video.addEventListener("canplay", finishTransition, { once: true }) - 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 (!heldFrame) finishTransition() - if (message) setPlayerMessage(message) - video.src = url - video.load() - return true - } - const cancelUpgrade = () => { qualityToken += 1 + isUpgrading = false clearTimeout(upgradeTimer) upgradeTimer = null upgradeController?.abort() upgradeController = null + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.load() } - // 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 performSeamlessUpgrade = (fullUrl, token) => { + if (token !== qualityToken || !showingPreview || !overlay) return + isUpgrading = true + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + stagingVideo.src = fullUrl + stagingVideo.preload = "auto" + stagingVideo.load() + + const cleanupStaging = () => { + stagingVideo.removeEventListener("loadedmetadata", onMetadata) + stagingVideo.removeEventListener("error", onStagingError) + } + + const onStagingError = () => { + cleanupStaging() + if (token !== qualityToken) return + isUpgrading = false + showingPreview = false + } + + const onMetadata = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const syncAndSwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const applySwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + cleanupStaging() + + const targetTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const isPlaying = !activeVideo.paused && !activeVideo.ended + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + + if (Math.abs(stagingVideo.currentTime - targetTime) > 0.15) { + try { + stagingVideo.currentTime = targetTime + } catch (_) {} + } + + if (isPlaying && stagingVideo.paused) { + stagingVideo.play().catch(() => {}) + } else if (!isPlaying && !stagingVideo.paused) { + stagingVideo.pause() + } + + activeVideo.classList.remove("active") + activeVideo.classList.add("staging") + activeVideo.controls = false + + stagingVideo.classList.remove("staging") + stagingVideo.classList.add("active") + stagingVideo.controls = true + + const oldActive = activeVideo + activeVideo = stagingVideo + stagingVideo = oldActive + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.load() + + showingPreview = false + isUpgrading = false + setPlayerMessage("") + } + + const isPlaying = !activeVideo.paused && !activeVideo.ended + if (isPlaying) { + stagingVideo.play().then(() => { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + stagingVideo.addEventListener("timeupdate", applySwap, { once: true }) + } + }).catch(() => { + applySwap() + }) + } else { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + applySwap() + } + } + } + + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + if (playbackTime > 0) { + stagingVideo.addEventListener("seeked", syncAndSwap, { once: true }) + try { + stagingVideo.currentTime = Math.min(playbackTime, Number.isFinite(stagingVideo.duration) ? stagingVideo.duration : playbackTime) + } catch (_) { + syncAndSwap() + } + } else { + syncAndSwap() + } + } + + stagingVideo.addEventListener("loadedmetadata", onMetadata, { once: true }) + stagingVideo.addEventListener("error", onStagingError, { once: true }) + } + + // Request the real stream behind the playing preview without interrupting playback. const requestFullQuality = (segmentUrl, camera, attempt = 0) => { const token = ++qualityToken upgradeController?.abort() @@ -423,24 +503,14 @@ async function openOverlay(route) { upgradeController = controller const fullUrl = cameraVideoUrl(segmentUrl, camera) const stillCurrent = () => - token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && !!overlay - const swapPreparedStream = (attempt = 0) => { - if (!stillCurrent()) return - if (swapSource(fullUrl, { seamless: true })) { - showingPreview = false - return - } - if (attempt < 5) { - upgradeTimer = setTimeout(() => swapPreparedStream(attempt + 1), 100) - } - } + token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && Boolean(overlay) fetch(fullUrl, { method: "HEAD", signal: controller.signal }) .then(response => { if (!stillCurrent()) return if (upgradeController === controller) upgradeController = null if (response.ok) { - swapPreparedStream() + performSeamlessUpgrade(fullUrl, token) return } if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { @@ -468,13 +538,22 @@ async function openOverlay(route) { const camera = selectedCamera if (!segmentUrl || !camera) return cancelUpgrade() - finishTransition() 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(() => {}) + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.classList.remove("active") + stagingVideo.classList.add("staging") + stagingVideo.controls = false + + activeVideo.classList.remove("staging") + activeVideo.classList.add("active") + activeVideo.controls = true + activeVideo.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) + activeVideo.load() + if (autoplay) activeVideo.play().catch(() => {}) } const playCurrentSegment = (autoplay = true) => { @@ -489,7 +568,7 @@ async function openOverlay(route) { syncSegmentControls() return } - const keepPlaying = video.ended || (!video.paused && !video.error) + const keepPlaying = activeVideo.ended || (!activeVideo.paused && !activeVideo.error) current = target playCurrentSegment(keepPlaying) } @@ -529,21 +608,26 @@ async function openOverlay(route) { link.remove() } - video.addEventListener("loadedmetadata", () => { + const handleLoadedMetadata = event => { + if (event.target !== activeVideo) return if (!showingPreview) return - if (shouldUpgradeFromHeight(video.videoHeight)) { + if (shouldUpgradeFromHeight(activeVideo.videoHeight)) { scheduleUpgrade(segments[current], selectedCamera) } else { showingPreview = false } - }) - video.addEventListener("loadeddata", () => setPlayerMessage("")) - video.addEventListener("playing", () => setPlayerMessage("")) - video.addEventListener("waiting", () => { - if (!transitionActive) setPlayerMessage("Loading video…") - }) - video.addEventListener("error", () => { - finishTransition() + } + const handleLoadedData = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handlePlaying = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handleWaiting = event => { + if (event.target === activeVideo && !isUpgrading) setPlayerMessage("Loading video…") + } + const handleError = event => { + if (event.target !== activeVideo) return // A dead preview drops through to the real stream rather than showing an error. if (showingPreview) { showingPreview = false @@ -552,8 +636,42 @@ async function openOverlay(route) { return } setPlayerMessage("This segment could not be played.", true) - }) - video.addEventListener("ended", () => goToSegment(current + 1)) + } + const handleEnded = event => { + if (event.target === activeVideo) goToSegment(current + 1) + } + const handleSeeking = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.readyState >= 1) { + try { + stagingVideo.currentTime = activeVideo.currentTime + } catch (_) {} + } + } + const handlePause = event => { + if (event.target !== activeVideo) return + if (isUpgrading && !stagingVideo.paused) stagingVideo.pause() + } + const handlePlay = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.paused && stagingVideo.readyState >= 3) { + stagingVideo.play().catch(() => {}) + } + } + + const bindVideoEvents = el => { + el.addEventListener("loadedmetadata", handleLoadedMetadata) + el.addEventListener("loadeddata", handleLoadedData) + el.addEventListener("playing", handlePlaying) + el.addEventListener("waiting", handleWaiting) + el.addEventListener("error", handleError) + el.addEventListener("ended", handleEnded) + el.addEventListener("seeking", handleSeeking) + el.addEventListener("pause", handlePause) + el.addEventListener("play", handlePlay) + } + bindVideoEvents(videoA) + bindVideoEvents(videoB) prevSegmentButton.onclick = () => goToSegment(current - 1) nextSegmentButton.onclick = () => goToSegment(current + 1) @@ -564,12 +682,12 @@ 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 playbackTime = Number.isFinite(video.currentTime) ? video.currentTime : 0 - const shouldResume = !video.paused && !video.ended - video.addEventListener("loadedmetadata", () => { + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const shouldResume = !activeVideo.paused && !activeVideo.ended + activeVideo.addEventListener("loadedmetadata", () => { if (playbackTime > 0) { try { - video.currentTime = Math.min(playbackTime, Number.isFinite(video.duration) ? video.duration : playbackTime) + activeVideo.currentTime = Math.min(playbackTime, Number.isFinite(activeVideo.duration) ? activeVideo.duration : playbackTime) } catch (_) {} } }, { once: true }) @@ -606,6 +724,12 @@ function closeOverlay() { if (!overlay) return overlay._cancelUpgrade?.() document.removeEventListener("keydown", overlay._closeOnEscape) + const videos = overlay.querySelectorAll("video") + videos.forEach(v => { + v.pause() + v.removeAttribute("src") + v.load() + }) overlay.remove() overlay = null state.selectedRoute = null