UX: Live progress panel below upload form, stable progress bar, inline preview/download
This commit is contained in:
parent
6e2a13d8a3
commit
c34e4aa376
@ -158,6 +158,17 @@
|
|||||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
code { background: var(--panel-2); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px; }
|
code { background: var(--panel-2); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.progress-bar.smooth { transition: width 0.4s ease; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -269,9 +280,22 @@
|
|||||||
Naredi reel
|
Naredi reel
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="upload-progress" class="hidden" style="margin-top: 12px;">
|
<!-- Live progress panel pod upload formo -->
|
||||||
<div class="step" id="upload-status">Nalaganje...</div>
|
<div id="live-progress" class="hidden" style="margin-top: 20px; padding: 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;">
|
||||||
<div class="progress"><div class="progress-bar" id="upload-bar"></div></div>
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
||||||
|
<div class="spinner" id="live-spinner"></div>
|
||||||
|
<div style="font-weight: 600; font-size: 14px;" id="live-stage">Nalaganje...</div>
|
||||||
|
<span class="badge processing" id="live-badge" style="margin-left: auto;">aktivno</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="margin: 8px 0;">
|
||||||
|
<div class="progress-bar" id="live-bar" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: var(--muted);" id="live-detail">Pripravljam...</div>
|
||||||
|
<div id="live-result" class="hidden" style="margin-top: 12px; display: flex; gap: 8px;">
|
||||||
|
<button class="small" id="live-download" style="display: none;">⬇ Download</button>
|
||||||
|
<button class="small ghost" id="live-preview" style="display: none;">▶ Preview</button>
|
||||||
|
</div>
|
||||||
|
<video id="live-video" class="hidden" controls style="margin-top: 12px; max-height: 400px; width: 100%; border-radius: 8px; background: black;"></video>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -352,71 +376,174 @@
|
|||||||
return parseFloat(s);
|
return parseFloat(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Live progress panel ────────────────────────
|
||||||
|
const livePanel = $("#live-progress");
|
||||||
|
const liveStage = $("#live-stage");
|
||||||
|
const liveDetail = $("#live-detail");
|
||||||
|
const liveBar = $("#live-bar");
|
||||||
|
const liveBadge = $("#live-badge");
|
||||||
|
const liveSpinner = $("#live-spinner");
|
||||||
|
const liveResult = $("#live-result");
|
||||||
|
const liveVideo = $("#live-video");
|
||||||
|
const liveDownloadBtn = $("#live-download");
|
||||||
|
const livePreviewBtn = $("#live-preview");
|
||||||
|
|
||||||
|
function showLive(stage, detail, pct, badge = "aktivno", badgeClass = "processing") {
|
||||||
|
livePanel.classList.remove("hidden");
|
||||||
|
liveStage.textContent = stage;
|
||||||
|
liveDetail.textContent = detail || "";
|
||||||
|
liveBadge.textContent = badge;
|
||||||
|
liveBadge.className = "badge " + badgeClass;
|
||||||
|
if (pct === null || pct === undefined) {
|
||||||
|
// indeterminate
|
||||||
|
liveBar.classList.add("indeterminate");
|
||||||
|
liveBar.classList.remove("smooth");
|
||||||
|
liveBar.style.width = "30%";
|
||||||
|
} else {
|
||||||
|
liveBar.classList.remove("indeterminate");
|
||||||
|
liveBar.classList.add("smooth");
|
||||||
|
liveBar.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveDone(jobId) {
|
||||||
|
liveSpinner.style.display = "none";
|
||||||
|
liveBar.classList.remove("indeterminate");
|
||||||
|
liveBar.classList.add("smooth");
|
||||||
|
liveBar.style.width = "100%";
|
||||||
|
liveStage.textContent = "✅ Končano";
|
||||||
|
liveBadge.textContent = "končano";
|
||||||
|
liveBadge.className = "badge done";
|
||||||
|
liveDetail.textContent = "Reel je pripravljen za prenos ali predogled.";
|
||||||
|
liveResult.classList.remove("hidden");
|
||||||
|
liveDownloadBtn.style.display = "inline-block";
|
||||||
|
livePreviewBtn.style.display = "inline-block";
|
||||||
|
liveDownloadBtn.onclick = () => window.open(`/api/download/${jobId}`);
|
||||||
|
livePreviewBtn.onclick = () => {
|
||||||
|
liveVideo.src = `/api/preview/${jobId}`;
|
||||||
|
liveVideo.classList.remove("hidden");
|
||||||
|
liveVideo.play();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveFail(error) {
|
||||||
|
liveSpinner.style.display = "none";
|
||||||
|
liveBar.classList.remove("indeterminate");
|
||||||
|
liveBar.style.width = "100%";
|
||||||
|
liveBar.style.background = "var(--error)";
|
||||||
|
liveStage.textContent = "❌ Napaka";
|
||||||
|
liveBadge.textContent = "napaka";
|
||||||
|
liveBadge.className = "badge failed";
|
||||||
|
liveDetail.innerHTML = `<span style="color: var(--error)">${error || "Neznana napaka"}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveReset() {
|
||||||
|
livePanel.classList.add("hidden");
|
||||||
|
liveSpinner.style.display = "block";
|
||||||
|
liveBar.style.background = "";
|
||||||
|
liveResult.classList.add("hidden");
|
||||||
|
liveDownloadBtn.style.display = "none";
|
||||||
|
livePreviewBtn.style.display = "none";
|
||||||
|
liveVideo.classList.add("hidden");
|
||||||
|
liveVideo.src = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage label mapping (server step → friendly slo + percent estimate)
|
||||||
|
const STAGE_INFO = {
|
||||||
|
"Naloženo, čaka na obdelavo": { pct: 30, friendly: "Naloženo, čaka v vrsti" },
|
||||||
|
"V vrsti za obdelavo": { pct: 35, friendly: "V vrsti za obdelavo" },
|
||||||
|
"V vrsti za YouTube prenos": { pct: 30, friendly: "V vrsti za YouTube prenos" },
|
||||||
|
"YouTube download": { pct: 45, friendly: "Prenašam z YouTube..." },
|
||||||
|
"Iščem refren (Whisper + energy)": { pct: 60, friendly: "Iščem refren v pesmi..." },
|
||||||
|
"Reframe + subtitles": { pct: 75, friendly: "Reframe v 9:16 + podnapisi..." },
|
||||||
|
"Končano": { pct: 100, friendly: "✅ Končano" },
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Submit ─────────────────────────────────────
|
// ─── Submit ─────────────────────────────────────
|
||||||
$("#submit-btn").addEventListener("click", async () => {
|
$("#submit-btn").addEventListener("click", async () => {
|
||||||
const isYT = $("#tab-youtube").classList.contains("hidden") === false;
|
const isYT = $("#tab-youtube").classList.contains("hidden") === false;
|
||||||
const settings = collectSettings();
|
const settings = collectSettings();
|
||||||
|
|
||||||
$("#submit-btn").disabled = true;
|
$("#submit-btn").disabled = true;
|
||||||
$("#upload-progress").classList.remove("hidden");
|
liveReset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isYT) {
|
if (isYT) {
|
||||||
const url = $("#yt-url").value.trim();
|
const url = $("#yt-url").value.trim();
|
||||||
if (!url) { alert("Vpiši YouTube URL"); return; }
|
if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; }
|
||||||
$("#upload-status").textContent = "Pošiljam YouTube job...";
|
showLive("Pošiljam YouTube job...", url, null);
|
||||||
const r = await fetch("/api/youtube", {
|
const r = await fetch("/api/youtube", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url, ...settings }),
|
body: JSON.stringify({ url, ...settings }),
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error("YouTube submit napaka");
|
if (!r.ok) {
|
||||||
|
const err = await r.text();
|
||||||
|
liveFail("YouTube submit napaka: " + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const job = await r.json();
|
const job = await r.json();
|
||||||
watchJob(job.id);
|
watchJob(job.id);
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
} else {
|
} else {
|
||||||
if (!pendingFile) { alert("Izberi datoteko"); return; }
|
if (!pendingFile) {
|
||||||
|
alert("Izberi datoteko");
|
||||||
|
$("#submit-btn").disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", pendingFile);
|
fd.append("file", pendingFile);
|
||||||
|
|
||||||
|
showLive("Nalaganje datoteke", `${pendingFile.name} (${(pendingFile.size / 1024 / 1024).toFixed(1)} MB)`, 0);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.upload.onprogress = e => {
|
xhr.upload.onprogress = e => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const pct = (e.loaded / e.total) * 100;
|
// Upload je 0–25% celotnega procesa
|
||||||
$("#upload-bar").style.width = pct + "%";
|
const uploadPct = (e.loaded / e.total) * 25;
|
||||||
$("#upload-status").textContent = `Nalagam... ${pct.toFixed(0)}%`;
|
const mbDone = (e.loaded / 1024 / 1024).toFixed(1);
|
||||||
|
const mbTotal = (e.total / 1024 / 1024).toFixed(1);
|
||||||
|
showLive(
|
||||||
|
"Nalaganje datoteke",
|
||||||
|
`${mbDone} / ${mbTotal} MB (${((e.loaded / e.total) * 100).toFixed(0)}%)`,
|
||||||
|
uploadPct
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onload = async () => {
|
xhr.onload = async () => {
|
||||||
if (xhr.status !== 200) {
|
if (xhr.status !== 200) {
|
||||||
alert("Upload napaka: " + xhr.responseText);
|
liveFail("Upload napaka: " + xhr.responseText);
|
||||||
$("#submit-btn").disabled = false;
|
$("#submit-btn").disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = JSON.parse(xhr.responseText);
|
const job = JSON.parse(xhr.responseText);
|
||||||
$("#upload-status").textContent = "Naloženo, začenjam obdelavo...";
|
showLive("Naloženo, začenjam obdelavo...", `Job ${job.id}`, 28);
|
||||||
const proc = await fetch("/api/process", {
|
try {
|
||||||
method: "POST",
|
const proc = await fetch("/api/process", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ job_id: job.id, ...settings }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ job_id: job.id, ...settings }),
|
||||||
if (!proc.ok) throw new Error("Process start napaka");
|
});
|
||||||
watchJob(job.id);
|
if (!proc.ok) {
|
||||||
refreshJobs();
|
liveFail("Process start napaka: " + await proc.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
watchJob(job.id);
|
||||||
|
refreshJobs();
|
||||||
|
} catch (e) {
|
||||||
|
liveFail("Process napaka: " + e.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
xhr.onerror = () => liveFail("Upload prekinjen — preveri internet povezavo");
|
||||||
|
xhr.upload.onerror = () => liveFail("Upload napaka pri prenosu");
|
||||||
xhr.open("POST", "/api/upload");
|
xhr.open("POST", "/api/upload");
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Napaka: " + e.message);
|
liveFail(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
// Submit button enable tudi če napaka, da lahko ponovno poskusi
|
||||||
$("#upload-progress").classList.add("hidden");
|
setTimeout(() => { $("#submit-btn").disabled = false; }, 500);
|
||||||
$("#submit-btn").disabled = false;
|
|
||||||
pendingFile = null;
|
|
||||||
fileInput.value = "";
|
|
||||||
dz.querySelector("div").textContent = "Klikni ali povleci video sem";
|
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -427,13 +554,40 @@
|
|||||||
try {
|
try {
|
||||||
const job = JSON.parse(e.data);
|
const job = JSON.parse(e.data);
|
||||||
updateJobInList(job);
|
updateJobInList(job);
|
||||||
|
|
||||||
|
// Update live panel
|
||||||
|
const step = job.current_step || "";
|
||||||
|
const info = STAGE_INFO[step] || { pct: null, friendly: step };
|
||||||
|
if (job.status === "done") {
|
||||||
|
liveDone(jobId);
|
||||||
|
$("#submit-btn").disabled = false;
|
||||||
|
// Reset upload form
|
||||||
|
pendingFile = null;
|
||||||
|
fileInput.value = "";
|
||||||
|
dz.querySelector("div").textContent = "Klikni ali povleci video sem";
|
||||||
|
} else if (job.status === "failed") {
|
||||||
|
liveFail(job.error || "Obdelava ni uspela");
|
||||||
|
$("#submit-btn").disabled = false;
|
||||||
|
} else {
|
||||||
|
showLive(info.friendly, `Job ${job.id} · ${job.status}`, info.pct);
|
||||||
|
}
|
||||||
|
|
||||||
if (job.status === "done" || job.status === "failed") {
|
if (job.status === "done" || job.status === "failed") {
|
||||||
evt.close();
|
evt.close();
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("SSE parse err:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
evt.onerror = () => {
|
||||||
|
evt.close();
|
||||||
|
// SSE may close at end of stream — final fetch to confirm
|
||||||
|
fetch(`/api/jobs/${jobId}`).then(r => r.json()).then(job => {
|
||||||
|
if (job.status === "done") liveDone(jobId);
|
||||||
|
else if (job.status === "failed") liveFail(job.error || "");
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
evt.onerror = () => evt.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Jobs list ──────────────────────────────────
|
// ─── Jobs list ──────────────────────────────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user