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:
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user