reels-app/templates/index.html
Claude cd872d8bea Layout: razširi main na 1600px + levi panel fiksno 440px
Prej: max-width 1100px, 1fr/1fr grid → na velikih ekranih je bil
levi 'Nov reel' panel zelo otesnjen, desni list pa premajhen.

Zdaj:
- main max-width 1600px (več prostora na velikih ekranih)
- levi panel fiksno 440px (vsebina diha + tabi v eni vrstici)
- desni jobs list zavzema vso preostalo širino (1fr)
- Responsive: <1100px → levi 380px, <900px → stack 1 stolpec
2026-05-03 14:15:42 +00:00

1992 lines
86 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: 1600px;
margin: 0 auto;
padding: 32px;
display: grid;
grid-template-columns: 440px 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: 1100px) {
main { grid-template-columns: 380px 1fr; }
}
@media (max-width: 900px) {
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 <span style="font-size:11px; color:var(--muted); font-weight:normal;">— en video ali cela playlist</span></label>
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=... ali https://www.youtube.com/playlist?list=...">
<div style="font-size:11px; color:var(--muted); margin-top:4px;">💡 Playlist: vsak komad postane svoj reel — Qnet auto-match po naslovu</div>
</div>
<!-- TV postaja: določa Nextcloud target mapo -->
<label style="margin-top:8px;">📺 TV postaja (določa kam gre na Nextcloud)</label>
<div class="tv-station-tabs" style="display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px;">
<button type="button" class="tv-tab active" data-station="FOLX SLOVENIJA" style="padding:6px 12px; border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:4px; cursor:pointer; font-size:13px;">FOLX SLOVENIJA</button>
<button type="button" class="tv-tab" data-station="FOLX DE" style="padding:6px 12px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:13px;">FOLX DE</button>
<button type="button" class="tv-tab" data-station="ONE DE" style="padding:6px 12px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:13px;">ONE DE</button>
<button type="button" class="tv-tab" data-station="ZWEI MUSIC" style="padding:6px 12px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:13px;">ZWEI MUSIC</button>
<button type="button" class="tv-tab" data-station="ADRIA" style="padding:6px 12px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:13px;">ADRIA</button>
</div>
<input type="hidden" id="tv-station-input" value="FOLX SLOVENIJA">
<!-- Skriti settings — vsi defaulti zapečeni za reels workflow -->
<input type="hidden" id="mode" value="track">
<input type="hidden" id="lang" value="">
<input type="hidden" id="model" value="large-v3">
<input type="hidden" id="quality" value="medium">
<input type="hidden" id="llm-provider" value="claude">
<input type="hidden" id="auto-chorus" data-checked="true">
<input type="hidden" id="include-prebuild" data-checked="false">
<input type="hidden" id="subtitle-style" value="reels">
<input type="hidden" id="start" value="">
<input type="hidden" id="duration" value="30">
<!-- EDINA vidna kontrola: napisi -->
<label class="toggle" style="margin-top: 12px;">
<input type="checkbox" id="no-subs">
Izklopi podnapise (brez kljukice = napisi v video · kljukica = brez napisov)
</label>
<div style="font-size: 11px; color: var(--text-dim); margin-top: 6px;">
🤖 Preset: Track reframe · Medium kvaliteta · Soniox+Claude · Smart chorus · Brez pre-chorus
</div>
<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">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; flex-wrap:wrap; gap:8px;">
<h2 style="margin:0;">moji reels</h2>
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<input type="text" id="jobs-search" placeholder="🔍 išči (izvajalec, naslov, file)…" style="width:240px; padding:5px 9px; font-size:13px;" autocomplete="off">
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); cursor:pointer;">
<input type="checkbox" id="show-uploaded">
☁ Pokaži tudi že naložene
</label>
</div>
</div>
<!-- Filter po TV postaji — pokaže samo še-ne-potrjene (ne-naložene) jobe -->
<div class="station-filter-tabs" id="station-filter-tabs" style="display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px;">
<button type="button" class="station-filter-tab active" data-station="" style="padding:5px 11px; border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:4px; cursor:pointer; font-size:12px;">
Vse <span class="cnt" data-cnt-all>0</span>
</button>
<button type="button" class="station-filter-tab" data-station="FOLX SLOVENIJA" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:12px;">
FOLX SLOVENIJA <span class="cnt" data-cnt="FOLX SLOVENIJA">0</span>
</button>
<button type="button" class="station-filter-tab" data-station="FOLX DE" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:12px;">
FOLX DE <span class="cnt" data-cnt="FOLX DE">0</span>
</button>
<button type="button" class="station-filter-tab" data-station="ONE DE" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:12px;">
ONE DE <span class="cnt" data-cnt="ONE DE">0</span>
</button>
<button type="button" class="station-filter-tab" data-station="ZWEI MUSIC" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:12px;">
ZWEI MUSIC <span class="cnt" data-cnt="ZWEI MUSIC">0</span>
</button>
<button type="button" class="station-filter-tab" data-station="ADRIA" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#ccc; border-radius:4px; cursor:pointer; font-size:12px;">
ADRIA <span class="cnt" data-cnt="ADRIA">0</span>
</button>
<button type="button" class="station-filter-tab" data-station="__none__" style="padding:5px 11px; border:1px solid #444; background:transparent; color:#888; border-radius:4px; cursor:pointer; font-size:12px;" title="Joby brez nastavljene TV postaje">
(brez postaje) <span class="cnt" data-cnt="__none__">0</span>
</button>
</div>
<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 + manual times → zdaj hidden inputs (zapečen default), brez handler-ja.
// ─── 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];
}
async function addFilesToQueue(files) {
const newItems = [];
for (const f of files) {
const [artist, title] = parseArtistTitle(f.name);
newItems.push({ file: f, artist, title, dedup: null, qnetMatch: null });
}
const filenames = newItems.map(i => i.file.name);
const tvStation = $("#tv-station-input").value || "FOLX SLOVENIJA";
// Vzporedno: dedup check + Qnet match (oba endpointa, neodvisna)
const [dedupRes, qnetRes] = await Promise.all([
fetch("/api/dedup/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filenames, tv_station: tvStation }),
}).then(r => r.ok ? r.json() : null).catch(e => { console.warn("Dedup check failed:", e); return null; }),
fetch("/api/qnet/match-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filenames, min_confidence: 0.85 }),
}).then(r => r.ok ? r.json() : null).catch(e => { console.warn("Qnet match failed:", e); return null; }),
]);
// Apply dedup
if (dedupRes && dedupRes.results) {
newItems.forEach(item => {
const m = dedupRes.results[item.file.name];
if (m) item.dedup = m;
});
}
// Apply Qnet match — če baza prepozna komad, prepiši artist+title
if (qnetRes && qnetRes.results) {
newItems.forEach(item => {
const m = qnetRes.results[item.file.name];
if (m && m.matched) {
item.qnetMatch = m;
// Auto-fill artist+title iz Qnet baze (clean podatki)
item.artist = m.artist;
item.title = m.title;
}
});
}
pendingFiles.push(...newItems);
renderFileQueue();
}
function removeFromQueue(idx) {
pendingFiles.splice(idx, 1);
renderFileQueue();
}
// Uporabnik želi vseeno re-process komada ki je bil že naložen
window.forceReprocess = async function(idx) {
const item = pendingFiles[idx];
if (!item || !item.dedup) return;
const tvStation = item.dedup.tv_station;
// Izbriši dedup zapis
try {
await fetch("/api/dedup/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filenames: [item.file.name], tv_station: tvStation }),
});
} catch (e) {
console.warn("Dedup remove failed:", e);
}
item.dedup = null;
item.forceReprocess = true;
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.qnetMatch) {
// Prepoznano iz Qnet player baze — clean artist+title + station badge
const m = item.qnetMatch;
const stationBadge = `<span style="font-size:9px; padding:1px 5px; background:rgba(74,222,128,0.18); border:1px solid rgba(74,222,128,0.5); border-radius:3px; color:#4ade80; font-weight:600; margin-left:6px; vertical-align:middle;">${escapeHtml(m.station)}</span>`;
const confLabel = m.confidence >= 0.95 ? "" : ` <span style="color:var(--muted); font-size:10px;">(${Math.round(m.confidence*100)}%)</span>`;
nameHtml = `<b>${escapeHtml(m.artist)}${escapeHtml(m.title)}</b>${stationBadge}` +
`<div style="font-size:10px;color:var(--muted)">🎵 prepoznano iz Qnet baze${confLabel} · ${escapeHtml(item.file.name)}</div>`;
} else 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>`;
}
// Dedup warning
if (item.dedup) {
const date = new Date(item.dedup.uploaded_at * 1000).toLocaleDateString("sl-SI");
nameHtml += `<div style="margin-top:4px; padding:4px 6px; background:rgba(239,68,68,0.15); border-left:3px solid #ef4444; border-radius:3px; font-size:11px; color:#fca5a5;">
⚠ <b>Že naložen na ${escapeHtml(item.dedup.tv_station)}</b> (${date}) — <a href="#" onclick="forceReprocess(${idx}); return false;" style="color:#ffd700; text-decoration:underline;">Re-process</a>
</div>`;
}
div.innerHTML = `
<div class="name">${nameHtml}</div>
<div class="size">${sizeMB} MB</div>
<button class="remove" data-idx="${idx}" title="Odstrani">×</button>
`;
if (item.dedup && !item.forceReprocess) {
div.style.opacity = "0.6";
}
q.appendChild(div);
});
q.querySelectorAll(".remove").forEach(btn => {
btn.addEventListener("click", () => removeFromQueue(parseInt(btn.dataset.idx)));
});
}
// ─── Settings collector ─────────────────────────
function collectSettings() {
// Hidden inputs: data-checked = "true"/"false" za bool fields
const auto = $("#auto-chorus").dataset.checked === "true";
const includePre = $("#include-prebuild").dataset.checked === "true";
const duration = parseFloat($("#duration").value) || 30;
return {
mode: $("#mode").value,
lang: $("#lang").value || null,
whisper_model: $("#model").value,
auto_chorus: auto,
include_prebuild: includePre,
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,
tv_station: $("#tv-station-input").value || "FOLX SLOVENIJA",
};
}
// ─── TV station tabs ─────────────────────────
document.querySelectorAll(".tv-tab").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tv-tab").forEach(b => {
b.classList.remove("active");
b.style.background = "transparent";
b.style.color = "#ccc";
b.style.borderColor = "#444";
});
btn.classList.add("active");
btn.style.background = "var(--accent)";
btn.style.color = "#fff";
btn.style.borderColor = "var(--accent)";
$("#tv-station-input").value = btn.dataset.station;
// Persist
try { localStorage.setItem("reels_tv_station", btn.dataset.station); } catch(e) {}
});
});
// ─── Settings persist v localStorage ─────────────────────────
// Edina vidna kontrola je no-subs. Ohranja se med page reload-i.
// Default = brez kljukice (= napisi VKLOPLJENI po default).
const PERSIST_FIELDS = [
{ id: "no-subs", type: "checkbox" },
];
// Load saved values na DOM ready
PERSIST_FIELDS.forEach(f => {
const el = document.getElementById(f.id);
if (!el) return;
const saved = localStorage.getItem("reels_" + f.id);
if (saved !== null) {
if (f.type === "checkbox") el.checked = saved === "true";
else el.value = saved;
}
// Persist na vsako spremembo
el.addEventListener("change", () => {
try {
if (f.type === "checkbox") localStorage.setItem("reels_" + f.id, el.checked ? "true" : "false");
else localStorage.setItem("reels_" + f.id, el.value);
} catch(e) {}
});
});
// Saved TV station
const savedStation = localStorage.getItem("reels_tv_station");
if (savedStation) {
$("#tv-station-input").value = savedStation;
// Aktiven gumb
document.querySelectorAll(".tv-tab").forEach(b => {
const isActive = b.dataset.station === savedStation;
b.classList.toggle("active", isActive);
b.style.background = isActive ? "var(--accent)" : "transparent";
b.style.color = isActive ? "#fff" : "#ccc";
b.style.borderColor = isActive ? "var(--accent)" : "#444";
});
}
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; }
// Detect playlist URL
const isPlaylist = /\/playlist\?/.test(url) || (url.includes("list=") && !url.match(/[?&]v=/));
if (isPlaylist) {
showLive("Berem playlist...", url, null);
try {
const previewR = await fetch("/api/youtube/playlist-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (!previewR.ok) {
const err = await previewR.text();
liveFail("Ne morem prebrati playlist: " + err);
$("#submit-btn").disabled = false;
return;
}
const preview = await previewR.json();
const count = preview.items ? preview.items.length : 0;
const titlePreview = preview.items.slice(0, 5).map(it => `${it.title}`).join("\n");
const moreText = count > 5 ? `\n ... in še ${count - 5} ostalih` : "";
const confirm_msg = `Najdenih ${count} komadov v playlistu "${preview.playlist_title}":\n\n${titlePreview}${moreText}\n\nNaloži vse in obdelaj?`;
if (!confirm(confirm_msg)) {
liveReset();
$("#submit-btn").disabled = false;
return;
}
} catch (e) {
liveFail("Playlist preview napaka: " + e.message);
$("#submit-btn").disabled = false;
return;
}
}
showLive(isPlaylist ? "Pošiljam playlist v queue..." : "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 data = await r.json();
if (data.is_playlist) {
// Batch playlist response
showLive(`${data.count} komadov v queueu`,
`Playlist "${data.playlist_title}" — obdelujejo se zaporedno`, 100);
// Watch all jobs
(data.jobs || []).forEach(j => watchJob(j.id));
} else {
// Single video
watchJob(data.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);
// Filtriraj ven dedup-ed items (uporabnik mora kliknili Re-process)
const filesToProcess = pendingFiles.filter(item => !item.dedup);
if (filesToProcess.length === 0) {
alert("Vsi izbrani komadi so že naloženi. Klikni 'Re-process' za ponovno obdelavo.");
$("#submit-btn").disabled = false;
return;
}
if (filesToProcess.length < pendingFiles.length) {
const skipped = pendingFiles.length - filesToProcess.length;
console.log(`Preskočil ${skipped} že obdelanih komadov`);
}
const totalFiles = filesToProcess.length;
// Upload + queue all files SEQUENTIALLY (1 hkrati za stabilnost)
for (let i = 0; i < filesToProcess.length; i++) {
const item = filesToProcess[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 uploadFileWithRetry(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}`);
showLive(
`⚠ Preskočil ${i + 1}/${totalFiles}`,
`${f.name}: ${e.message} (nadaljujem z naslednjim)`,
((i + 1) / totalFiles) * 100
);
// Continue z naslednjimi (ne breakaj cel loop)
}
}
// 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();
// 10 min timeout per file (za velike komade)
xhr.timeout = 10 * 60 * 1000;
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("Network error (offline?)"));
xhr.ontimeout = () => reject(new Error("Timeout (10min)"));
xhr.upload.onerror = () => reject(new Error("Upload transfer error"));
xhr.upload.ontimeout = () => reject(new Error("Upload timeout"));
xhr.open("POST", "/api/upload");
xhr.send(formData);
});
}
// Retry helper: če upload faila, poskusi 2x ponovno z malim delay-om
async function uploadFileWithRetry(formData, onProgress, maxRetries = 2) {
let lastErr = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await uploadFileXHR(formData, onProgress);
} catch (e) {
lastErr = e;
console.warn(`Upload attempt ${attempt + 1} failed: ${e.message}`);
if (attempt < maxRetries) {
// Eksponentni delay: 2s, 4s
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
}
}
}
throw lastErr;
}
// ─── 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 ──────────────────────────────────
function jobMatchesSearch(job, query) {
if (!query) return true;
const q = query.toLowerCase().trim();
if (!q) return true;
const haystack = [
job.parsed_artist || "",
job.parsed_title || "",
job.filename || "",
job.id || "",
job.tv_station || "",
].join(" ").toLowerCase();
// vsaka beseda iz query-ja mora biti v haystacku (AND match)
return q.split(/\s+/).every(w => haystack.includes(w));
}
async function refreshJobs() {
const r = await fetch("/api/jobs");
if (!r.ok) return;
const data = await r.json();
const list = $("#jobs-list");
const showUploaded = $("#show-uploaded") && $("#show-uploaded").checked;
const searchQuery = ($("#jobs-search") && $("#jobs-search").value) || "";
const stationFilter = (window._stationFilter !== undefined ? window._stationFilter : "");
// 1) Posodobi števce ob tabih — samo ne-potrjeni (!hidden_after_upload) joby
updateStationCounts(data.jobs);
// 2) Filtriraj za prikaz
const visible = data.jobs.filter(j => {
// Iskanje IGNORIRA hidden filter (vrne tudi naložene), TUDI station filter
if (searchQuery.trim()) return jobMatchesSearch(j, searchQuery);
// Sicer: skrij potrjene (razen če je showUploaded)
if (!showUploaded && j.hidden_after_upload) return false;
// Station filter: prazen string = "Vse", drugače uskladi
if (stationFilter === "") return true;
if (stationFilter === "__none__") return !j.tv_station;
return (j.tv_station || "") === stationFilter;
});
if (!visible.length) {
list.innerHTML = searchQuery.trim()
? `<div class="empty">Ni zadetkov za "${escapeHtml(searchQuery)}"</div>`
: (stationFilter
? `<div class="empty">Ni nepotrjenih reelov za <b>${escapeHtml(stationFilter === "__none__" ? "(brez postaje)" : stationFilter)}</b>.</div>`
: (showUploaded
? '<div class="empty">Še ni obdelav</div>'
: '<div class="empty">Vse obdelano in naloženo. Klikni "Pokaži tudi že naložene" če želiš popraviti.</div>'));
return;
}
list.innerHTML = "";
visible.forEach(j => list.appendChild(buildJobEl(j)));
// Watch any in-progress job
visible.forEach(j => {
if (["queued", "processing", "downloading", "uploaded"].includes(j.status)) {
watchJob(j.id);
}
});
}
// Posodobi števce ob station-filter tabih.
// Šteje samo še-ne-potrjene jobe (!hidden_after_upload).
function updateStationCounts(jobs) {
const tabs = $("#station-filter-tabs");
if (!tabs) return;
const pending = jobs.filter(j => !j.hidden_after_upload);
const counts = {};
pending.forEach(j => {
const k = j.tv_station || "__none__";
counts[k] = (counts[k] || 0) + 1;
});
// Vse
const allEl = tabs.querySelector("[data-cnt-all]");
if (allEl) allEl.textContent = pending.length;
// Posamezne postaje
tabs.querySelectorAll("[data-cnt]").forEach(el => {
const key = el.dataset.cnt;
el.textContent = counts[key] || 0;
});
// Skrij/show "(brez postaje)" tab — samo če imamo kakšen brez postaje
const noneTab = tabs.querySelector('[data-station="__none__"]');
if (noneTab) noneTab.style.display = (counts["__none__"] || 0) > 0 ? "" : "none";
}
// Toggle pokaži/skrij že naložene + iskalnik
document.addEventListener("DOMContentLoaded", () => {
const toggle = $("#show-uploaded");
if (toggle) toggle.addEventListener("change", refreshJobs);
const search = $("#jobs-search");
if (search) {
let searchTimer = null;
search.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(refreshJobs, 150);
});
}
// Station filter tabs
const stationTabs = $$(".station-filter-tab");
// Restore from localStorage
try {
const saved = localStorage.getItem("reels_jobs_station_filter");
if (saved !== null) window._stationFilter = saved;
} catch (e) {}
// Apply visual active state
stationTabs.forEach(t => {
const isActive = (t.dataset.station || "") === (window._stationFilter || "");
t.classList.toggle("active", isActive);
t.style.borderColor = isActive ? "var(--accent)" : "#444";
t.style.background = isActive ? "var(--accent)" : "transparent";
t.style.color = isActive ? "#fff" : (t.dataset.station === "__none__" ? "#888" : "#ccc");
});
// Click handler
stationTabs.forEach(tab => {
tab.addEventListener("click", () => {
const station = tab.dataset.station || "";
window._stationFilter = station;
try { localStorage.setItem("reels_jobs_station_filter", station); } catch(e) {}
// Vizualno označi
stationTabs.forEach(t => {
const active = t === tab;
t.classList.toggle("active", active);
t.style.borderColor = active ? "var(--accent)" : "#444";
t.style.background = active ? "var(--accent)" : "transparent";
t.style.color = active ? "#fff" : (t.dataset.station === "__none__" ? "#888" : "#ccc");
});
refreshJobs();
});
});
});
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;
// Vizualni hint če je že naložen na Nextcloud
if (job.nextcloud_status === "uploaded") {
el.style.borderLeft = "3px solid #4ade80";
el.style.background = "rgba(74,222,128,0.04)";
}
// Prikazi: parsed_artist — parsed_title (če obstaja, za YT in upload jobe enako),
// sicer YT naslov (youtube_title), sicer YT URL, sicer filename, sicer id
const title = (job.parsed_artist && job.parsed_title)
? `${job.parsed_artist}${job.parsed_title}`
: (job.youtube_title
|| job.youtube_url
|| 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>`);
// Nextcloud upload — različni stanja:
const nc = job.nextcloud_status;
if (nc === "uploaded") {
actions.push(`<button class="small ghost" data-action="nextcloud" data-id="${job.id}" title="Že naloženo — klikni da naložiš ponovno" style="border-color:#4ade80; color:#4ade80;">☁ ✓ Nextcloud</button>`);
} else if (nc === "uploading") {
actions.push(`<button class="small ghost" disabled title="Nalagam...">☁ ⏳ Nalagam...</button>`);
} else if (nc === "failed") {
actions.push(`<button class="small ghost" data-action="nextcloud" data-id="${job.id}" title="Napaka: ${escapeHtml(job.nextcloud_error || '')}" style="border-color:#ef4444; color:#ef4444;">☁ ✕ Poskusi znova</button>`);
} else {
actions.push(`<button class="small ghost" data-action="nextcloud" data-id="${job.id}" title="Naloži v Nextcloud /folxspeed/REELS/" style="border-color:#3b82f6; color:#3b82f6;">☁ Nextcloud</button>`);
}
}
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
// TV station label (brez emoji)
const tvStation = job.tv_station || "FOLX SLOVENIJA";
const tvBadge = `<span style="font-size:10px; padding:2px 6px; background:rgba(255,107,107,0.15); border:1px solid rgba(255,107,107,0.4); border-radius:3px; color:#ff8c8c; font-weight:600; white-space:nowrap;">${escapeHtml(tvStation)}</span>`;
el.innerHTML = `
<div class="job-head">
<div class="job-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
<div style="display:flex; gap:6px; align-items:center;">
${tvBadge}
<span class="badge ${job.status}">${statusLabel}</span>
</div>
</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 === "nextcloud") {
uploadToNextcloud(id, title);
} else if (action === "delete") {
deleteJob(id);
}
});
async function uploadToNextcloud(id, title) {
// Optimistic UI: takoj zamrzni vse Nextcloud gumbe za ta job in pokazi "Posiljam..."
const btns = document.querySelectorAll(`button[data-action="nextcloud"][data-id="${id}"]`);
const orig = [];
btns.forEach(b => {
orig.push({ btn: b, html: b.innerHTML, disabled: b.disabled, style: b.getAttribute("style") || "" });
b.disabled = true;
b.innerHTML = '⏳ Pošiljam...';
b.setAttribute("style", "border-color:#f59e0b; color:#f59e0b; opacity:0.85; cursor:wait;");
});
try {
const r = await fetch(`/api/jobs/${id}/upload-nextcloud`, { method: "POST" });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
alert("❌ Napaka: " + (err.detail || r.status));
// Vrni original state pri napaki
orig.forEach(o => { o.btn.disabled = o.disabled; o.btn.innerHTML = o.html; o.btn.setAttribute("style", o.style); });
return;
}
await r.json();
// refreshJobs() bo zamenjal gumb v zelen ✓
await refreshJobs();
} catch (e) {
alert("❌ Napaka: " + e.message);
orig.forEach(o => { o.btn.disabled = o.disabled; o.btn.innerHTML = o.html; o.btn.setAttribute("style", o.style); });
}
}
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; flex-wrap:wrap;">
<button class="primary" id="preview-btn" onclick="previewSelection()" title="Predvajaj cel označen odsek" style="background:var(--accent); padding:8px 16px;">▶ Predvajaj cel</button>
<button class="small" onclick="previewLast5()" title="Predvajaj zadnjih 5s odseka — preverit konec" style="background:#4a8; color:#fff; padding:8px 14px;">▶ Konec (5s)</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;
// Fiksna sredina za marker assignment — original LLM-jev clip center
// (NE se ne spreminja ko user drag-a handle-je, da klik blizu OUT
// vedno premakne OUT, ne glede koliko user razširi clip)
const initialCenter = (startInit + endInit) / 2;
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;
}
// Sync markerji: če čez initialCenter, reset (zelen sme samo levo, rdeč desno)
if (markerInTime > initialCenter - 0.1) markerInTime = trimStart;
if (markerOutTime < initialCenter + 0.1) markerOutTime = trimEnd;
renderTrim();
renderMarkers();
}
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;
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
e.preventDefault();
if (video) {
if (video.paused) {
// STROGO: zelen IN samo v LEVI polovici, rdeč OUT samo v DESNI polovici
// Center = ORIGINAL (LLM-jev clip center) — NE se NE spreminja!
// Tako, če user razširi clip, klik blizu OUT še vedno → OUT marker
const t = video.currentTime;
const center = initialCenter;
if (t < center) {
markerInTime = Math.min(t, center - 0.1);
} else {
markerOutTime = Math.max(t, center + 0.1);
}
console.log("[Marker]", { t, center, trimStart, trimEnd, markerInTime, markerOutTime });
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);
});
};
// ─── PREVIEW LAST 5s: samo zadnjih 5 sekund odseka — za preverit konec ───
window.previewLast5 = function() {
if (!video) return;
// Skoči 5s pred konec odseka
const startFrom = Math.max(trimStart, trimEnd - 5);
video.currentTime = startFrom;
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
// Dodaj timestamp cache-buster da browser ne servira starega cached output-a
const cacheBust = Date.now();
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}?v=${cacheBust}" controls autoplay playsinline></video>
${title ? `<div class="modal-title">${title}</div>` : ""}
<div class="modal-actions">
<button class="primary" onclick="window.open('/api/download/${id}?v=${cacheBust}')">⬇ 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>