reels-app/templates/index.html
Sebastjan Artič 9dba2a1185 iPhone-style trim bar: drag handles instead of sliders
User feedback: 'tako kot imajo na iphonu - potegnem iz leve in iz
desne za na konec... reel pa more biti že v stanju postavljen'

Replaced 2 separate range sliders with iPhone-style trim bar:
- Single horizontal bar showing full video duration
- 2 draggable handles (left = start, right = end)
- Selected region highlighted in accent color
- Live playhead during playback
- Mouse + touch support
- Click anywhere on bar = seek to that position
- Initial state: handles positioned at auto-selected clip range
  (just fine-tune left/right, no need to set from scratch)

formatTime helper for nice m:ss.c display.
2026-04-30 10:34:34 +00:00

1326 lines
51 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);
const videoDuration = data.video_duration || endInit + 60;
const segments = data.segments || [];
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-content edit-modal" onclick="event.stopPropagation()" style="max-width:900px;">
<button class="modal-close" title="Zapri (ESC)">×</button>
<div class="modal-title" style="margin-bottom:12px;">✏️ Edit: ${escapeHtml(title)}</div>
<video id="edit-video" src="/api/source-video/${jobId}" preload="metadata" style="width:100%; max-height:50vh; background:#000; border-radius:6px;"></video>
<!-- iPhone-style trim bar -->
<div id="trim-bar" style="position:relative; height:64px; margin-top:14px; background:rgba(255,255,255,0.05); border-radius:8px; overflow:hidden; user-select:none; touch-action:none;">
<!-- Selected region (highlighted) -->
<div id="trim-region" style="position:absolute; top:0; bottom:0; background:linear-gradient(180deg, rgba(255,107,107,0.25), rgba(255,107,107,0.15)); border-top:3px solid var(--accent); border-bottom:3px solid var(--accent); z-index:1;"></div>
<!-- Left handle -->
<div id="trim-handle-left" style="position:absolute; top:0; bottom:0; width:18px; background:var(--accent); cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 8px rgba(0,0,0,0.4);">
<div style="width:3px; height:24px; background:#fff; border-radius:2px;"></div>
</div>
<!-- Right handle -->
<div id="trim-handle-right" style="position:absolute; top:0; bottom:0; width:18px; background:var(--accent); cursor:ew-resize; z-index:3; display:flex; align-items:center; justify-content:center; box-shadow:0 0 8px rgba(0,0,0,0.4);">
<div style="width:3px; height:24px; background:#fff; border-radius:2px;"></div>
</div>
<!-- Playhead (current video position) -->
<div id="trim-playhead" style="position:absolute; top:-3px; bottom:-3px; width:2px; background:#fff; z-index:2; pointer-events:none; opacity:0.7;"></div>
<!-- Time labels -->
<div style="position:absolute; bottom:4px; left:8px; font-size:10px; color:var(--muted); pointer-events:none; z-index:4;">0:00</div>
<div style="position:absolute; bottom:4px; right:8px; font-size:10px; color:var(--muted); pointer-events:none; z-index:4;" id="trim-end-label">${formatTime(videoDuration)}</div>
</div>
<!-- Time display + controls -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:12px; gap:12px;">
<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="small ghost" onclick="seekEditVideo('start')" title="Predvajaj od začetka">▶ Začetek</button>
<button class="small ghost" onclick="seekEditVideo('end')" title="Skoči na konec">↪ Konec</button>
</div>
</div>
<details style="margin-top:14px;">
<summary style="cursor:pointer; font-size:13px; color:var(--muted);">📝 Edit napise (kliknite vrstico za popravek)</summary>
<div id="edit-segments" style="max-height:30vh; overflow:auto; margin-top:10px; padding:10px; background:rgba(255,255,255,0.03); border-radius:6px;">
${segments.filter(s => s.start < endInit && s.end > startInit).map((s, i) => `
<div class="seg-row" data-idx="${i}" style="margin-bottom:6px; padding:6px; background:rgba(255,255,255,0.04); border-radius:4px;">
<span style="font-size:11px; color:var(--muted); margin-right:8px;">[${s.start.toFixed(1)}s]</span>
<input type="text" data-orig="${escapeHtml(s.text || '')}" data-start="${s.start}" data-end="${s.end}" value="${escapeHtml(s.text || '').replace(/\\n/g, ' ').trim()}" style="width:calc(100% - 80px); padding:4px 8px; font-size:13px; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.1); border-radius:3px; color:#fff;">
</div>
`).join("")}
</div>
</details>
<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
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) + '%';
handleL.style.left = `calc(${lPct}% - 9px)`;
handleR.style.left = `calc(${rPct}% - 9px)`;
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() {
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 = seek video
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) video.currentTime = t;
});
// Update playhead during playback
if (video) {
video.addEventListener("timeupdate", renderPlayhead);
video.addEventListener("loadedmetadata", () => {
// Če videoDuration ni bilo podanih, vzemi iz videa
if (!data.video_duration && video.duration) {
// Recalculate render
renderTrim();
renderPlayhead();
}
});
}
window.seekEditVideo = function(which) {
if (!video) return;
const t = which === "start" ? trimStart : trimEnd;
video.currentTime = t;
if (which === "start") video.play();
};
// Initial render
renderTrim();
renderPlayhead();
// 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) {
// 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>