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.
This commit is contained in:
Sebastjan Artič 2026-04-30 10:34:34 +00:00
parent b4294e7113
commit 9dba2a1185

View File

@ -986,6 +986,14 @@
}
// ─── 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;
@ -1013,41 +1021,56 @@
<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}" controls preload="metadata" style="width:100%; max-height:50vh; background:#000;"></video>
<video id="edit-video" src="/api/source-video/${jobId}" preload="metadata" style="width:100%; max-height:50vh; background:#000; border-radius:6px;"></video>
<div style="display:grid; gap:14px; margin-top:14px;">
<div>
<label style="display:flex; justify-content:space-between; font-size:13px;">
<span>Začetek</span>
<span><b id="edit-start-val">${startInit.toFixed(1)}s</b> · trajanje: <b id="edit-duration">${(endInit-startInit).toFixed(1)}s</b></span>
</label>
<input type="range" id="edit-start" min="0" max="${videoDuration.toFixed(1)}" step="0.1" value="${startInit.toFixed(1)}" style="width:100%;">
</div>
<div>
<label style="display:flex; justify-content:space-between; font-size:13px;">
<span>Konec</span>
<span id="edit-end-val">${endInit.toFixed(1)}s</span>
</label>
<input type="range" id="edit-end" min="0" max="${videoDuration.toFixed(1)}" step="0.1" value="${endInit.toFixed(1)}" style="width:100%;">
</div>
<div style="display:flex; gap:8px;">
<button class="small" onclick="seekEditVideo('start')">▶ Predvajaj od začetka</button>
<button class="small ghost" onclick="seekEditVideo('end')">↪ Skoči na konec</button>
<!-- 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>
<details style="margin-top:8px;">
<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>
<!-- 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>
@ -1068,48 +1091,125 @@
document.addEventListener("keydown", escHandler);
overlay.querySelector(".modal-close").addEventListener("click", closeModal);
// Slider handlers
// ─── iPhone-style trim bar logic ───
const video = document.getElementById("edit-video");
const startSlider = document.getElementById("edit-start");
const endSlider = document.getElementById("edit-end");
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");
function updateUI() {
const s = parseFloat(startSlider.value);
const e = parseFloat(endSlider.value);
startVal.textContent = s.toFixed(1) + "s";
endVal.textContent = e.toFixed(1) + "s";
durVal.textContent = (e - s).toFixed(1) + "s";
// State
let trimStart = startInit;
let trimEnd = endInit;
let dragging = null; // 'left' / 'right' / null
function pctOf(t) {
return (t / videoDuration) * 100;
}
startSlider.addEventListener("input", () => {
if (parseFloat(startSlider.value) >= parseFloat(endSlider.value)) {
startSlider.value = parseFloat(endSlider.value) - 0.5;
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;
}
updateUI();
if (video) video.currentTime = parseFloat(startSlider.value);
});
endSlider.addEventListener("input", () => {
if (parseFloat(endSlider.value) <= parseFloat(startSlider.value)) {
endSlider.value = parseFloat(startSlider.value) + 0.5;
}
updateUI();
if (video) video.currentTime = parseFloat(endSlider.value);
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" ? parseFloat(startSlider.value) : parseFloat(endSlider.value);
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 = parseFloat(startSlider.value);
const end = parseFloat(endSlider.value);
const start = trimStart;
const end = trimEnd;
if (end - start < 5) {
alert("Trajanje mora biti vsaj 5s");
return;