feat(dashcam): mobile layout fix + optional hw-encode conversion on import

- yaris.css: on mobile (<576px) hide file/size/date/upload cols, show
  filename inside thumb cell, icon-only buttons, dash-actions-td nowrap
- local-videos.js: filename in thumb cell for mobile; Converting badge with
  spinner; auto-refresh every 10s while any file is converting
- app_video.py: CONVERT_SETTING, is_convert_enabled(), queue_convert(),
  _convert_bg() — h264_v4l2m2m + aac faststart, deletes original on success
- app_dash.py: call queue_convert() after each successful copy on import
- app_settings.py: expose video_convert_enabled in get/save settings
- settings.html: toggle for 'Convert imported videos for web playback'

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 00:30:59 -04:00
co-authored by Copilot
parent dd2f95b9c1
commit 01704d5429
7 changed files with 130 additions and 18 deletions
+3
View File
@@ -520,6 +520,9 @@ def copy_files(source_folder, destination_folder, relative_paths=None):
try:
shutil.copy2(abs_path, dest_path)
copied += 1
# Queue background conversion if enabled (import app_video lazily to avoid circular import)
from modules import app_video as _app_video
_app_video.queue_convert(rel_path)
except OSError as e:
errors.append(f"{rel_path}: {e}")
+4 -3
View File
@@ -38,6 +38,7 @@ def get_settings():
return {
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
"local_video_retention_days": app_video.retention_days(),
"video_convert_enabled": app_video.is_convert_enabled(),
"cellular_enabled": _setting_bool(
"cellular_enabled", "YARIS_CELLULAR_ENABLED"
),
@@ -98,6 +99,7 @@ def update_settings(body):
values = {}
for field in (
"dashcam_upload_enabled",
"video_convert_enabled",
"cellular_enabled",
"ntfy_enabled",
"clear_ntfy_url",
@@ -182,9 +184,8 @@ def update_settings(body):
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
"1" if values["dashcam_upload_enabled"] else "0"
),
app_video.RETENTION_SETTING: str(
values["local_video_retention_days"]
),
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0",
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
"cellular_apn": apn,
"alerts_enabled": "1" if values["ntfy_enabled"] else "0",
+70 -1
View File
@@ -12,10 +12,76 @@ from modules import app_dash, app_sql
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
RETENTION_SETTING = "local_video_retention_days"
CONVERT_SETTING = "video_convert_enabled"
_thumbnail_lock = Semaphore()
# Tracks which relative paths are currently being generated (set of strings)
_generating = set()
_generating_lock = Semaphore()
_converting = set()
_converting_lock = Semaphore()
def is_convert_enabled():
return app_sql.get_setting(CONVERT_SETTING, "0") == "1"
def queue_convert(relative_path):
"""Spawn a background greenlet to re-encode relative_path to web-ready MP4.
No-op if conversion is disabled, already running, or file is already MP4."""
if not is_convert_enabled():
return
if relative_path.lower().endswith(".mp4"):
return
with _converting_lock:
if relative_path in _converting:
return
_converting.add(relative_path)
gevent.spawn(_convert_bg, relative_path)
def _convert_bg(relative_path):
try:
source_path = _local_path(relative_path)
mp4_relative = os.path.splitext(relative_path)[0] + ".mp4"
mp4_path = _local_path(mp4_relative)
if not app_dash.begin_local_video_use(relative_path):
return
try:
tmp_path = mp4_path + ".converting.mp4"
cmd = [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", source_path,
"-c:v", "h264_v4l2m2m",
"-c:a", "aac",
"-movflags", "faststart",
"-f", "mp4",
tmp_path,
]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
while proc.poll() is None:
sleep(1)
if proc.returncode == 0 and os.path.isfile(tmp_path) and os.path.getsize(tmp_path) > 0:
os.replace(tmp_path, mp4_path)
# Delete original now that MP4 is ready
try:
os.remove(source_path)
except OSError:
pass
# Also remove old thumbnail so it gets regenerated from MP4
_source, thumb = _thumbnail_path(relative_path)
if os.path.isfile(thumb):
try:
os.remove(thumb)
except OSError:
pass
elif os.path.exists(tmp_path):
os.remove(tmp_path)
finally:
app_dash.end_local_video_use(relative_path)
finally:
with _converting_lock:
_converting.discard(relative_path)
def _local_path(relative_path):
@@ -47,6 +113,8 @@ def list_local_videos():
except OSError as e:
errors.append(f"{relative_path}: {e}")
continue
with _converting_lock:
converting = relative_path in _converting
files.append(
{
"path": relative_path,
@@ -57,6 +125,7 @@ def list_local_videos():
stat.st_size,
stat.st_mtime,
) in uploaded_stats,
"converting": converting,
}
)
total_size += stat.st_size
+24 -6
View File
@@ -91,12 +91,13 @@
body.replaceChildren();
const files = result.files || [];
let uploadedCount = 0;
let convertingCount = 0;
files.forEach(function (file) {
const row = document.createElement("tr");
row.dataset.path = file.path;
// Thumbnail cell
// Thumbnail cell — also contains filename for mobile view
const preview = document.createElement("td");
preview.style.width = "9rem";
preview.style.padding = "0.25rem";
@@ -105,6 +106,12 @@
image.alt = "";
image.dataset.path = file.path;
preview.appendChild(image);
// Filename shown only on mobile (hidden on desktop via CSS)
const mobileLabel = document.createElement("div");
mobileLabel.className = "dash-thumb-name text-truncate small mt-1";
mobileLabel.style.maxWidth = "5rem";
mobileLabel.textContent = file.path.split("/").pop();
preview.appendChild(mobileLabel);
row.appendChild(preview);
thumbnailObserver.observe(image);
@@ -124,19 +131,25 @@
dateCell.className = "dash-col-date";
row.appendChild(dateCell);
// Upload badge
// Upload / convert badge
const upload = document.createElement("td");
upload.className = "dash-col-upload";
const badge = document.createElement("span");
badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
badge.textContent = file.uploaded ? "✓" : "…";
if (file.converting) {
badge.className = "badge bg-info text-dark";
badge.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Converting';
convertingCount++;
} else {
badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
badge.textContent = file.uploaded ? "✓" : "…";
}
upload.appendChild(badge);
row.appendChild(upload);
if (file.uploaded) uploadedCount++;
// Actions — wrap in div so td stays a table cell (not flex)
const actionsTd = document.createElement("td");
actionsTd.style.whiteSpace = "nowrap";
actionsTd.className = "dash-actions-td";
const actionsDiv = document.createElement("div");
actionsDiv.className = "data-list-actions";
@@ -160,12 +173,17 @@
});
summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`;
if (convertingCount > 0) summary.textContent += `; ${convertingCount} converting…`;
if (result.retention_days > 0) {
summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`;
summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`;
}
if (result.errors && result.errors.length) {
summary.textContent += ` (${result.errors.length} error(s))`;
}
// Auto-refresh every 10s while conversions are running
if (convertingCount > 0) {
setTimeout(refreshFiles, 10000);
}
}
function refreshFiles() {
+3
View File
@@ -15,6 +15,7 @@ let initialUplinkStatusLoaded = false;
const fields = {
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
retention: document.getElementById('retention-days'),
videoConvert: document.getElementById('video-convert-enabled'),
cellularEnabled: document.getElementById('cellular-enabled'),
cellularApn: document.getElementById('cellular-apn'),
ntfyEnabled: document.getElementById('ntfy-enabled'),
@@ -80,6 +81,7 @@ function populate(settings) {
const thresholds = settings.alert_thresholds;
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
fields.retention.value = settings.local_video_retention_days;
fields.videoConvert.checked = settings.video_convert_enabled;
fields.cellularEnabled.checked = settings.cellular_enabled;
fields.cellularApn.value = settings.cellular_apn;
fields.ntfyEnabled.checked = settings.ntfy_enabled;
@@ -105,6 +107,7 @@ function payload() {
return {
dashcam_upload_enabled: fields.dashcamUpload.checked,
local_video_retention_days: Number(fields.retention.value),
video_convert_enabled: fields.videoConvert.checked,
cellular_enabled: fields.cellularEnabled.checked,
cellular_apn: fields.cellularApn.value,
ntfy_enabled: fields.ntfyEnabled.checked,
+19 -8
View File
@@ -269,18 +269,29 @@ body {
/* ── Dashcam / video ─────────────────────────────────────────────────────── */
/* Hide less-important columns on mobile to keep the table usable */
/* On mobile: hide file/size/date/upload columns — only thumb+name and actions remain */
@media (max-width: 575.98px) {
.dash-col-size,
.dash-col-date,
.dash-col-upload { display: none; }
}
@media (max-width: 767.98px) {
.dash-col-file { max-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
/* Icon-only action buttons on mobile */
@media (max-width: 575.98px) {
.dash-col-upload,
.dash-col-file { display: none; }
/* Show filename inside the thumbnail cell instead */
.dash-thumb-name { display: block !important; }
/* Shrink thumbnail on mobile */
.local-video-thumbnail { width: 5rem; }
/* Icon-only action buttons */
.btn-icon-mobile .btn-label { display: none; }
/* Keep actions cell from overflowing */
.dash-actions-td { width: 1%; white-space: nowrap; }
}
@media (min-width: 576px) {
.dash-thumb-name { display: none; }
.dash-col-file { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
+7
View File
@@ -39,6 +39,13 @@
<span class="small text-muted">(0 = keep forever)</span>
</div>
<div class="invalid-feedback" data-error-for="local_video_retention_days"></div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" role="switch" id="video-convert-enabled">
<label class="form-check-label" for="video-convert-enabled">
Convert imported videos for web playback
<span class="text-muted small d-block">Re-encodes MOV→MP4 using hardware encoder in background after import. Original deleted when done.</span>
</label>
</div>
</div>
</div>