- app.py: replace send_file with manual Range-aware chunked generator;
returns 206 on partial requests so browsers can seek/buffer properly.
Thumbnail route returns 202 while ffmpeg is generating.
- app_video.py: thumbnail generation moved to background gevent greenlet
(_generate_thumbnail_bg). get_thumbnail_path returns immediately with
RuntimeError('generating') if not cached; ffmpeg only ever runs once
per video (result cached in dash/thumbnails/).
- dash.html: replaced bottom card player with Bootstrap modal (modal-xl,
centered, dark bg) — video pops up in place regardless of scroll position.
- local-videos.js: rewritten to use Bootstrap modal; thumbnail loader
polls with fetch() and retries on 202 every 2s (up to 15x) instead of
setting img.src directly (which can't detect 202).
Co-authored-by: Copilot <[email protected]>
370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const summary = document.getElementById("local-video-summary");
|
|
const body = document.querySelector("#local-video-list [data-data-list-body]");
|
|
const refreshButton = document.getElementById("local-videos-refresh-btn");
|
|
let playingPath = null;
|
|
|
|
// ── Modal player ──────────────────────────────────────────────────────────
|
|
const modalEl = document.getElementById("video-player-modal");
|
|
const modalTitle = document.getElementById("video-player-modal-title");
|
|
const player = document.getElementById("local-video-player");
|
|
const bsModal = new bootstrap.Modal(modalEl);
|
|
|
|
modalEl.addEventListener("hidden.bs.modal", function () {
|
|
player.pause();
|
|
player.removeAttribute("src");
|
|
player.load();
|
|
playingPath = null;
|
|
});
|
|
|
|
function playVideo(path) {
|
|
playingPath = path;
|
|
modalTitle.textContent = path.split("/").pop();
|
|
player.src = `/api/local-video/file?path=${encodeURIComponent(path)}`;
|
|
bsModal.show();
|
|
player.load();
|
|
player.play().catch(function () {});
|
|
}
|
|
|
|
// ── Thumbnail loading with retry on 202 ──────────────────────────────────
|
|
const THUMB_RETRY_MS = 2000;
|
|
const THUMB_MAX_RETRIES = 15;
|
|
|
|
function loadThumbnail(image) {
|
|
const url = `/api/local-video/thumbnail?path=${encodeURIComponent(image.dataset.path)}`;
|
|
let attempts = 0;
|
|
|
|
function attempt() {
|
|
fetch(url)
|
|
.then(function (resp) {
|
|
if (resp.status === 202 && attempts < THUMB_MAX_RETRIES) {
|
|
attempts++;
|
|
setTimeout(attempt, THUMB_RETRY_MS);
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
image.hidden = true;
|
|
return;
|
|
}
|
|
return resp.blob().then(function (blob) {
|
|
image.src = URL.createObjectURL(blob);
|
|
});
|
|
})
|
|
.catch(function () {
|
|
image.hidden = true;
|
|
});
|
|
}
|
|
attempt();
|
|
}
|
|
|
|
const thumbnailObserver = new IntersectionObserver(function (entries, observer) {
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
observer.unobserve(entry.target);
|
|
loadThumbnail(entry.target);
|
|
});
|
|
}, { rootMargin: "200px" });
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
function fetchJson(url, options) {
|
|
return fetch(url, options).then(function (response) {
|
|
return response.json().then(function (data) {
|
|
if (!response.ok) {
|
|
throw new Error(data.message || data.error || data.status || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
});
|
|
});
|
|
}
|
|
|
|
function textCell(text) {
|
|
const cell = document.createElement("td");
|
|
cell.textContent = text;
|
|
return cell;
|
|
}
|
|
|
|
// ── File list rendering ───────────────────────────────────────────────────
|
|
function renderFiles(result) {
|
|
thumbnailObserver.disconnect();
|
|
body.replaceChildren();
|
|
const files = result.files || [];
|
|
let uploadedCount = 0;
|
|
|
|
files.forEach(function (file) {
|
|
const row = document.createElement("tr");
|
|
row.dataset.path = file.path;
|
|
|
|
// Thumbnail cell
|
|
const preview = document.createElement("td");
|
|
const image = document.createElement("img");
|
|
image.className = "local-video-thumbnail";
|
|
image.alt = "";
|
|
image.dataset.path = file.path;
|
|
preview.appendChild(image);
|
|
row.appendChild(preview);
|
|
thumbnailObserver.observe(image);
|
|
|
|
// File name
|
|
const nameCell = document.createElement("td");
|
|
nameCell.className = "dash-col-file";
|
|
nameCell.textContent = file.path;
|
|
row.appendChild(nameCell);
|
|
|
|
// Size
|
|
const sizeCell = textCell(YarisUI.formatBytes(file.size));
|
|
sizeCell.className = "dash-col-size";
|
|
row.appendChild(sizeCell);
|
|
|
|
// Date
|
|
const dateCell = textCell(new Date(file.modified_at * 1000).toLocaleString());
|
|
dateCell.className = "dash-col-date";
|
|
row.appendChild(dateCell);
|
|
|
|
// Upload 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 ? "✓" : "…";
|
|
upload.appendChild(badge);
|
|
row.appendChild(upload);
|
|
if (file.uploaded) uploadedCount++;
|
|
|
|
// Actions
|
|
const actions = document.createElement("td");
|
|
actions.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 = '<i class="fas fa-play"></i><span class="btn-label"> Play</span>';
|
|
actions.appendChild(playButton);
|
|
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.type = "button";
|
|
deleteButton.className = "btn btn-sm btn-outline-danger btn-icon-mobile";
|
|
deleteButton.dataset.deleteLocalPath = file.path;
|
|
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"?`;
|
|
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Del</span>';
|
|
actions.appendChild(deleteButton);
|
|
row.appendChild(actions);
|
|
body.appendChild(row);
|
|
});
|
|
|
|
summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`;
|
|
if (result.retention_days > 0) {
|
|
summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`;
|
|
}
|
|
if (result.errors && result.errors.length) {
|
|
summary.textContent += ` (${result.errors.length} error(s))`;
|
|
}
|
|
}
|
|
|
|
function refreshFiles() {
|
|
refreshButton.disabled = true;
|
|
summary.textContent = "Loading...";
|
|
return fetchJson("/api/local-videos")
|
|
.then(renderFiles)
|
|
.catch(function (error) {
|
|
body.replaceChildren();
|
|
summary.textContent = `Unable to list local videos: ${error.message}`;
|
|
})
|
|
.finally(function () {
|
|
refreshButton.disabled = false;
|
|
});
|
|
}
|
|
|
|
function deleteVideo(path) {
|
|
return fetchJson("/api/local-video", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
}).then(function () {
|
|
if (playingPath === path) bsModal.hide();
|
|
return refreshFiles();
|
|
}).catch(function (error) {
|
|
summary.textContent = `Local delete failed: ${error.message}`;
|
|
});
|
|
}
|
|
|
|
body.addEventListener("click", function (event) {
|
|
const playButton = event.target.closest("[data-play-path]");
|
|
if (playButton) {
|
|
playVideo(playButton.dataset.playPath);
|
|
return;
|
|
}
|
|
const deleteButton = event.target.closest("[data-delete-local-path]");
|
|
if (deleteButton) {
|
|
if (!confirm(deleteButton.dataset.confirm)) return;
|
|
deleteButton.disabled = true;
|
|
deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () {
|
|
deleteButton.disabled = false;
|
|
});
|
|
}
|
|
});
|
|
|
|
refreshButton.addEventListener("click", refreshFiles);
|
|
refreshFiles();
|
|
}());
|
|
|
|
|
|
function fetchJson(url, options) {
|
|
return fetch(url, options).then(function (response) {
|
|
return response.json().then(function (data) {
|
|
if (!response.ok) {
|
|
throw new Error(data.message || data.error || data.status || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
});
|
|
});
|
|
}
|
|
|
|
function textCell(text) {
|
|
const cell = document.createElement("td");
|
|
cell.textContent = text;
|
|
return cell;
|
|
}
|
|
|
|
function closePlayer() {
|
|
player.pause();
|
|
player.removeAttribute("src");
|
|
player.load();
|
|
playerPanel.hidden = true;
|
|
playingPath = null;
|
|
}
|
|
|
|
function playVideo(path) {
|
|
playingPath = path;
|
|
playerTitle.textContent = path;
|
|
player.src = `/api/local-video/file?path=${encodeURIComponent(path)}`;
|
|
playerPanel.hidden = false;
|
|
player.load();
|
|
player.play().catch(function () {
|
|
// Browser autoplay policy may require the user to press play.
|
|
});
|
|
playerPanel.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
|
|
function renderFiles(result) {
|
|
thumbnailObserver.disconnect();
|
|
body.replaceChildren();
|
|
const files = result.files || [];
|
|
let uploadedCount = 0;
|
|
|
|
files.forEach(function (file) {
|
|
const row = document.createElement("tr");
|
|
row.dataset.path = file.path;
|
|
row.dataset.size = file.size;
|
|
row.dataset.modified = file.modified_at;
|
|
row.dataset.uploaded = file.uploaded ? "1" : "0";
|
|
|
|
const preview = document.createElement("td");
|
|
const image = document.createElement("img");
|
|
image.className = "local-video-thumbnail";
|
|
image.alt = `Preview of ${file.path}`;
|
|
image.loading = "lazy";
|
|
image.dataset.path = file.path;
|
|
image.addEventListener("error", function () {
|
|
image.hidden = true;
|
|
});
|
|
preview.appendChild(image);
|
|
row.appendChild(preview);
|
|
thumbnailObserver.observe(image);
|
|
|
|
row.appendChild(textCell(file.path));
|
|
row.appendChild(textCell(YarisUI.formatBytes(file.size)));
|
|
row.appendChild(textCell(new Date(file.modified_at * 1000).toLocaleString()));
|
|
|
|
const upload = document.createElement("td");
|
|
const badge = document.createElement("span");
|
|
badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark";
|
|
badge.textContent = file.uploaded ? "Uploaded" : "Pending";
|
|
upload.appendChild(badge);
|
|
row.appendChild(upload);
|
|
if (file.uploaded) {
|
|
uploadedCount += 1;
|
|
}
|
|
|
|
const actions = document.createElement("td");
|
|
actions.className = "data-list-actions";
|
|
const playButton = document.createElement("button");
|
|
playButton.type = "button";
|
|
playButton.className = "btn btn-sm btn-outline-primary";
|
|
playButton.dataset.playPath = file.path;
|
|
playButton.innerHTML = '<i class="fas fa-play me-1"></i>Play';
|
|
actions.appendChild(playButton);
|
|
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.type = "button";
|
|
deleteButton.className = "btn btn-sm btn-outline-danger";
|
|
deleteButton.dataset.deleteLocalPath = file.path;
|
|
deleteButton.dataset.confirm = `Delete the local Pi copy of "${file.path}"? The remote archive will not be changed.`;
|
|
deleteButton.innerHTML = '<i class="fas fa-trash me-1"></i>Delete local';
|
|
actions.appendChild(deleteButton);
|
|
row.appendChild(actions);
|
|
body.appendChild(row);
|
|
});
|
|
|
|
summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`;
|
|
if (result.retention_days > 0) {
|
|
summary.textContent += `; uploaded copies older than ${result.retention_days} day(s) are auto-deleted`;
|
|
} else {
|
|
summary.textContent += "; automatic retention cleanup is off";
|
|
}
|
|
if (result.errors && result.errors.length) {
|
|
summary.textContent += ` (${result.errors.length} file error(s))`;
|
|
}
|
|
}
|
|
|
|
function refreshFiles() {
|
|
refreshButton.disabled = true;
|
|
summary.textContent = "Loading...";
|
|
return fetchJson("/api/local-videos")
|
|
.then(renderFiles)
|
|
.catch(function (error) {
|
|
body.replaceChildren();
|
|
summary.textContent = `Unable to list local videos: ${error.message}`;
|
|
})
|
|
.finally(function () {
|
|
refreshButton.disabled = false;
|
|
});
|
|
}
|
|
|
|
function deleteVideo(path) {
|
|
return fetchJson("/api/local-video", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
}).then(function () {
|
|
if (playingPath === path) {
|
|
closePlayer();
|
|
}
|
|
return refreshFiles();
|
|
}).catch(function (error) {
|
|
summary.textContent = `Local delete failed: ${error.message}`;
|
|
});
|
|
}
|
|
|
|
body.addEventListener("click", function (event) {
|
|
const playButton = event.target.closest("[data-play-path]");
|
|
if (playButton) {
|
|
playVideo(playButton.dataset.playPath);
|
|
return;
|
|
}
|
|
|
|
const deleteButton = event.target.closest("[data-delete-local-path]");
|
|
if (deleteButton) {
|
|
deleteButton.disabled = true;
|
|
deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () {
|
|
deleteButton.disabled = false;
|
|
});
|
|
}
|
|
});
|
|
refreshButton.addEventListener("click", refreshFiles);
|
|
closePlayerButton.addEventListener("click", closePlayer);
|
|
refreshFiles();
|
|
}());
|