reels-app/templates/index.html
Sebastjan Artič 02fbae7c4f Edit: triangles INSIDE trim bar (overflow:hidden was clipping them)
Bug: triangles positioned at top:-14px were outside trim bar bounds.
Trim bar has overflow:hidden, so triangles were clipped (invisible).

Fix: top:0 (inside trim bar, at the very top edge).
Triangle 14px tall now sits at top of trim bar (overlapping waveform
slightly but visible, with drop-shadow to make them stand out).
2026-04-30 13:57:09 +00:00

1590 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="sl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reels Clipper · biba.live</title>
<style>
:root {
--bg: #0d0e12;
--panel: #1a1c24;
--panel-2: #232631;
--border: #2d3142;
--text: #e6e8ed;
--muted: #8a8fa3;
--accent: #DC1C4C;
--accent-2: #ff3a6e;
--success: #3ec98f;
--warn: #f0b03b;
--error: #ef4444;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
header {
padding: 24px 32px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
}
header h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
}
.accent-mark {
display: inline-block;
background: var(--accent);
padding: 2px 8px;
border-radius: 4px;
font-weight: 800;
color: white;
margin-right: 4px;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 32px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
main > section.card:first-of-type {
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
@media (max-width: 800px) {
main { grid-template-columns: 1fr; }
main > section.card:first-of-type {
position: static;
max-height: none;
}
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.card h2 {
margin: 0 0 16px;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
}
.dropzone:hover, .dropzone.drag {
border-color: var(--accent);
background: rgba(220, 28, 76, 0.05);
}
.dropzone svg { width: 48px; height: 48px; opacity: 0.5; margin-bottom: 8px; }
.dropzone .small { color: var(--muted); font-size: 13px; }
input[type="text"], input[type="url"], select, input[type="number"] {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; margin-top: 12px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
button {
background: var(--accent);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
button:hover { background: var(--accent-2); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
button.ghost:hover { background: var(--panel-2); color: var(--text); }
button.small { padding: 6px 12px; font-size: 12px; }
.full-width { grid-column: 1 / -1; }
.jobs-list { display: flex; flex-direction: column; gap: 10px; }
.job {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.job-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.job-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge { padding: 3px 10px; border-radius: 99px; font-size: 11px; font-weight: 600; }
.badge.queued { background: rgba(138, 143, 163, 0.15); color: var(--muted); }
.badge.processing, .badge.downloading { background: rgba(240, 176, 59, 0.15); color: var(--warn); }
.badge.done { background: rgba(62, 201, 143, 0.15); color: var(--success); }
.badge.failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.badge.uploaded { background: rgba(220, 28, 76, 0.15); color: var(--accent); }
.progress { height: 4px; background: var(--border); border-radius: 99px; overflow: hidden; }
.progress-bar { height: 100%; background: var(--accent); width: 0%; transition: width 0.3s; }
.progress-bar.indeterminate {
width: 30%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.step { font-size: 12px; color: var(--muted); }
.meta { font-size: 11px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.error-text { color: var(--error); font-size: 12px; }
video { width: 100%; max-height: 400px; border-radius: 8px; background: black; }
.empty { color: var(--muted); text-align: center; padding: 40px 20px; font-size: 14px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 13px; }
.toggle input { width: auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.tab { padding: 10px 14px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; font-size: 14px; }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.hidden { display: none !important; }
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; }
/* ─── Video preview modal ─── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-content {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.modal-content video {
max-width: 100%;
max-height: 85vh;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
background: black;
}
.modal-title {
color: #fff;
font-weight: 600;
text-align: center;
max-width: 600px;
padding: 0 12px;
font-size: 14px;
}
.modal-close {
position: absolute;
top: -8px;
right: -8px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: white;
border: none;
font-size: 20px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: transform 0.15s ease;
z-index: 1;
}
.modal-close:hover { transform: scale(1.1); background: var(--accent-2); }
.modal-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.modal-actions button {
padding: 10px 18px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font-weight: 500;
cursor: pointer;
font-size: 14px;
}
.modal-actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.modal-actions button:hover { background: var(--panel-2); }
.modal-actions button.primary:hover { background: var(--accent-2); }
/* ─── Multi-file queue ─── */
.file-queue {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-queue-item {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.file-queue-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-queue-item .name b { color: var(--accent-2); }
.file-queue-item .size {
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.file-queue-item .remove {
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
.file-queue-item .remove:hover { color: var(--error); }
.file-queue-item .warn {
color: var(--warn);
font-size: 10px;
}
</style>
</head>
<body>
<header>
<h1><span class="accent-mark">1]</span> reels clipper</h1>
<span style="color: var(--muted); font-size: 13px;">biba.live</span>
</header>
<main>
<!-- ─── INPUT ───────────────────────────────────── -->
<section class="card">
<h2>nov reel</h2>
<div class="tabs">
<div class="tab active" data-tab="upload">Upload</div>
<div class="tab" data-tab="youtube">YouTube</div>
</div>
<div id="tab-upload">
<div class="dropzone" id="dropzone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="dz-text">Klikni ali povleci video sem</div>
<div class="small dz-hint">.mp4, .mov, .webm, .mxf, .mpg — do 10 GB · <b>Lahko izberete več datotek hkrati</b></div>
<input type="file" id="file-input" accept="video/*,.mxf,.mpg,.mpeg,.ts,.m2ts,.mts" multiple style="display:none">
</div>
<div id="file-queue" class="file-queue"></div>
</div>
<div id="tab-youtube" class="hidden">
<label>YouTube URL</label>
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
</div>
<label>Način reframe</label>
<select id="mode">
<option value="track">Track (sledi obrazu — intervjuji, vlogi)</option>
<option value="center">Center (statična kamera)</option>
<option value="blur">Blur (glasba, koncerti)</option>
</select>
<!-- Skrita polja: jezik in model sta avto. Vrednosti uporabljene v JS submit. -->
<input type="hidden" id="lang" value="">
<input type="hidden" id="model" value="large-v3">
<div style="font-size: 12px; color: var(--text-dim); margin-top: 8px;">
🤖 Jezik: avtomatsko zaznan (Whisper, 3-sample voting) · Model: medium · LLM analiza: Claude
</div>
<label class="toggle" style="margin-top: 16px;">
<input type="checkbox" id="auto-chorus" checked>
Pametna izbira odseka (Whisper + energy → najde refren)
</label>
<div style="font-size: 12px; color: var(--text-dim); margin-top: 4px; margin-left: 26px;">
Sistem naredi <b>celoten transkript</b> in <b>energy profile</b>, najde refren in ga izreže.
Audio fade in/out je avtomatsko dodan na meje vokala.
</div>
<label class="toggle" style="margin-top: 12px; margin-left: 26px;">
<input type="checkbox" id="include-prebuild">
Vključi pre-chorus (build-up pred refrenom)
</label>
<div style="font-size: 12px; color: var(--text-dim); margin-top: 2px; margin-left: 52px;">
Privzeto izklopljeno: dobiš čist refren brez kitice.
</div>
<div id="manual-times" class="row hidden">
<div>
<label>Začetek (sekunde ali mm:ss)</label>
<input type="text" id="start" placeholder="npr. 1:24">
</div>
<div>
<label>Trajanje (s)</label>
<input type="number" id="duration" value="30" min="5" max="180">
</div>
</div>
<div class="row">
<div>
<label>Kvaliteta</label>
<select id="quality">
<option value="fast">Fast (preview)</option>
<option value="medium" selected>Medium (objava)</option>
<option value="high">High (arhiv)</option>
</select>
</div>
<div>
<label>AI za analizo (izbere refren)</label>
<select id="llm-provider">
<option value="claude" selected>Claude Sonnet 4.6 (priporočeno)</option>
<option value="gemini">Gemini 3.1 Pro (multilingual)</option>
<option value="auto">Auto</option>
</select>
</div>
</div>
<!-- Skriti subtitle-style za backward compat -->
<select id="subtitle-style" style="display:none">
<option value="reels" selected>Reels</option>
</select>
<label class="toggle" style="margin-top: 12px;">
<input type="checkbox" id="no-subs" checked>
Brez podnapisov (privzeto — bolj zanesljivo)
</label>
<button id="submit-btn" class="full-width" style="margin-top: 20px; width: 100%;">
Naredi reel
</button>
<!-- Live progress panel pod upload formo -->
<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 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>
<!-- Analysis summary z izbranim odsekom in transkriptom -->
<div id="live-analysis" class="hidden" style="margin-top: 12px; padding: 10px; background: var(--panel); border-radius: 6px; font-size: 12px;">
<div id="live-analysis-summary" style="margin-bottom: 8px; color: var(--text-dim);"></div>
<details style="margin-top: 6px;">
<summary style="cursor: pointer; color: var(--accent); font-weight: 600;">Pokaži celoten transkript</summary>
<div id="live-transcript" style="margin-top: 8px; max-height: 240px; overflow-y: auto; font-family: monospace; font-size: 11px; line-height: 1.6;"></div>
</details>
</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>
</div>
</section>
<!-- ─── JOBS ────────────────────────────────────── -->
<section class="card">
<h2>moji reels</h2>
<div class="jobs-list" id="jobs-list">
<div class="empty">Še ni obdelav</div>
</div>
</section>
</main>
<script>
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// ─── Tabs ───────────────────────────────────────
$$(".tab").forEach(t => {
t.addEventListener("click", () => {
$$(".tab").forEach(x => x.classList.remove("active"));
t.classList.add("active");
const target = t.dataset.tab;
$("#tab-upload").classList.toggle("hidden", target !== "upload");
$("#tab-youtube").classList.toggle("hidden", target !== "youtube");
});
});
// ─── Auto-chorus toggle ─────────────────────────
$("#auto-chorus").addEventListener("change", e => {
$("#manual-times").classList.toggle("hidden", e.target.checked);
});
// Jezik in model sta avto — skritja polja, ne potrebujemo listenerjev.
// ─── Drag & drop ────────────────────────────────
const dz = $("#dropzone");
const fileInput = $("#file-input");
let pendingFiles = []; // array namesto single file
dz.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files.length > 0) {
addFilesToQueue([...fileInput.files]);
}
fileInput.value = "";
});
["dragover", "dragenter"].forEach(ev =>
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add("drag"); }));
["dragleave", "drop"].forEach(ev =>
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove("drag"); }));
dz.addEventListener("drop", e => {
if (e.dataTransfer.files.length > 0) {
addFilesToQueue([...e.dataTransfer.files]);
}
});
// Klient-side parser (mora ustrezati backend parse_artist_title)
function parseArtistTitle(filename) {
if (!filename) return [null, null];
let name = filename.replace(/\.[^.]+$/, ""); // remove ext
// Odstrani noise
const noise = [
/\(Official\s+(?:Music\s+)?Video\)/gi,
/\(Officia[lk]\s+Audio\)/gi,
/\(Offizielles\s+(?:Musik)?[Vv]ideo\)/gi,
/\(Lyric[s]?\s+Video\)/gi,
/\(Audio\)/gi,
/\(HD\)|\(HQ\)|\(4K\)/gi,
/\(Live\)|\(Remix\)|\(Remaster(?:ed)?\s*\d{0,4}\)/gi,
/\[Official.*?\]|\[Music.*?\]|\[Audio.*?\]/gi,
/\bofficial\s+video\b|\bofficial\s+audio\b/gi,
/\boriginal\s+(?:video|audio)\b/gi,
/\bMV\b|\b4K\b|\bHD\b|\bHQ\b/g,
];
for (const r of noise) name = name.replace(r, "");
name = name.replace(/\s+/g, " ").trim();
// Probaj separatorje
for (const sep of [" - ", " ", " — ", " | ", " : "]) {
if (name.includes(sep)) {
const parts = name.split(sep);
if (parts.length >= 2) {
const artist = parts[0].trim().replace(/^[\s\-–—|.:_]+|[\s\-–—|.:_]+$/g, "");
const title = parts.slice(1).join(sep).trim().replace(/^[\s\-–—|.:_]+|[\s\-–—|.:_]+$/g, "");
if (artist && title) return [artist, title];
}
}
}
return [null, null];
}
function addFilesToQueue(files) {
for (const f of files) {
const [artist, title] = parseArtistTitle(f.name);
pendingFiles.push({ file: f, artist, title });
}
renderFileQueue();
}
function removeFromQueue(idx) {
pendingFiles.splice(idx, 1);
renderFileQueue();
}
function renderFileQueue() {
const q = $("#file-queue");
if (!q) return;
q.innerHTML = "";
const dzText = dz.querySelector(".dz-text");
const dzHint = dz.querySelector(".dz-hint");
if (pendingFiles.length === 0) {
if (dzText) dzText.textContent = "Klikni ali povleci video sem";
if (dzHint) dzHint.innerHTML = ".mp4, .mov, .webm, .mxf, .mpg — do 10 GB · <b>Lahko izberete več datotek hkrati</b>";
return;
}
if (dzText) dzText.textContent = `📹 ${pendingFiles.length} datotek v vrsti`;
if (dzHint) dzHint.textContent = "Klikni za dodatne ali povleci sem";
pendingFiles.forEach((item, idx) => {
const div = document.createElement("div");
div.className = "file-queue-item";
const sizeMB = (item.file.size / 1024 / 1024).toFixed(1);
let nameHtml;
if (item.artist && item.title) {
nameHtml = `<b>${escapeHtml(item.artist)}${escapeHtml(item.title)}</b>` +
`<div style="font-size:10px;color:var(--muted)">${escapeHtml(item.file.name)}</div>`;
} else {
nameHtml = `${escapeHtml(item.file.name)}` +
`<div class="warn">⚠ Brez razvidnega imena — ACR bo poskusil prepoznati</div>`;
}
div.innerHTML = `
<div class="name">${nameHtml}</div>
<div class="size">${sizeMB} MB</div>
<button class="remove" data-idx="${idx}" title="Odstrani">×</button>
`;
q.appendChild(div);
});
q.querySelectorAll(".remove").forEach(btn => {
btn.addEventListener("click", () => removeFromQueue(parseInt(btn.dataset.idx)));
});
}
// ─── Settings collector ─────────────────────────
function collectSettings() {
const auto = $("#auto-chorus").checked;
const duration = parseFloat($("#duration").value) || 30;
return {
mode: $("#mode").value,
lang: $("#lang").value || null,
whisper_model: $("#model").value,
auto_chorus: auto,
include_prebuild: $("#include-prebuild").checked,
start: !auto && $("#start").value ? parseTimestamp($("#start").value) : null,
duration: duration,
max_duration: auto ? Math.round(duration * 1.5) : duration,
min_duration: auto ? Math.round(duration * 0.7) : duration,
subtitle_style: $("#subtitle-style").value,
quality: $("#quality").value,
no_subs: $("#no-subs").checked,
llm_provider: $("#llm-provider").value,
};
}
function parseTimestamp(s) {
s = s.trim();
if (s.includes(":")) {
const parts = s.split(":").map(parseFloat);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
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 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 = () => {
// Uporabi modal namesto inline video, da ne pokvari layout-a in
// ne blokira gumbov na desni strani (jobs list).
// Izvleci title iz job-a v jobs listu če obstaja
const jobCard = document.querySelector(`.job[data-id="${jobId}"]`);
const title = jobCard?.dataset.title || "";
previewJob(jobId, title);
};
}
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";
}
// 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..." },
"Končano": { pct: 100, friendly: "✅ Končano" },
};
// ─── Submit ─────────────────────────────────────
$("#submit-btn").addEventListener("click", async () => {
const isYT = $("#tab-youtube").classList.contains("hidden") === false;
const settings = collectSettings();
$("#submit-btn").disabled = true;
liveReset();
try {
if (isYT) {
const url = $("#yt-url").value.trim();
if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; }
showLive("Pošiljam YouTube job...", url, null);
const r = await fetch("/api/youtube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, ...settings }),
});
if (!r.ok) {
const err = await r.text();
liveFail("YouTube submit napaka: " + err);
return;
}
const job = await r.json();
watchJob(job.id);
refreshJobs();
} else {
if (pendingFiles.length === 0) {
alert("Izberi vsaj eno datoteko");
$("#submit-btn").disabled = false;
return;
}
// Generate batch ID za skupinsko sledenje (Telegram summary)
const batchId = "batch-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
const totalFiles = pendingFiles.length;
// Upload + queue all files SEQUENTIALLY (1 hkrati za stabilnost)
for (let i = 0; i < pendingFiles.length; i++) {
const item = pendingFiles[i];
const f = item.file;
const sizeMB = (f.size / 1024 / 1024).toFixed(1);
showLive(
`Nalaganje ${i + 1}/${totalFiles}`,
`${f.name} (${sizeMB} MB)`,
((i / totalFiles) * 100).toFixed(0)
);
const fd = new FormData();
fd.append("file", f);
if (item.artist) fd.append("artist", item.artist);
if (item.title) fd.append("title", item.title);
fd.append("batch_id", batchId);
try {
const uploadResp = await uploadFileXHR(fd, (loaded, total) => {
const filePct = (loaded / total) * 100;
showLive(
`Nalaganje ${i + 1}/${totalFiles}`,
`${f.name}${(loaded/1024/1024).toFixed(1)}/${sizeMB} MB (${filePct.toFixed(0)}%)`,
((i + filePct/100) / totalFiles) * 100
);
});
const job = JSON.parse(uploadResp);
// Pošlji "process" da se postavi v queue
await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.id, batch_id: batchId, ...settings }),
});
} catch (e) {
console.error(`Failed to upload ${f.name}: ${e.message}`);
liveFail(`Napaka pri ${f.name}: ${e.message}`);
// Continue z naslednjimi
}
}
// Vsi naloženi
showLive(
"✅ Vse v vrsti za obdelavo",
`${totalFiles} datotek se obdeluje zaporedno · obvestilo na Telegram`,
100
);
pendingFiles = [];
renderFileQueue();
refreshJobs();
}
} catch (e) {
liveFail(e.message);
} finally {
setTimeout(() => { $("#submit-btn").disabled = false; }, 500);
}
});
// XHR helper z progress callback (vrne text response)
function uploadFileXHR(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable && onProgress) {
onProgress(e.loaded, e.total);
}
};
xhr.onload = () => {
if (xhr.status === 200) resolve(xhr.responseText);
else reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`));
};
xhr.onerror = () => reject(new Error("Upload error"));
xhr.upload.onerror = () => reject(new Error("Upload transfer error"));
xhr.open("POST", "/api/upload");
xhr.send(formData);
});
}
// ─── Watch job (SSE) ────────────────────────────
function watchJob(jobId) {
const evt = new EventSource(`/api/stream/${jobId}`);
evt.onmessage = (e) => {
try {
const job = JSON.parse(e.data);
updateJobInList(job);
// Pokaži analysis summary in transkript če je na voljo
if (job.analysis_summary || job.full_transcript) {
updateAnalysisDisplay(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;
} 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") {
evt.close();
refreshJobs();
}
} 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(() => {});
};
}
// ─── Jobs list ──────────────────────────────────
async function refreshJobs() {
const r = await fetch("/api/jobs");
if (!r.ok) return;
const data = await r.json();
const list = $("#jobs-list");
if (!data.jobs.length) {
list.innerHTML = '<div class="empty">Še ni obdelav</div>';
return;
}
list.innerHTML = "";
data.jobs.forEach(j => list.appendChild(buildJobEl(j)));
// Watch any in-progress job
data.jobs.forEach(j => {
if (["queued", "processing", "downloading", "uploaded"].includes(j.status)) {
watchJob(j.id);
}
});
}
function updateJobInList(job) {
const existing = document.getElementById(`job-${job.id}`);
const el = buildJobEl(job);
if (existing) {
existing.replaceWith(el);
} else {
const list = $("#jobs-list");
if (list.querySelector(".empty")) list.innerHTML = "";
list.prepend(el);
}
}
function buildJobEl(job) {
const el = document.createElement("div");
el.className = "job";
el.id = `job-${job.id}`;
el.dataset.id = job.id;
const title = job.source_type === "youtube"
? (job.youtube_url || "YouTube")
: (job.parsed_artist && job.parsed_title
? `${job.parsed_artist}${job.parsed_title}`
: (job.filename || job.id));
el.dataset.title = title;
const sizeStr = job.output_size_mb ? `${job.output_size_mb} MB` :
job.size_mb ? `${job.size_mb} MB` : "";
const statusLabel = {
queued: "v vrsti", uploaded: "naloženo", processing: "obdeluje",
downloading: "prenaša", done: "končano", failed: "napaka",
}[job.status] || job.status;
const isProcessing = ["queued", "processing", "downloading"].includes(job.status);
const showBar = isProcessing ? '<div class="progress"><div class="progress-bar indeterminate"></div></div>' : "";
const actions = [];
if (job.status === "done") {
actions.push(`<button class="small" data-action="download" data-id="${job.id}">⬇ Download</button>`);
actions.push(`<button class="small ghost" data-action="preview" data-id="${job.id}">▶ Preview</button>`);
actions.push(`<button class="small ghost" data-action="edit" data-id="${job.id}">✏️ Edit</button>`);
}
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
el.innerHTML = `
<div class="job-head">
<div class="job-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
<span class="badge ${job.status}">${statusLabel}</span>
</div>
${job.current_step ? `<div class="step">${escapeHtml(job.current_step)}</div>` : ""}
${showBar}
${job.error ? `<div class="error-text">⚠ ${escapeHtml(job.error)}</div>` : ""}
<div class="meta">
<span>${job.source_type === "youtube" ? "YouTube" : "Upload"}</span>
${sizeStr ? `<span>${sizeStr}</span>` : ""}
${job.mode ? `<span>${job.mode}</span>` : ""}
${job.lang ? `<span>${job.lang}</span>` : ""}
</div>
<div class="actions">${actions.join("")}</div>
`;
// Shrani naslov za preview modal
el.dataset.title = title;
return el;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
// Globalno delegirano poslušanje za action gumbe (Download / Preview / Delete)
document.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!id) return;
const card = btn.closest(".job");
const title = card?.dataset.title || "";
if (action === "download") {
window.open(`/api/download/${id}`);
} else if (action === "preview") {
previewJob(id, title);
} else if (action === "edit") {
openEditModal(id, title);
} else if (action === "delete") {
deleteJob(id);
}
});
async function deleteJob(id) {
if (!confirm("Izbrišem ta job?")) return;
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
refreshJobs();
}
// ─── EDIT MODAL ─────────────────────────────────────
function formatTime(sec) {
if (!isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
const cs = Math.floor((sec % 1) * 10);
return `${m}:${String(s).padStart(2, "0")}.${cs}`;
}
async function openEditModal(jobId, title) {
// Fetch transcript + clip range
let data;
try {
const res = await fetch(`/api/transcript/${jobId}`);
if (!res.ok) {
alert("Napaka: ne morem naložiti transkripta");
return;
}
data = await res.json();
} catch (e) {
alert("Napaka: " + e.message);
return;
}
const startInit = data.clip_range?.start || 0;
const endInit = data.clip_range?.end || (startInit + 30);
let videoDuration = data.video_duration || endInit + 60;
const segments = data.segments || [];
// Helper za inline styles (procent v stringu)
function pctOfStr(t, total) {
if (!total || total <= 0) return "0";
return ((t / total) * 100).toFixed(2);
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-content edit-modal" onclick="event.stopPropagation()" style="max-width:1200px; width:95vw;">
<button class="modal-close" title="Zapri (ESC)">×</button>
<div class="modal-title" style="margin-bottom:12px;">✏️ Edit: ${escapeHtml(title)}</div>
<!-- Top section: video LEVO + napisi DESNO -->
<div style="display:grid; grid-template-columns: 1fr 320px; gap:14px; align-items:start;">
<!-- LEFT: video -->
<div>
<video id="edit-video" src="/api/source-video/${jobId}?quality=low" controls preload="auto" style="width:100%; max-height:50vh; background:#000; border-radius:6px;"></video>
<div id="source-status" style="font-size:11px; color:var(--muted); margin-top:4px; text-align:center;">⏳ Pripravljam predogled (~5s prvič, potem instant)…</div>
</div>
<!-- RIGHT: napisi -->
<div style="background:rgba(255,255,255,0.03); border-radius:6px; padding:10px; max-height:55vh; overflow-y:auto;">
<div style="font-size:13px; font-weight:bold; margin-bottom:8px; color:var(--muted); position:sticky; top:0; background:#1e1e1e; padding:6px 0;">📝 Napisi (klikni vrstico = skoči, edit besedilo)</div>
<div id="edit-segments">
${segments.map((s, i) => {
const inClip = s.start < endInit && s.end > startInit;
return `
<div class="seg-row" data-idx="${i}" data-start="${s.start}" data-end="${s.end}" style="margin-bottom:4px; padding:5px 6px; background:${inClip ? 'rgba(255,107,107,0.12)' : 'rgba(255,255,255,0.03)'}; border-left:2px solid ${inClip ? '#ff6b6b' : 'transparent'}; border-radius:3px; cursor:pointer;" onclick="seekToSegment(${s.start})">
<div style="font-size:10px; color:var(--muted);">[${formatTime(s.start)}${formatTime(s.end)}]</div>
<input type="text" data-orig="${escapeHtml(s.text || '')}" data-start="${s.start}" data-end="${s.end}" value="${escapeHtml(s.text || '').replace(/\\n/g, ' ').trim()}" onclick="event.stopPropagation()" style="width:100%; padding:3px 6px; margin-top:3px; font-size:12px; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.1); border-radius:3px; color:#fff; box-sizing:border-box;">
</div>
`;
}).join("")}
</div>
</div>
</div>
<!-- iPhone-style trim bar + WAVEFORM spodaj (full width) -->
<div style="margin-top:18px;">
<!-- Zoom controls -->
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
<span style="font-size:12px; color:var(--muted);">Zoom:</span>
<button class="small ghost zoom-btn" data-zoom="1" style="padding:3px 10px;">1x</button>
<button class="small ghost zoom-btn" data-zoom="2" style="padding:3px 10px;">2x</button>
<button class="small ghost zoom-btn" data-zoom="5" style="padding:3px 10px;">5x</button>
<button class="small ghost zoom-btn" data-zoom="10" style="padding:3px 10px;">10x</button>
<button class="small ghost zoom-btn" data-zoom="20" style="padding:3px 10px;">20x</button>
<span style="margin-left:14px; font-size:11px; color:var(--muted);">Klik = skoči (bel črtnik) · Enter = play/pause od pozicije</span>
</div>
<!-- Scrollable wrapper -->
<div id="trim-scroll" style="width:100%; overflow-x:auto; overflow-y:hidden; background:#0d0d0d; border-radius:8px;">
<!-- Trim bar (gets wider on zoom) -->
<div id="trim-bar" style="position:relative; height:72px; width:100%; min-width:100%; flex-shrink:0; box-sizing:border-box; background:#1a1a1a; border:2px solid #444; border-radius:6px; overflow:hidden; user-select:none; touch-action:none;">
<!-- Waveform image (background) -->
<img id="trim-waveform" src="/api/waveform/${jobId}?width=2400&height=72" style="position:absolute; top:0; left:0; width:100%; height:100%; opacity:0.6; pointer-events:none; z-index:0; object-fit:fill;" onerror="this.style.display='none'">
<!-- Selected region -->
<div id="trim-region" style="position:absolute; top:0; bottom:0; left:${pctOfStr(startInit, videoDuration)}%; right:${(100 - parseFloat(pctOfStr(endInit, videoDuration))).toFixed(2)}%; background:linear-gradient(180deg, rgba(255,107,107,0.35), rgba(255,107,107,0.2)); border-top:4px solid #ff6b6b; border-bottom:4px solid #ff6b6b; z-index:1;"></div>
<!-- Left handle -->
<div id="trim-handle-left" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(startInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
</div>
<!-- Right handle -->
<div id="trim-handle-right" style="position:absolute; top:0; bottom:0; left:calc(${pctOfStr(endInit, videoDuration)}% - 12px); width:24px; background:#ff6b6b; cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 12px rgba(255,107,107,0.6);">
<div style="width:4px; height:32px; background:#fff; border-radius:2px;"></div>
</div>
<!-- IN marker (zelen trikotnik znotraj trim bar-a, na vrhu) -->
<div id="marker-in" style="position:absolute; top:0; left:calc(${pctOfStr(startInit, videoDuration)}% - 7px); width:14px; height:14px; z-index:5; pointer-events:none;">
<div style="width:0; height:0; border-left:7px solid transparent; border-right:7px solid transparent; border-top:14px solid #4ade80; filter:drop-shadow(0 0 4px #4ade80);"></div>
</div>
<!-- OUT marker (rdeč trikotnik znotraj trim bar-a, na vrhu) -->
<div id="marker-out" style="position:absolute; top:0; left:calc(${pctOfStr(endInit, videoDuration)}% - 7px); width:14px; height:14px; z-index:5; pointer-events:none;">
<div style="width:0; height:0; border-left:7px solid transparent; border-right:7px solid transparent; border-top:14px solid #ff6b6b; filter:drop-shadow(0 0 4px #ff6b6b);"></div>
</div>
<!-- Playhead (bel črtnik — "tracker") -->
<div id="trim-playhead" style="position:absolute; top:-6px; bottom:-6px; left:0%; width:2px; background:#fff; z-index:2; pointer-events:none; opacity:1; box-shadow:0 0 8px rgba(255,255,255,0.8);">
<!-- Trikotnik na vrhu -->
<div style="position:absolute; top:-2px; left:50%; transform:translateX(-50%); width:0; height:0; border-left:6px solid transparent; border-right:6px solid transparent; border-top:8px solid #fff;"></div>
</div>
<!-- Time labels -->
<div style="position:absolute; bottom:4px; left:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;">0:00</div>
<div style="position:absolute; bottom:4px; right:8px; font-size:11px; color:#fff; pointer-events:none; z-index:4; text-shadow:0 0 4px #000;" id="trim-end-label">${formatTime(videoDuration)}</div>
</div>
</div>
<!-- Hint -->
<div style="font-size:11px; color:var(--muted); margin-top:6px; text-align:center;">
← Klik = bel črtnik · Enter = play (postavi ▼ trikotnik kjer si bil) · "Postavi IN/OUT" = handle skoči na trikotnik
</div>
</div>
<!-- Time display + controls -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:12px; gap:12px; flex-wrap:wrap;">
<div style="font-size:14px;">
<span style="color:var(--muted);">Začetek:</span> <b id="edit-start-val">${formatTime(startInit)}</b>
<span style="color:var(--muted); margin-left:12px;">Konec:</span> <b id="edit-end-val">${formatTime(endInit)}</b>
<span style="color:var(--muted); margin-left:12px;">Trajanje:</span> <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b>
</div>
<div style="display:flex; gap:8px;">
<button class="primary" id="preview-btn" onclick="previewSelection()" title="Predvajaj točno označen del" style="background:var(--accent); padding:8px 16px;">▶ Predvajaj odsek</button>
<button class="small ghost" onclick="seekEditVideo('start')" title="Premakni levi handle na zelen trikotnik" style="border-color:#4ade80; color:#4ade80;">▼ Postavi IN</button>
<button class="small ghost" onclick="seekEditVideo('end')" title="Premakni desni handle na rdeč trikotnik" style="border-color:#ff6b6b; color:#ff6b6b;">▼ Postavi OUT</button>
</div>
</div>
<div id="preview-status" style="margin-top:6px; font-size:12px; color:var(--muted); text-align:right;"></div>
<div class="modal-actions" style="margin-top:18px;">
<button class="primary" id="edit-save-btn">✅ Shrani in re-render</button>
<button onclick="closeModal()">Prekliči</button>
</div>
<div id="edit-status" style="margin-top:10px; font-size:12px; color:var(--muted);"></div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
// ESC key
const escHandler = (e) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
overlay.querySelector(".modal-close").addEventListener("click", closeModal);
// ─── iPhone-style trim bar logic ───
const video = document.getElementById("edit-video");
const trimBar = document.getElementById("trim-bar");
const trimRegion = document.getElementById("trim-region");
const handleL = document.getElementById("trim-handle-left");
const handleR = document.getElementById("trim-handle-right");
const playhead = document.getElementById("trim-playhead");
const startVal = document.getElementById("edit-start-val");
const endVal = document.getElementById("edit-end-val");
const durVal = document.getElementById("edit-duration");
// State
let trimStart = startInit;
let trimEnd = endInit;
let dragging = null; // 'left' / 'right' / null
// Marker state — kje je bil zadnji play (loči levi/desni)
let markerInTime = startInit; // pozicija zelenega trikotnika
let markerOutTime = endInit; // pozicija rdečega trikotnika
const markerInEl = document.getElementById("marker-in");
const markerOutEl = document.getElementById("marker-out");
function renderMarkers() {
if (markerInEl) markerInEl.style.left = `calc(${pctOfStr(markerInTime, videoDuration)}% - 7px)`;
if (markerOutEl) markerOutEl.style.left = `calc(${pctOfStr(markerOutTime, videoDuration)}% - 7px)`;
}
function pctOf(t) {
return (t / videoDuration) * 100;
}
function timeFromPx(px) {
const rect = trimBar.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (px - rect.left) / rect.width));
return pct * videoDuration;
}
function renderTrim() {
const lPct = pctOf(trimStart);
const rPct = pctOf(trimEnd);
trimRegion.style.left = lPct + '%';
trimRegion.style.right = (100 - rPct) + '%';
// Handle 24px width → offset 12px
handleL.style.left = `calc(${lPct}% - 12px)`;
handleR.style.left = `calc(${rPct}% - 12px)`;
startVal.textContent = formatTime(trimStart);
endVal.textContent = formatTime(trimEnd);
durVal.textContent = (trimEnd - trimStart).toFixed(1) + "s";
}
function renderPlayhead() {
if (!video) return;
const t = video.currentTime || 0;
playhead.style.left = `calc(${pctOf(t)}% - 1px)`;
}
// Mouse + touch start
function onPointerDown(which, e) {
e.preventDefault();
dragging = which;
document.body.style.cursor = 'ew-resize';
}
// Mouse + touch move
function onPointerMove(e) {
if (!dragging) return;
const x = e.touches ? e.touches[0].clientX : e.clientX;
let t = timeFromPx(x);
// Constraints
if (dragging === 'left') {
t = Math.max(0, Math.min(t, trimEnd - 1));
trimStart = t;
if (video) video.currentTime = t;
} else if (dragging === 'right') {
t = Math.max(trimStart + 1, Math.min(t, videoDuration));
trimEnd = t;
if (video) video.currentTime = t;
}
renderTrim();
}
function onPointerUp() {
if (dragging) {
// Po dragu posodobi napis highlights (kateri so zdaj v clipu)
if (typeof highlightActiveSegment === 'function') {
highlightActiveSegment();
}
}
dragging = null;
document.body.style.cursor = '';
}
handleL.addEventListener("mousedown", (e) => onPointerDown('left', e));
handleR.addEventListener("mousedown", (e) => onPointerDown('right', e));
handleL.addEventListener("touchstart", (e) => onPointerDown('left', e));
handleR.addEventListener("touchstart", (e) => onPointerDown('right', e));
document.addEventListener("mousemove", onPointerMove);
document.addEventListener("touchmove", onPointerMove);
document.addEventListener("mouseup", onPointerUp);
document.addEventListener("touchend", onPointerUp);
// Click anywhere on trim bar = SAMO seek (NE predvajaj)
// Playhead skoči tja, počaka na Enter za predvajanje
trimBar.addEventListener("click", (e) => {
if (e.target === handleL || e.target === handleR || handleL.contains(e.target) || handleR.contains(e.target)) return;
const t = timeFromPx(e.clientX);
if (video) {
// Ustavi predvajanje če teče (da ne moti)
if (!video.paused) video.pause();
video.currentTime = t;
renderPlayhead(); // takoj posodobi vizualno pozicijo
}
});
// ─── ZOOM logic ───
let currentZoom = 1;
const trimScroll = document.getElementById("trim-scroll");
function applyZoom(zoom) {
currentZoom = zoom;
trimBar.style.width = (100 * zoom) + "%";
trimBar.style.minWidth = (100 * zoom) + "%";
// Aktiven gumb
document.querySelectorAll(".zoom-btn").forEach(b => {
if (parseInt(b.dataset.zoom) === zoom) {
b.style.background = "var(--accent)";
b.style.color = "#fff";
} else {
b.style.background = "";
b.style.color = "";
}
});
// Auto-scroll na sredino trim region
setTimeout(() => {
if (trimScroll) {
const barWidth = trimBar.getBoundingClientRect().width;
const centerPct = ((trimStart + trimEnd) / 2) / videoDuration;
const scrollTarget = barWidth * centerPct - trimScroll.clientWidth / 2;
trimScroll.scrollLeft = Math.max(0, scrollTarget);
}
renderTrim();
renderMarkers();
}, 50);
}
document.querySelectorAll(".zoom-btn").forEach(btn => {
btn.addEventListener("click", () => {
applyZoom(parseInt(btn.dataset.zoom));
});
});
// ENTER tipka = play/pause od trenutne pozicije (kjer je playhead)
// Space tudi (back-compat)
const playPauseHandler = (e) => {
const isPlayKey = e.code === "Enter" || e.code === "Space";
if (!isPlayKey) return;
// Ne moti če uporabnik tipka v inputu/textarea
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
e.preventDefault();
if (video) {
if (video.paused) {
// Pred play-em: postavi trikotnik na trenutno pozicijo
// Loči levi/desni glede na bližino handle-jev
const t = video.currentTime;
const distToLeft = Math.abs(t - trimStart);
const distToRight = Math.abs(t - trimEnd);
if (distToLeft <= distToRight) {
markerInTime = t;
} else {
markerOutTime = t;
}
renderMarkers();
video.play().catch(() => {});
} else {
video.pause();
}
}
};
document.addEventListener("keydown", playPauseHandler);
// Cleanup ob zaprtju modala
overlay._cleanup = () => {
document.removeEventListener("keydown", playPauseHandler);
};
// Update playhead during playback + re-render če videoDuration manjkalo
if (video) {
video.addEventListener("timeupdate", renderPlayhead);
video.addEventListener("loadedmetadata", () => {
// Če videoDuration ni bilo podanih iz API, vzemi iz video elementa
if ((!videoDuration || videoDuration < 1) && video.duration) {
videoDuration = video.duration;
}
// Refresh end label
const endLabel = document.getElementById("trim-end-label");
if (endLabel) endLabel.textContent = formatTime(videoDuration);
// Re-render handles z pravilnim videoDuration
renderTrim();
renderPlayhead();
});
}
// "⤴ Začetek" gumb = premakni LEVI handle na zelen trikotnik (commit IN)
// "↪ Konec" gumb = premakni DESNI handle na rdeč trikotnik (commit OUT)
window.seekEditVideo = function(which) {
if (!video) return;
if (which === "start") {
// Commit IN: levi handle skoči na zelen trikotnik
if (markerInTime < trimEnd - 1) {
trimStart = markerInTime;
renderTrim();
// Skoči na to pozicijo
video.currentTime = trimStart;
}
} else {
// Commit OUT: desni handle skoči na rdeč trikotnik
if (markerOutTime > trimStart + 1) {
trimEnd = markerOutTime;
renderTrim();
video.currentTime = trimEnd;
}
}
};
// Klik na napis → skoči video na tisti timestamp
window.seekToSegment = function(t) {
if (!video) return;
// Pause če teče, samo skoči (Enter za play)
if (!video.paused) video.pause();
video.currentTime = t;
renderPlayhead();
};
// Live highlight aktivnega segmenta med predvajanjem
function highlightActiveSegment() {
if (!video) return;
const t = video.currentTime;
const rows = document.querySelectorAll(".seg-row");
rows.forEach(row => {
const sStart = parseFloat(row.dataset.start);
const sEnd = parseFloat(row.dataset.end);
const isActive = t >= sStart && t < sEnd;
if (isActive) {
row.style.background = "rgba(255, 215, 0, 0.25)";
row.style.borderLeft = "2px solid #ffd700";
if (!row._scrolledTo) {
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
row._scrolledTo = true;
setTimeout(() => { row._scrolledTo = false; }, 500);
}
} else {
const inClip = (sStart < trimEnd && sEnd > trimStart);
row.style.background = inClip ? "rgba(255,107,107,0.12)" : "rgba(255,255,255,0.03)";
row.style.borderLeft = "2px solid " + (inClip ? "#ff6b6b" : "transparent");
}
});
}
// ─── INSTANT PREVIEW: predvajaj označen del (od trimStart, auto-stop pri trimEnd) ───
window.previewSelection = function() {
if (!video) return;
// Skoči na trim start in predvajaj
video.currentTime = trimStart;
video.play().catch(err => {
console.warn("Play failed:", err);
});
};
// Auto-stop ko doseže trimEnd (brez render-a, brez preview clip URL)
// Samo če NI v aktivnem dragu (ker drag = naročiteljsko seekanje)
if (video) {
video.addEventListener("timeupdate", () => {
highlightActiveSegment(); // Live highlight aktivnega napisa
if (dragging) return; // Ne ustavi med dragom
if (!video.paused && video.currentTime >= trimEnd) {
video.pause();
video.currentTime = trimStart;
}
});
// Source-status: skrij ko se naloži
const sourceStatus = document.getElementById("source-status");
video.addEventListener("loadeddata", () => {
if (sourceStatus) sourceStatus.textContent = "✅ Predogled pripravljen — drag ročajev za fine-tune";
setTimeout(() => { if (sourceStatus) sourceStatus.style.display = "none"; }, 2000);
});
video.addEventListener("error", () => {
if (sourceStatus) sourceStatus.textContent = "❌ Napaka pri nalaganju predogleda";
});
}
// Initial render — počakaj da DOM ima dimenzije (modal je bil pravkar dodan)
console.log("[EditModal] init", { startInit, endInit, videoDuration, trimStart, trimEnd });
// ResizeObserver: ko se trim-bar dobi pravilno širino, re-render
const ro = new ResizeObserver(() => {
renderTrim();
renderPlayhead();
renderMarkers();
});
ro.observe(trimBar);
// Tudi takoj renderiraj (za primer, da se ResizeObserver ne sproži)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
renderTrim();
renderPlayhead();
renderMarkers();
// Nastavi 1x kot aktiven gumb
applyZoom(1);
console.log("[EditModal] after renderTrim", {
leftStyle: handleL.style.left,
rightStyle: handleR.style.left,
trimBarWidth: trimBar.getBoundingClientRect().width
});
});
});
// Save button
document.getElementById("edit-save-btn").addEventListener("click", async () => {
const start = trimStart;
const end = trimEnd;
if (end - start < 5) {
alert("Trajanje mora biti vsaj 5s");
return;
}
if (end - start > 60) {
alert("Trajanje največ 60s");
return;
}
// Zberi popravljene segmente
const segInputs = document.querySelectorAll("#edit-segments input");
const customSegments = [];
let hasChanges = false;
segInputs.forEach(inp => {
const orig = inp.dataset.orig.replace(/\\n/g, ' ').trim();
const newText = inp.value.trim();
if (orig !== newText) hasChanges = true;
customSegments.push({
start: parseFloat(inp.dataset.start),
end: parseFloat(inp.dataset.end),
text: newText,
});
});
const status = document.getElementById("edit-status");
status.textContent = "⏳ Pošiljam zahtevo...";
document.getElementById("edit-save-btn").disabled = true;
try {
const body = { start, end };
if (hasChanges) body.custom_segments = customSegments;
const res = await fetch(`/api/jobs/${jobId}/recut`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
status.textContent = "❌ " + (err.detail || "Napaka");
document.getElementById("edit-save-btn").disabled = false;
return;
}
status.textContent = "✅ Re-render v vrsti! Bo gotov v ~30s.";
setTimeout(() => {
closeModal();
refreshJobs();
}, 1500);
} catch (e) {
status.textContent = "❌ Napaka: " + e.message;
document.getElementById("edit-save-btn").disabled = false;
}
});
}
function previewJob(id, title) {
// Odpre velik modal z reel videom
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close" title="Zapri (ESC)">×</button>
<video src="/api/preview/${id}" controls autoplay playsinline></video>
${title ? `<div class="modal-title">${title}</div>` : ""}
<div class="modal-actions">
<button class="primary" onclick="window.open('/api/download/${id}')">⬇ Prenesi reel</button>
<button onclick="closeModal()">Zapri</button>
</div>
</div>
`;
const close = () => closeModal();
overlay.addEventListener("click", close);
overlay.querySelector(".modal-close").addEventListener("click", (e) => {
e.stopPropagation();
close();
});
// ESC key to close
const escHandler = (e) => {
if (e.key === "Escape") {
close();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden"; // prevent scroll behind modal
}
function closeModal() {
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
// Cleanup event listeners (npr. Space tipka)
if (typeof overlay._cleanup === "function") {
try { overlay._cleanup(); } catch (e) {}
}
// Stop video before removing (prevents memory leak)
const video = overlay.querySelector("video");
if (video) {
video.pause();
video.removeAttribute("src");
video.load();
}
overlay.remove();
document.body.style.overflow = "";
}
}
refreshJobs();
setInterval(refreshJobs, 10000);
</script>
</body>
</html>