diff --git a/app.py b/app.py
index 10cdec0..d5098e6 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network
-import os, time
+import os, time, subprocess
from gevent.pywsgi import WSGIServer
@@ -150,48 +150,30 @@ def local_video_file():
except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404
- ext = os.path.splitext(path)[1].lower()
- mime_map = {".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"}
- mime = mime_map.get(ext, "video/mp4")
-
- file_size = os.path.getsize(path)
- range_header = request.headers.get("Range")
-
- if range_header:
- m = range_header.strip().replace("bytes=", "").split("-")
- byte1 = int(m[0]) if m[0] else 0
- byte2 = int(m[1]) if len(m) > 1 and m[1] else file_size - 1
- byte2 = min(byte2, file_size - 1)
- length = byte2 - byte1 + 1
-
- def generate_range():
- with open(path, "rb") as f:
- f.seek(byte1)
- remaining = length
- while remaining > 0:
- chunk = f.read(min(65536, remaining))
- if not chunk:
- break
- remaining -= len(chunk)
- yield chunk
-
- resp = Response(generate_range(), 206, mimetype=mime, direct_passthrough=True)
- resp.headers["Content-Range"] = f"bytes {byte1}-{byte2}/{file_size}"
- resp.headers["Accept-Ranges"] = "bytes"
- resp.headers["Content-Length"] = str(length)
- return resp
-
- def generate_full():
- with open(path, "rb") as f:
+ # Stream via ffmpeg: copy streams into an MP4 container with moov atom first
+ # so browsers can start playback immediately without downloading the whole file.
+ def generate_stream():
+ cmd = [
+ "ffmpeg", "-nostdin", "-loglevel", "error",
+ "-i", path,
+ "-c", "copy", # no re-encode, just remux
+ "-movflags", "frag_keyframe+empty_moov+faststart",
+ "-f", "mp4",
+ "pipe:1",
+ ]
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ try:
while True:
- chunk = f.read(65536)
+ chunk = proc.stdout.read(65536)
if not chunk:
break
yield chunk
+ finally:
+ proc.stdout.close()
+ proc.wait()
- resp = Response(generate_full(), 200, mimetype=mime, direct_passthrough=True)
- resp.headers["Accept-Ranges"] = "bytes"
- resp.headers["Content-Length"] = str(file_size)
+ resp = Response(generate_stream(), 200, mimetype="video/mp4", direct_passthrough=True)
+ resp.headers["Cache-Control"] = "no-cache"
return resp
@app.route('/api/local-video/thumbnail')
diff --git a/static/js/local-videos.js b/static/js/local-videos.js
index 22e0cc9..b419b6d 100644
--- a/static/js/local-videos.js
+++ b/static/js/local-videos.js
@@ -98,6 +98,8 @@
// Thumbnail cell
const preview = document.createElement("td");
+ preview.style.width = "9rem";
+ preview.style.padding = "0.25rem";
const image = document.createElement("img");
image.className = "local-video-thumbnail";
image.alt = "";
@@ -132,16 +134,18 @@
row.appendChild(upload);
if (file.uploaded) uploadedCount++;
- // Actions
- const actions = document.createElement("td");
- actions.className = "data-list-actions";
+ // Actions — wrap in div so td stays a table cell (not flex)
+ const actionsTd = document.createElement("td");
+ actionsTd.style.whiteSpace = "nowrap";
+ const actionsDiv = document.createElement("div");
+ actionsDiv.className = "data-list-actions";
const playButton = document.createElement("button");
playButton.type = "button";
playButton.className = "btn btn-sm btn-outline-primary btn-icon-mobile";
playButton.dataset.playPath = file.path;
playButton.innerHTML = ' Play';
- actions.appendChild(playButton);
+ actionsDiv.appendChild(playButton);
const deleteButton = document.createElement("button");
deleteButton.type = "button";
@@ -149,8 +153,9 @@
deleteButton.dataset.deleteLocalPath = file.path;
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`;
deleteButton.innerHTML = ' Del';
- actions.appendChild(deleteButton);
- row.appendChild(actions);
+ actionsDiv.appendChild(deleteButton);
+ actionsTd.appendChild(actionsDiv);
+ row.appendChild(actionsTd);
body.appendChild(row);
});