Upload panel: drag-drop multi-file queue, parallel uploads, MP4+AVI support, styled UI
This commit is contained in:
+143
-28
@@ -87,6 +87,40 @@
|
|||||||
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
|
.encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); }
|
||||||
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
|
.encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; }
|
||||||
|
|
||||||
|
/* upload panel */
|
||||||
|
.upload-toggle svg { fill: #8ab4ff; }
|
||||||
|
.upload-toggle::before { content: ""; display: block; width: 8px; height: 8px; border-radius: 50%; background: #3ea6ff; box-shadow: 0 0 8px #3ea6ff; }
|
||||||
|
.upload-body { border-top: 1px solid #1c1c1c; }
|
||||||
|
.upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||||
|
.upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); }
|
||||||
|
.upload-drop b { color: var(--accent); font-weight: 500; }
|
||||||
|
.upload-drop input { display: none; }
|
||||||
|
.upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; }
|
||||||
|
.upload-file-chip.show { display: flex; }
|
||||||
|
.upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; }
|
||||||
|
.upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; }
|
||||||
|
.upload-file-chip .chip-x:hover { color: var(--red); }
|
||||||
|
.upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; }
|
||||||
|
.upload-queue.show { display: flex; }
|
||||||
|
.q-count { color: var(--dim); font-size: 11px; padding: 2px 0; }
|
||||||
|
.q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; }
|
||||||
|
.q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.q-item .q-size { color: var(--dim); white-space: nowrap; }
|
||||||
|
.q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; }
|
||||||
|
.q-item.ok .q-state { color: #7bd97b; }
|
||||||
|
.q-item.err .q-state { color: var(--red); }
|
||||||
|
.q-item.busy .q-state { color: var(--accent); }
|
||||||
|
.q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; }
|
||||||
|
.q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; }
|
||||||
|
.upload-btn { position: relative; overflow: hidden; }
|
||||||
|
.upload-btn.busy { color: transparent; pointer-events: none; }
|
||||||
|
.upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.upload-status { transition: color .2s; }
|
||||||
|
.upload-status.ok { color: #7bd97b; }
|
||||||
|
.upload-status.err { color: var(--red); }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.page { padding: 8px; }
|
.page { padding: 8px; }
|
||||||
.ctrl-row { gap: 3px; }
|
.ctrl-row { gap: 3px; }
|
||||||
@@ -170,12 +204,16 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="encode-panel">
|
<div class="encode-panel">
|
||||||
<button class="encode-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
|
<button class="encode-toggle upload-toggle" id="upload-toggle"><svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>Upload</button>
|
||||||
<div class="encode-body" id="upload-body" style="flex-wrap:wrap">
|
<div class="encode-body upload-body" id="upload-body" style="flex-wrap:wrap;gap:14px">
|
||||||
<input type="file" id="upload-file" accept="video/mp4" style="font-size:12px;color:var(--dim)">
|
<div class="upload-drop" id="upload-drop" title="Click to browse, or drag MP4/AVI files here">
|
||||||
<span class="enc-status" style="min-width:120px">Date: <b id="upload-date">-</b></span>
|
Drop MP4/AVI files here or <b>browse</b><br><span style="font-size:11px">named HH.MM.SS.mp4 or .avi, multiple files allowed</span>
|
||||||
<button id="upload-btn" disabled>Upload</button>
|
<input type="file" id="upload-file" accept="video/mp4,video/x-msvideo,.avi" multiple>
|
||||||
<span class="enc-status" id="upload-status" style="min-width:200px">Pick an MP4 named HH.MM.SS.mp4</span>
|
</div>
|
||||||
|
<div class="upload-queue" id="upload-queue"></div>
|
||||||
|
<span class="enc-status" style="min-width:110px">Date: <b id="upload-date" style="color:var(--text)">-</b></span>
|
||||||
|
<button id="upload-btn" class="upload-btn" disabled>Upload all</button>
|
||||||
|
<span class="enc-status upload-status" id="upload-status" style="min-width:200px;text-align:left">Ready</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -531,33 +569,110 @@
|
|||||||
});
|
});
|
||||||
const uploadFile = document.querySelector("#upload-file");
|
const uploadFile = document.querySelector("#upload-file");
|
||||||
const uploadBtn = document.querySelector("#upload-btn");
|
const uploadBtn = document.querySelector("#upload-btn");
|
||||||
uploadFile.addEventListener("change", () => {
|
const uploadDrop = document.querySelector("#upload-drop");
|
||||||
uploadBtn.disabled = !uploadFile.files.length;
|
const uploadQueue = document.querySelector("#upload-queue");
|
||||||
document.querySelector("#upload-status").textContent = uploadFile.files.length
|
const uploadStatus = document.querySelector("#upload-status");
|
||||||
? `${uploadFile.files[0].name} (${(uploadFile.files[0].size / 1048576).toFixed(1)} MB)` : "Pick an MP4 named HH.MM.SS.mp4";
|
let queue = [];
|
||||||
|
function setStatus(msg, cls) {
|
||||||
|
uploadStatus.textContent = msg;
|
||||||
|
uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
function renderQueue() {
|
||||||
|
uploadQueue.replaceChildren();
|
||||||
|
uploadQueue.classList.toggle("show", queue.length > 0);
|
||||||
|
if (!queue.length) return;
|
||||||
|
const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {});
|
||||||
|
const summary = document.createElement("div");
|
||||||
|
summary.className = "q-count";
|
||||||
|
summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`;
|
||||||
|
uploadQueue.append(summary);
|
||||||
|
queue.forEach(entry => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "q-item" + (entry.state ? " " + entry.state : "");
|
||||||
|
const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name;
|
||||||
|
const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB";
|
||||||
|
const bar = document.createElement("span"); bar.className = "q-bar";
|
||||||
|
const fill = document.createElement("i"); bar.append(fill);
|
||||||
|
entry.bar = fill;
|
||||||
|
const state = document.createElement("span"); state.className = "q-state";
|
||||||
|
state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued";
|
||||||
|
row.append(name, size, bar, state);
|
||||||
|
uploadQueue.append(row);
|
||||||
});
|
});
|
||||||
uploadBtn.addEventListener("click", async () => {
|
uploadBtn.disabled = !queue.some(e => e.state === "queued");
|
||||||
const file = uploadFile.files[0];
|
}
|
||||||
if (!file) return;
|
function addFiles(files) {
|
||||||
const name = file.name;
|
for (const file of files) {
|
||||||
|
if (!/\.(mp4|avi)$/i.test(file.name)) continue;
|
||||||
|
queue.push({file, state: "queued"});
|
||||||
|
}
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
function clearDone() {
|
||||||
|
queue = queue.filter(e => e.state === "err");
|
||||||
|
renderQueue();
|
||||||
|
}
|
||||||
|
uploadDrop.addEventListener("click", () => uploadFile.click());
|
||||||
|
uploadFile.addEventListener("change", () => addFiles(uploadFile.files));
|
||||||
|
["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); }));
|
||||||
|
["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); }));
|
||||||
|
uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files));
|
||||||
|
function normName(name) {
|
||||||
const dot = name.lastIndexOf(".");
|
const dot = name.lastIndexOf(".");
|
||||||
const filename = dot > 0 ? name.slice(0, dot).padStart(8, "0").slice(-8) + name.slice(dot).toLowerCase() : name;
|
if (dot <= 0) return null;
|
||||||
if (!/^\d{2}\.\d{2}\.\d{2}\.mp4$/i.test(filename)) {
|
const base = name.slice(0, dot).padStart(8, "0").slice(-8);
|
||||||
document.querySelector("#upload-status").textContent = "Bad name: use HH.MM.SS.mp4 (e.g. 09.28.36.mp4)";
|
const ext = name.slice(dot).toLowerCase();
|
||||||
return;
|
return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null;
|
||||||
}
|
}
|
||||||
|
async function uploadOne(entry) {
|
||||||
|
const filename = normName(entry.file.name);
|
||||||
|
if (!filename) {
|
||||||
|
entry.state = "err"; entry.msg = "bad name";
|
||||||
|
renderQueue();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.state = "busy"; renderQueue();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", `/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`);
|
||||||
|
xhr.upload.addEventListener("progress", e => {
|
||||||
|
if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`;
|
||||||
|
});
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
let d = {};
|
||||||
|
try { d = JSON.parse(xhr.responseText); } catch {}
|
||||||
|
entry.state = xhr.status < 300 ? "ok" : "err";
|
||||||
|
entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40);
|
||||||
|
if (entry.bar) entry.bar.style.transform = "scaleX(1)";
|
||||||
|
renderQueue();
|
||||||
|
resolve(xhr.status < 300);
|
||||||
|
});
|
||||||
|
xhr.addEventListener("error", () => {
|
||||||
|
entry.state = "err"; entry.msg = "network";
|
||||||
|
renderQueue();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
xhr.send(entry.file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
uploadBtn.addEventListener("click", async () => {
|
||||||
|
const pending = queue.filter(e => e.state === "queued");
|
||||||
|
if (!pending.length) return;
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
document.querySelector("#upload-status").textContent = "Uploading...";
|
let okCount = 0, done = 0;
|
||||||
try {
|
const workers = Array.from({length: Math.min(3, pending.length)}, async () => {
|
||||||
const r = await fetch(`/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`, {method: "POST", body: file});
|
while (pending.length) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const entry = pending.shift();
|
||||||
document.querySelector("#upload-status").textContent = r.ok
|
if (await uploadOne(entry)) okCount++;
|
||||||
? `Stored: ${d.file}` : `Failed: ${d.message || r.status}`;
|
done++;
|
||||||
if (r.ok) { uploadFile.value = ""; loadDate(dateSelect.value); }
|
setStatus(`${done}/${pending.length + done} sent...`);
|
||||||
} catch (err) {
|
|
||||||
document.querySelector("#upload-status").textContent = "Failed: network error";
|
|
||||||
}
|
}
|
||||||
uploadBtn.disabled = !uploadFile.files.length;
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
const errs = queue.filter(e => e.state === "err").length;
|
||||||
|
setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok");
|
||||||
|
if (okCount) loadDate(dateSelect.value);
|
||||||
|
if (!errs) clearDone();
|
||||||
});
|
});
|
||||||
|
|
||||||
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
dateSelect.addEventListener("change", () => loadDate(dateSelect.value));
|
||||||
|
|||||||
@@ -163,12 +163,15 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
||||||
suffix = match.group(4).lower()
|
suffix = match.group(4).lower()
|
||||||
if suffix == "avi":
|
if suffix not in ("mp4", "avi"):
|
||||||
return self.send_error(400, "Only pre-encoded .mp4 uploads are accepted; AVI sources go through the Encoding menu")
|
return self.send_error(400, "Only .mp4 or .avi uploads are accepted")
|
||||||
|
|
||||||
target_dir = self.server.output_root / day
|
target_dir = self.server.output_root / day
|
||||||
target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.mp4"
|
if suffix == "avi":
|
||||||
temporary = target.with_suffix(".uploading.mp4")
|
# AVI sources mirror the real recorder: stored in the source tree for the Encoding menu
|
||||||
|
target_dir = self.server.source_root / day
|
||||||
|
target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.{suffix}"
|
||||||
|
temporary = target.with_suffix(f".uploading.{suffix}")
|
||||||
try:
|
try:
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
received = 0
|
received = 0
|
||||||
@@ -187,7 +190,7 @@ class PlayerHandler(BaseHTTPRequestHandler):
|
|||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
return self.send_error(500, "Failed to store upload")
|
return self.send_error(500, "Failed to store upload")
|
||||||
self.server.recordings.setdefault(day, [])
|
self.server.recordings.setdefault(day, [])
|
||||||
if not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
||||||
self.server.recordings[day].append({
|
self.server.recordings[day].append({
|
||||||
"name": target.name,
|
"name": target.name,
|
||||||
"start": hour * 3600 + minute * 60 + second,
|
"start": hour * 3600 + minute * 60 + second,
|
||||||
|
|||||||
Reference in New Issue
Block a user