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:
|
try:
|
||||||
shutil.copy2(abs_path, dest_path)
|
shutil.copy2(abs_path, dest_path)
|
||||||
copied += 1
|
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:
|
except OSError as e:
|
||||||
errors.append(f"{rel_path}: {e}")
|
errors.append(f"{rel_path}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ def get_settings():
|
|||||||
return {
|
return {
|
||||||
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
|
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
|
||||||
"local_video_retention_days": app_video.retention_days(),
|
"local_video_retention_days": app_video.retention_days(),
|
||||||
|
"video_convert_enabled": app_video.is_convert_enabled(),
|
||||||
"cellular_enabled": _setting_bool(
|
"cellular_enabled": _setting_bool(
|
||||||
"cellular_enabled", "YARIS_CELLULAR_ENABLED"
|
"cellular_enabled", "YARIS_CELLULAR_ENABLED"
|
||||||
),
|
),
|
||||||
@@ -98,6 +99,7 @@ def update_settings(body):
|
|||||||
values = {}
|
values = {}
|
||||||
for field in (
|
for field in (
|
||||||
"dashcam_upload_enabled",
|
"dashcam_upload_enabled",
|
||||||
|
"video_convert_enabled",
|
||||||
"cellular_enabled",
|
"cellular_enabled",
|
||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
"clear_ntfy_url",
|
"clear_ntfy_url",
|
||||||
@@ -182,9 +184,8 @@ def update_settings(body):
|
|||||||
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
|
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
|
||||||
"1" if values["dashcam_upload_enabled"] else "0"
|
"1" if values["dashcam_upload_enabled"] else "0"
|
||||||
),
|
),
|
||||||
app_video.RETENTION_SETTING: str(
|
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
|
||||||
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_enabled": "1" if values["cellular_enabled"] else "0",
|
||||||
"cellular_apn": apn,
|
"cellular_apn": apn,
|
||||||
"alerts_enabled": "1" if values["ntfy_enabled"] else "0",
|
"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__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
|
THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails")
|
||||||
RETENTION_SETTING = "local_video_retention_days"
|
RETENTION_SETTING = "local_video_retention_days"
|
||||||
|
CONVERT_SETTING = "video_convert_enabled"
|
||||||
_thumbnail_lock = Semaphore()
|
_thumbnail_lock = Semaphore()
|
||||||
# Tracks which relative paths are currently being generated (set of strings)
|
|
||||||
_generating = set()
|
_generating = set()
|
||||||
_generating_lock = Semaphore()
|
_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):
|
def _local_path(relative_path):
|
||||||
@@ -47,6 +113,8 @@ def list_local_videos():
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
errors.append(f"{relative_path}: {e}")
|
errors.append(f"{relative_path}: {e}")
|
||||||
continue
|
continue
|
||||||
|
with _converting_lock:
|
||||||
|
converting = relative_path in _converting
|
||||||
files.append(
|
files.append(
|
||||||
{
|
{
|
||||||
"path": relative_path,
|
"path": relative_path,
|
||||||
@@ -57,6 +125,7 @@ def list_local_videos():
|
|||||||
stat.st_size,
|
stat.st_size,
|
||||||
stat.st_mtime,
|
stat.st_mtime,
|
||||||
) in uploaded_stats,
|
) in uploaded_stats,
|
||||||
|
"converting": converting,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
total_size += stat.st_size
|
total_size += stat.st_size
|
||||||
|
|||||||
@@ -91,12 +91,13 @@
|
|||||||
body.replaceChildren();
|
body.replaceChildren();
|
||||||
const files = result.files || [];
|
const files = result.files || [];
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
|
let convertingCount = 0;
|
||||||
|
|
||||||
files.forEach(function (file) {
|
files.forEach(function (file) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
row.dataset.path = file.path;
|
row.dataset.path = file.path;
|
||||||
|
|
||||||
// Thumbnail cell
|
// Thumbnail cell — also contains filename for mobile view
|
||||||
const preview = document.createElement("td");
|
const preview = document.createElement("td");
|
||||||
preview.style.width = "9rem";
|
preview.style.width = "9rem";
|
||||||
preview.style.padding = "0.25rem";
|
preview.style.padding = "0.25rem";
|
||||||
@@ -105,6 +106,12 @@
|
|||||||
image.alt = "";
|
image.alt = "";
|
||||||
image.dataset.path = file.path;
|
image.dataset.path = file.path;
|
||||||
preview.appendChild(image);
|
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);
|
row.appendChild(preview);
|
||||||
thumbnailObserver.observe(image);
|
thumbnailObserver.observe(image);
|
||||||
|
|
||||||
@@ -124,19 +131,25 @@
|
|||||||
dateCell.className = "dash-col-date";
|
dateCell.className = "dash-col-date";
|
||||||
row.appendChild(dateCell);
|
row.appendChild(dateCell);
|
||||||
|
|
||||||
// Upload badge
|
// Upload / convert badge
|
||||||
const upload = document.createElement("td");
|
const upload = document.createElement("td");
|
||||||
upload.className = "dash-col-upload";
|
upload.className = "dash-col-upload";
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
|
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.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
|
||||||
badge.textContent = file.uploaded ? "✓" : "…";
|
badge.textContent = file.uploaded ? "✓" : "…";
|
||||||
|
}
|
||||||
upload.appendChild(badge);
|
upload.appendChild(badge);
|
||||||
row.appendChild(upload);
|
row.appendChild(upload);
|
||||||
if (file.uploaded) uploadedCount++;
|
if (file.uploaded) uploadedCount++;
|
||||||
|
|
||||||
// Actions — wrap in div so td stays a table cell (not flex)
|
// Actions — wrap in div so td stays a table cell (not flex)
|
||||||
const actionsTd = document.createElement("td");
|
const actionsTd = document.createElement("td");
|
||||||
actionsTd.style.whiteSpace = "nowrap";
|
actionsTd.className = "dash-actions-td";
|
||||||
const actionsDiv = document.createElement("div");
|
const actionsDiv = document.createElement("div");
|
||||||
actionsDiv.className = "data-list-actions";
|
actionsDiv.className = "data-list-actions";
|
||||||
|
|
||||||
@@ -160,12 +173,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`;
|
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) {
|
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) {
|
if (result.errors && result.errors.length) {
|
||||||
summary.textContent += ` (${result.errors.length} error(s))`;
|
summary.textContent += ` (${result.errors.length} error(s))`;
|
||||||
}
|
}
|
||||||
|
// Auto-refresh every 10s while conversions are running
|
||||||
|
if (convertingCount > 0) {
|
||||||
|
setTimeout(refreshFiles, 10000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshFiles() {
|
function refreshFiles() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let initialUplinkStatusLoaded = false;
|
|||||||
const fields = {
|
const fields = {
|
||||||
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
||||||
retention: document.getElementById('retention-days'),
|
retention: document.getElementById('retention-days'),
|
||||||
|
videoConvert: document.getElementById('video-convert-enabled'),
|
||||||
cellularEnabled: document.getElementById('cellular-enabled'),
|
cellularEnabled: document.getElementById('cellular-enabled'),
|
||||||
cellularApn: document.getElementById('cellular-apn'),
|
cellularApn: document.getElementById('cellular-apn'),
|
||||||
ntfyEnabled: document.getElementById('ntfy-enabled'),
|
ntfyEnabled: document.getElementById('ntfy-enabled'),
|
||||||
@@ -80,6 +81,7 @@ function populate(settings) {
|
|||||||
const thresholds = settings.alert_thresholds;
|
const thresholds = settings.alert_thresholds;
|
||||||
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
||||||
fields.retention.value = settings.local_video_retention_days;
|
fields.retention.value = settings.local_video_retention_days;
|
||||||
|
fields.videoConvert.checked = settings.video_convert_enabled;
|
||||||
fields.cellularEnabled.checked = settings.cellular_enabled;
|
fields.cellularEnabled.checked = settings.cellular_enabled;
|
||||||
fields.cellularApn.value = settings.cellular_apn;
|
fields.cellularApn.value = settings.cellular_apn;
|
||||||
fields.ntfyEnabled.checked = settings.ntfy_enabled;
|
fields.ntfyEnabled.checked = settings.ntfy_enabled;
|
||||||
@@ -105,6 +107,7 @@ function payload() {
|
|||||||
return {
|
return {
|
||||||
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
||||||
local_video_retention_days: Number(fields.retention.value),
|
local_video_retention_days: Number(fields.retention.value),
|
||||||
|
video_convert_enabled: fields.videoConvert.checked,
|
||||||
cellular_enabled: fields.cellularEnabled.checked,
|
cellular_enabled: fields.cellularEnabled.checked,
|
||||||
cellular_apn: fields.cellularApn.value,
|
cellular_apn: fields.cellularApn.value,
|
||||||
ntfy_enabled: fields.ntfyEnabled.checked,
|
ntfy_enabled: fields.ntfyEnabled.checked,
|
||||||
|
|||||||
+19
-8
@@ -269,18 +269,29 @@ body {
|
|||||||
|
|
||||||
/* ── Dashcam / video ─────────────────────────────────────────────────────── */
|
/* ── 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) {
|
@media (max-width: 575.98px) {
|
||||||
.dash-col-size,
|
.dash-col-size,
|
||||||
.dash-col-date,
|
.dash-col-date,
|
||||||
.dash-col-upload { display: none; }
|
.dash-col-upload,
|
||||||
}
|
.dash-col-file { display: none; }
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
.dash-col-file { max-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
/* Show filename inside the thumbnail cell instead */
|
||||||
}
|
.dash-thumb-name { display: block !important; }
|
||||||
/* Icon-only action buttons on mobile */
|
|
||||||
@media (max-width: 575.98px) {
|
/* Shrink thumbnail on mobile */
|
||||||
|
.local-video-thumbnail { width: 5rem; }
|
||||||
|
|
||||||
|
/* Icon-only action buttons */
|
||||||
.btn-icon-mobile .btn-label { display: none; }
|
.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>
|
<span class="small text-muted">(0 = keep forever)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="invalid-feedback" data-error-for="local_video_retention_days"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user