mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-11 10:43:46 +08:00
Bugs Be Ghosty
This commit is contained in:
@@ -361,30 +361,6 @@ async function openOverlay(route) {
|
||||
let upgradeTimer = null
|
||||
let isUpgrading = false
|
||||
|
||||
const clearDeferredNativeControls = video => {
|
||||
if (typeof video._dashcamControlsCleanup === "function") video._dashcamControlsCleanup()
|
||||
}
|
||||
const setNativeControls = (video, enabled) => {
|
||||
clearDeferredNativeControls(video)
|
||||
video.controls = enabled
|
||||
}
|
||||
const deferNativeControlsUntilInteraction = video => {
|
||||
clearDeferredNativeControls(video)
|
||||
video.controls = false
|
||||
|
||||
const restore = () => {
|
||||
if (video !== activeVideo || !overlay) return
|
||||
setNativeControls(video, true)
|
||||
}
|
||||
const events = ["pointermove", "pointerdown", "focus"]
|
||||
const cleanup = () => {
|
||||
events.forEach(eventName => video.removeEventListener(eventName, restore))
|
||||
delete video._dashcamControlsCleanup
|
||||
}
|
||||
video._dashcamControlsCleanup = cleanup
|
||||
events.forEach(eventName => video.addEventListener(eventName, restore))
|
||||
}
|
||||
|
||||
const setPlayerMessage = (message, isError = false) => {
|
||||
playerState.textContent = message
|
||||
playerState.hidden = !message
|
||||
@@ -412,7 +388,7 @@ async function openOverlay(route) {
|
||||
upgradeController = null
|
||||
stagingVideo.pause()
|
||||
stagingVideo.removeAttribute("src")
|
||||
setNativeControls(stagingVideo, false)
|
||||
stagingVideo.controls = false
|
||||
stagingVideo.load()
|
||||
}
|
||||
|
||||
@@ -479,12 +455,13 @@ async function openOverlay(route) {
|
||||
|
||||
activeVideo.classList.remove("active")
|
||||
activeVideo.classList.add("staging")
|
||||
setNativeControls(activeVideo, false)
|
||||
activeVideo.controls = false
|
||||
|
||||
stagingVideo.classList.remove("staging")
|
||||
stagingVideo.classList.add("active")
|
||||
if (isPlaying) deferNativeControlsUntilInteraction(stagingVideo)
|
||||
else setNativeControls(stagingVideo, true)
|
||||
// Keep native controls enabled across the quality swap so touch browsers
|
||||
// can reveal them again after their normal auto-hide.
|
||||
stagingVideo.controls = true
|
||||
|
||||
const oldActive = activeVideo
|
||||
activeVideo = stagingVideo
|
||||
@@ -590,11 +567,11 @@ async function openOverlay(route) {
|
||||
stagingVideo.removeAttribute("src")
|
||||
stagingVideo.classList.remove("active")
|
||||
stagingVideo.classList.add("staging")
|
||||
setNativeControls(stagingVideo, false)
|
||||
stagingVideo.controls = false
|
||||
|
||||
activeVideo.classList.remove("staging")
|
||||
activeVideo.classList.add("active")
|
||||
setNativeControls(activeVideo, true)
|
||||
activeVideo.controls = true
|
||||
activeVideo.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined)
|
||||
activeVideo.load()
|
||||
if (autoplay) activeVideo.play().catch(() => {})
|
||||
@@ -768,7 +745,6 @@ function closeOverlay() {
|
||||
document.removeEventListener("keydown", overlay._closeOnEscape)
|
||||
const videos = overlay.querySelectorAll("video")
|
||||
videos.forEach(v => {
|
||||
v._dashcamControlsCleanup?.()
|
||||
v.pause()
|
||||
v.removeAttribute("src")
|
||||
v.load()
|
||||
|
||||
@@ -733,6 +733,8 @@ ul { list-style: none; margin: 0; padding: 0; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gx-row__actions { align-self: flex-end; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; }
|
||||
.gx-row--favorites { align-items: stretch; flex-direction: column; }
|
||||
.gx-row--favorites .gx-row__info { flex: none; }
|
||||
@@ -974,6 +976,8 @@ input[type="color"].gx-color {
|
||||
.gx-btn--danger:hover { background: #ff6b8b; }
|
||||
[data-theme="light"] .gx-btn--danger:hover { background: #e04b6f; }
|
||||
|
||||
.gx-btn--icon { min-height: 36px; min-width: 36px; padding: 0; }
|
||||
|
||||
.gx-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1083,6 +1087,20 @@ input[type="color"].gx-color {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
button.gx-chip {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--surface-container-high);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--on-surface-variant);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
button.gx-chip:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
}
|
||||
|
||||
.gx-chip--advanced {
|
||||
background: rgba(255, 184, 101, 0.18);
|
||||
border: 1px solid var(--warning);
|
||||
@@ -1300,7 +1318,7 @@ input[type="color"].gx-color {
|
||||
sibling of gx-sheet so confirm dialogs keep their centered look. */
|
||||
.gx-scrim--bottomsheet {
|
||||
align-items: flex-end;
|
||||
padding: var(--sp-4) 0 calc(var(--bottomnav-height) + var(--sp-3));
|
||||
padding: var(--sp-4) 0 0;
|
||||
}
|
||||
|
||||
.gx-scrim--bottomsheet .gx-sheet {
|
||||
@@ -1372,7 +1390,224 @@ input[type="color"].gx-color {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-height: 48dvh;
|
||||
/* Give native controls their own stacking layer above the sheet background. */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gx-video-segment-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gx-video-segment-select {
|
||||
flex: 0 1 240px;
|
||||
}
|
||||
|
||||
/* Custom dropdown standing in for a native <select>: Android renders a native
|
||||
select as a full-screen wall of options with no way to restyle it, so segment
|
||||
picking gets its own small themed menu instead, consistent on every platform. */
|
||||
.gx-segment-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gx-segment-picker__trigger {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
color: var(--on-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-base);
|
||||
justify-content: space-between;
|
||||
min-height: 46px;
|
||||
padding: 0 var(--sp-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gx-segment-picker__trigger:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
.gx-segment-picker__menu {
|
||||
background: var(--surface-container-high);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
bottom: calc(100% + 4px);
|
||||
box-shadow: var(--elev-3);
|
||||
left: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
[data-theme="light"] .gx-segment-picker__menu {
|
||||
border: 1.5px solid var(--primary);
|
||||
}
|
||||
|
||||
.gx-segment-picker__item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--on-surface);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gx-segment-picker__item:hover { background: var(--glass-bg-hover); }
|
||||
|
||||
.gx-segment-picker__item.active {
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.gx-route-logs-section {
|
||||
scroll-margin-top: calc(var(--appbar-height) + var(--sp-3));
|
||||
}
|
||||
|
||||
.gx-route-logs-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gx-route-logs-title .gx-section__title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gx-route-logs-summary {
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.gx-recordings-preserved-chip {
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
margin-top: var(--sp-1);
|
||||
padding: 2px 7px;
|
||||
vertical-align: middle;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.gx-recordings-row--preserved .gx-row__actions {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.gx-video-player-header {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.gx-route-logs-download {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.gx-route-logs-close {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.gx-video-actions {
|
||||
justify-content: center;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-video-download {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.gx-recordings-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"name name"
|
||||
"details actions"
|
||||
"preserved preserved";
|
||||
row-gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.gx-recordings-row .gx-row__info {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.gx-recordings-row .gx-row__label {
|
||||
grid-area: name;
|
||||
}
|
||||
|
||||
.gx-recordings-row .gx-row__desc {
|
||||
grid-area: details;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gx-recordings-row .gx-recordings-preserved-chip {
|
||||
grid-area: preserved;
|
||||
justify-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gx-recordings-row .gx-row__actions {
|
||||
align-self: center;
|
||||
grid-area: actions;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.gx-screen-recordings-row {
|
||||
grid-template-columns: 84px minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"thumbnail name name"
|
||||
"thumbnail details actions";
|
||||
}
|
||||
|
||||
.gx-screen-recordings-row .gx-recording-row-thumb {
|
||||
grid-area: thumbnail;
|
||||
}
|
||||
|
||||
.gx-route-logs-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.gx-route-logs-close {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.gx-route-logs-download {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.gx-route-delete-actions {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.gx-route-delete-btn {
|
||||
flex: 1 1 0;
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
padding-left: var(--sp-2);
|
||||
padding-right: var(--sp-2);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.gx-view, .gx-embed {
|
||||
|
||||
@@ -77,6 +77,7 @@ export const Recordings = {
|
||||
playerError: "",
|
||||
segments: [],
|
||||
current: 0,
|
||||
segmentMenuOpen: false,
|
||||
cameras: [],
|
||||
selectedCamera: "",
|
||||
logsRoute: null,
|
||||
@@ -206,7 +207,7 @@ export const Recordings = {
|
||||
if (!newName || newName === route.displayName) return
|
||||
try {
|
||||
const payload = await api.renameRoute(route.name, newName)
|
||||
Object.assign(route, normalizeRoute({ ...route, name: payload.name || newName, isCustomName: true }))
|
||||
Object.assign(route, normalizeRoute({ ...route, isCustomName: true }))
|
||||
route.displayName = payload.name || newName
|
||||
showSnackbar("Route renamed!")
|
||||
} catch (e) {
|
||||
@@ -237,6 +238,7 @@ export const Recordings = {
|
||||
if (!cameras.length) throw new Error("No camera video for this route.")
|
||||
this.segments = segments
|
||||
this.current = 0
|
||||
this.segmentMenuOpen = false
|
||||
this.cameras = cameras
|
||||
this.selectedCamera = cameras.includes("forward") ? "forward" : cameras[0]
|
||||
this.$nextTick(() => this.playSegment())
|
||||
@@ -264,10 +266,23 @@ export const Recordings = {
|
||||
return
|
||||
}
|
||||
this._playRetries = 0
|
||||
video.controls = true
|
||||
video.src = this.cameraUrl(this.segments[this.current])
|
||||
video.load()
|
||||
video.play().catch(() => {})
|
||||
},
|
||||
toggleSegmentMenu() {
|
||||
this.segmentMenuOpen = !this.segmentMenuOpen
|
||||
if (this.segmentMenuOpen) {
|
||||
this.$nextTick(() => this.$el.querySelector(".gx-segment-picker__item.active")?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
},
|
||||
selectSegment(i) {
|
||||
this.segmentMenuOpen = false
|
||||
if (i === this.current) return
|
||||
this.current = i
|
||||
this.playSegment()
|
||||
},
|
||||
downloadRoute() {
|
||||
if (!this.playerRoute) return
|
||||
const a = document.createElement("a")
|
||||
@@ -282,16 +297,22 @@ export const Recordings = {
|
||||
this.playerLoading = false
|
||||
this.playerError = ""
|
||||
this.segments = []
|
||||
this.segmentMenuOpen = false
|
||||
this.cameras = []
|
||||
},
|
||||
async openLogs(route) {
|
||||
try {
|
||||
this.logsData = await api.getRouteLogs(route.name)
|
||||
this.logsRoute = route
|
||||
this.$nextTick(() => this.$refs.logsSection?.scrollIntoView({ behavior: "smooth", block: "start" }))
|
||||
} catch (e) {
|
||||
showSnackbar("Could not read logs.", "error")
|
||||
}
|
||||
},
|
||||
closeLogs() {
|
||||
this.logsRoute = null
|
||||
this.logsData = null
|
||||
},
|
||||
closeRecPlayer() {
|
||||
this.recPlay = null
|
||||
},
|
||||
@@ -388,16 +409,17 @@ export const Recordings = {
|
||||
<div v-if="error" class="gx-empty" style="color: var(--error);">{{ error }}</div>
|
||||
<section class="gx-card">
|
||||
<div v-if="!visibleRoutes.length && !loading" class="gx-empty">No routes found.</div>
|
||||
<article v-for="r in visibleRoutes" :key="r.name" class="gx-row" style="cursor:pointer;" @click="openPlayer(r)">
|
||||
<article v-for="r in visibleRoutes" :key="r.name" class="gx-row gx-recordings-row" :class="{ 'gx-recordings-row--preserved': r.is_preserved }" style="cursor:pointer;" @click="openPlayer(r)">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ r.displayName }} <span v-if="r.is_preserved" class="gx-chip gx-chip--dev">Preserved</span></span>
|
||||
<span class="gx-row__label">{{ r.displayName }}</span>
|
||||
<span class="gx-row__desc">{{ fmtDuration(r.approxDurationSeconds) }} · {{ r.segmentCount }} segments</span>
|
||||
<span v-if="r.is_preserved" class="gx-chip gx-chip--dev gx-recordings-preserved-chip">Preserved</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Preserve" @click.stop="togglePreserved(r)"><i class="bi" :class="r.is_preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Logs" @click.stop="openLogs(r)"><i class="bi bi-file-earmark-arrow-down"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRoute(r)"><i class="bi bi-pencil"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
|
||||
<div class="gx-row__actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Preserve" @click.stop="togglePreserved(r)"><i class="bi" :class="r.is_preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Logs" @click.stop="openLogs(r)"><i class="bi bi-file-earmark-arrow-down"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Rename" @click.stop="renameRoute(r)"><i class="bi bi-pencil"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--danger gx-btn--icon" title="Delete" @click.stop="deleteRoute(r)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -407,17 +429,21 @@ export const Recordings = {
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span class="gx-section__title">Delete local routes</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="deleteAllRoutes(false)">Delete Non-Preserved</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
|
||||
<div class="gx-route-delete-actions" style="display:flex; gap:8px; padding: var(--sp-3); flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-route-delete-btn" @click="deleteAllRoutes(false)">Delete Non-Preserved</button>
|
||||
<button type="button" class="gx-btn gx-btn--danger gx-route-delete-btn" @click="deleteAllRoutes(true)">Delete All Including Preserved</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="logsRoute && logsData" class="gx-card" style="margin-top:12px;">
|
||||
<div class="gx-section__header">
|
||||
<div v-if="logsRoute && logsData" ref="logsSection" class="gx-card gx-route-logs-section" style="margin-top:12px;">
|
||||
<div class="gx-section__header gx-route-logs-header">
|
||||
<i class="bi bi-file-earmark-arrow-down"></i>
|
||||
<span class="gx-section__title">{{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }}</span>
|
||||
<a class="gx-btn gx-btn--tonal" :href="'/api/routes/' + logsRoute.name + '/logs/download'" download>Download all (.tar)</a>
|
||||
<div class="gx-route-logs-title">
|
||||
<span class="gx-section__title">{{ logsRoute.displayName }} Logs</span>
|
||||
<span class="gx-route-logs-summary">{{ logsData.segments?.length || 0 }} segments · {{ formatBytes(logsData.totalBytes) }}</span>
|
||||
</div>
|
||||
<button type="button" class="gx-icon-btn gx-route-logs-close" aria-label="Close logs" title="Close logs" @click="closeLogs"><i class="bi bi-x-lg"></i></button>
|
||||
<a class="gx-btn gx-btn--tonal gx-route-logs-download" :href="'/api/routes/' + logsRoute.name + '/logs/download'" download>Download all (.tar)</a>
|
||||
</div>
|
||||
<div v-for="seg in logsData.segments || []" :key="seg.segmentNum" class="gx-row">
|
||||
<div class="gx-row__info">
|
||||
@@ -439,17 +465,17 @@ export const Recordings = {
|
||||
<div v-if="screenLoading && !recordings.length" class="gx-loading">Loading screen recordings...</div>
|
||||
<div v-else-if="screenError" class="gx-empty" style="color: var(--error);">{{ screenError }}</div>
|
||||
<div v-else-if="!recordings.length" class="gx-empty">No screen recordings found.</div>
|
||||
<article v-for="r in recordings" :key="r.filename" class="gx-row" style="cursor:pointer;" @click="playRec(r)">
|
||||
<img :src="r.png" alt="" loading="lazy" style="width:84px; height:auto; border-radius:var(--radius-sm); object-fit:cover; flex:none;">
|
||||
<article v-for="r in recordings" :key="r.filename" class="gx-row gx-recordings-row gx-screen-recordings-row" style="cursor:pointer;" @click="playRec(r)">
|
||||
<img class="gx-recording-row-thumb" :src="r.png" alt="" loading="lazy" style="width:84px; height:auto; border-radius:var(--radius-sm); object-fit:cover; flex:none;">
|
||||
<div class="gx-row__info">
|
||||
<span class="gx-row__label">{{ screenDisplayName(r) }}</span>
|
||||
<span class="gx-row__desc">{{ r.filename }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Play" @click.stop="playRec(r)"><i class="bi bi-play-fill"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Rename" @click.stop="renameRec(r)"><i class="bi bi-pencil"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" title="Download" @click.stop="downloadRec(r)"><i class="bi bi-download"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--danger" title="Delete" @click.stop="deleteRec(r)"><i class="bi bi-trash"></i></button>
|
||||
<div class="gx-row__actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Play" @click.stop="playRec(r)"><i class="bi bi-play-fill"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Rename" @click.stop="renameRec(r)"><i class="bi bi-pencil"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" title="Download" @click.stop="downloadRec(r)"><i class="bi bi-download"></i></button>
|
||||
<button type="button" class="gx-btn gx-btn--danger gx-btn--icon" title="Delete" @click.stop="deleteRec(r)"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -468,8 +494,8 @@ export const Recordings = {
|
||||
<Teleport to="body">
|
||||
<transition name="gx-fade">
|
||||
<div v-if="sub === 'routes' && playerRoute" class="gx-scrim gx-scrim--bottomsheet" @click.self="closePlayer">
|
||||
<div class="gx-sheet" role="dialog" aria-label="Route video player">
|
||||
<div class="gx-section__header" style="cursor:default;">
|
||||
<div class="gx-sheet" role="dialog" aria-label="Route video player" @click="segmentMenuOpen = false">
|
||||
<div class="gx-section__header gx-video-player-header" style="cursor:default;">
|
||||
<i class="bi bi-camera-video"></i>
|
||||
<span class="gx-section__title">{{ playerRoute.displayName }}</span>
|
||||
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closePlayer"><i class="bi bi-x-lg"></i></button>
|
||||
@@ -479,14 +505,24 @@ export const Recordings = {
|
||||
<div v-else-if="playerLoading" class="gx-loading"><i class="bi bi-hourglass-split"></i> Loading video...</div>
|
||||
<template v-else-if="segments.length">
|
||||
<video ref="player" class="gx-video" controls muted playsinline preload="metadata"></video>
|
||||
<div style="display:flex; gap:8px; padding: var(--sp-3) 0 0; flex-wrap:wrap; align-items:center;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<select class="gx-field" :value="current" @change="current = Number($event.target.value); playSegment()">
|
||||
<option v-for="(s,i) in segments" :key="i" :value="i">Segment {{ i + 1 }}</option>
|
||||
</select>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-skip-end-fill"></i></button>
|
||||
<button v-for="c in cameras" :key="c" type="button" class="gx-chip" :style="selectedCamera===c?'background:var(--primary);color:var(--on-primary);':''" @click="selectedCamera=c; playSegment()">{{ c }}</button>
|
||||
<button type="button" class="gx-btn" @click="downloadRoute"><i class="bi bi-download"></i> Download</button>
|
||||
<div style="display:flex; flex-direction:column; gap:8px; padding: var(--sp-3) 0 0;">
|
||||
<div class="gx-video-segment-controls">
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" aria-label="Previous segment" :disabled="current<=0" @click="current--; playSegment()"><i class="bi bi-chevron-left"></i></button>
|
||||
<div class="gx-segment-picker gx-video-segment-select">
|
||||
<button type="button" class="gx-segment-picker__trigger" :aria-expanded="segmentMenuOpen ? 'true' : 'false'" @click.stop="toggleSegmentMenu">
|
||||
<span>Segment {{ current + 1 }} of {{ segments.length }}</span>
|
||||
<i class="bi" :class="segmentMenuOpen ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
|
||||
</button>
|
||||
<div v-if="segmentMenuOpen" class="gx-segment-picker__menu" role="listbox" @click.stop>
|
||||
<button v-for="(s,i) in segments" :key="i" type="button" class="gx-segment-picker__item" :class="{ active: i === current }" role="option" :aria-selected="i === current ? 'true' : 'false'" @click="selectSegment(i)">Segment {{ i + 1 }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon" aria-label="Next segment" :disabled="current>=segments.length-1" @click="current++; playSegment()"><i class="bi bi-chevron-right"></i></button>
|
||||
</div>
|
||||
<div class="gx-video-actions" style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||
<button v-for="c in cameras" :key="c" type="button" class="gx-chip" :style="selectedCamera===c?'background:var(--primary);color:var(--on-primary);':''" @click="selectedCamera=c; playSegment()">{{ c }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal gx-btn--icon gx-video-download" title="Download" style="margin-left:auto;" @click="downloadRoute"><i class="bi bi-download"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -499,7 +535,7 @@ export const Recordings = {
|
||||
<transition name="gx-fade">
|
||||
<div v-if="recPlay" class="gx-scrim gx-scrim--bottomsheet" @click.self="closeRecPlayer">
|
||||
<div class="gx-sheet" role="dialog" aria-label="Screen recording player">
|
||||
<div class="gx-section__header" style="cursor:default;">
|
||||
<div class="gx-section__header gx-video-player-header" style="cursor:default;">
|
||||
<i class="bi bi-record-circle"></i>
|
||||
<span class="gx-section__title">{{ screenDisplayName(recPlay) }}</span>
|
||||
<button type="button" class="gx-icon-btn" aria-label="Close player" @click="closeRecPlayer"><i class="bi bi-x-lg"></i></button>
|
||||
|
||||
@@ -100,8 +100,8 @@ def test_player_shell_keeps_both_quality_levels_at_one_fixed_size():
|
||||
assert ".dashcam-video-shell.qcamera-framing video" in source
|
||||
assert "object-fit: fill;" in source
|
||||
assert 'videoShell.classList.toggle("qcamera-framing", showingPreview)' in component
|
||||
assert "deferNativeControlsUntilInteraction(stagingVideo)" in component
|
||||
assert "stagingVideo.controls = true" not in component
|
||||
assert "deferNativeControlsUntilInteraction" not in component
|
||||
assert "stagingVideo.controls = true" in component
|
||||
|
||||
|
||||
def test_groups_routes_into_today_yesterday_dates_and_unknown():
|
||||
|
||||
@@ -709,6 +709,8 @@ def _prune_video_cache(keep_path=None):
|
||||
|
||||
|
||||
def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
# -tag:v hvc1: ffmpeg tags a copied HEVC stream "hev1" by default, which Safari/iOS
|
||||
# refuses to play inline; "hvc1" is the tag WebKit actually accepts.
|
||||
if not input_files:
|
||||
raise ValueError("No input files provided for concatenation")
|
||||
|
||||
@@ -733,7 +735,7 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
||||
try:
|
||||
subprocess.run(
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
"-i", str(list_file), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
"-i", str(list_file), "-c", "copy", "-tag:v", "hvc1", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -770,7 +772,8 @@ def ffmpeg_stream_concatenated_mp4(input_files, chunk_size=256 * 1024):
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
||||
"-i", str(list_path), "-c", "copy", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-i", str(list_path), "-c", "copy", "-tag:v", "hvc1",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f", "mp4", "pipe:1"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -845,7 +848,9 @@ def ffmpeg_mp4_wrap_to_path(filename):
|
||||
|
||||
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
|
||||
|
||||
file_hash = hashlib.md5(str(input_path).encode()).hexdigest()
|
||||
# "hvc1" prefix busts caches remuxed before the hvc1 tag fix, so iPhones don't keep
|
||||
# getting served an old hev1-tagged file that predates it.
|
||||
file_hash = hashlib.md5(f"hvc1|{input_path}".encode()).hexdigest()
|
||||
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
|
||||
|
||||
if cache_path.exists() and cache_path.stat().st_mtime > input_path.stat().st_mtime:
|
||||
@@ -860,7 +865,7 @@ def ffmpeg_mp4_wrap_to_path(filename):
|
||||
try:
|
||||
subprocess.run(
|
||||
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path),
|
||||
"-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
"-c", "copy", "-tag:v", "hvc1", "-movflags", "faststart", "-y", str(cache_path)],
|
||||
check=True,
|
||||
timeout=remaining_time(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user