Bugs Be Ghosty

This commit is contained in:
Danny
2026-09-08 22:23:05 -07:00
parent a33bec1ca4
commit e6df2aadb8
5 changed files with 323 additions and 71 deletions
@@ -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():
+9 -4
View File
@@ -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(),
)