Edit modal: zoom + play-from-position + Space toggle

User feedback:
1. 'Wave form je premajhen — zoom'
2. 'Ko nastavimo pozicijo, play od začetka — ne moremo predvajat od tam'

NEW Zoom feature:
- 5 zoom levels: 1x, 2x, 5x, 10x, 20x
- Trim bar wrapped in scrollable container
- On zoom: bar width grows to 100*N%, scroll auto-centers on trim region
- Higher zoom = more pixels per second = micro-tuning possible
  (1x: 5px/s, 20x: 100px/s for 4min song)
- Active zoom button highlighted accent red

NEW Play-from-position:
- Click on waveform/trim bar = playhead JUMPS THERE + auto-plays
  (was: just moved playhead, no play)
- Space key = play/pause toggle from current position
  (works anywhere except in input fields)
- '▶ Predvajaj odsek' still does start-to-end of selection
- Cleanup keydown listener on modal close

Waveform now rendered at 2400x72 (higher res) so zoom looks crisp.

User can now:
- Zoom 10x to see exact word boundaries in waveform
- Click anywhere → instant play from there
- Hit Space to toggle while watching
This commit is contained in:
Sebastjan Artič 2026-04-30 12:37:06 +00:00
parent facfd6bd39
commit 47a114ce6a

View File

@ -1054,30 +1054,44 @@
<!-- iPhone-style trim bar + WAVEFORM spodaj (full width) -->
<div style="margin-top:18px;">
<!-- Trim bar -->
<div id="trim-bar" style="position:relative; height:72px; width:100%; flex-shrink:0; box-sizing:border-box; background:#1a1a1a; border:2px solid #444; border-radius:8px 8px 0 0; overflow:hidden; user-select:none; touch-action:none;">
<!-- Waveform image (background) -->
<img id="trim-waveform" src="/api/waveform/${jobId}?width=1200&height=72" style="position:absolute; top:0; left:0; width:100%; height:100%; opacity:0.6; pointer-events:none; z-index:0;" 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>
<!-- 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 na valove = skoči + predvaja · Space = play/pause</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>
<!-- Playhead -->
<div id="trim-playhead" style="position:absolute; top:-4px; bottom:-4px; left:0%; width:3px; background:#fff; z-index:2; pointer-events:none; opacity:0.9; box-shadow:0 0 6px #fff;"></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>
<!-- 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>
<!-- Playhead -->
<div id="trim-playhead" style="position:absolute; top:-4px; bottom:-4px; left:0%; width:3px; background:#fff; z-index:2; pointer-events:none; opacity:0.8; box-shadow:0 0 4px #fff;"></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>
<!-- Hint -->
@ -1212,13 +1226,71 @@
document.addEventListener("mouseup", onPointerUp);
document.addEventListener("touchend", onPointerUp);
// Click anywhere on trim bar = seek video
// Click anywhere on trim bar = seek + predvajaj OD TJE (ne od trim start)
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;
if (video) {
video.currentTime = t;
video.play().catch(() => {});
}
});
// ─── 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();
}, 50);
}
document.querySelectorAll(".zoom-btn").forEach(btn => {
btn.addEventListener("click", () => {
applyZoom(parseInt(btn.dataset.zoom));
});
});
// Space tipka = play/pause od trenutne pozicije (brez resetiranja na trim start)
const spaceHandler = (e) => {
if (e.code === "Space" && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
e.preventDefault();
if (video) {
if (video.paused) {
video.play().catch(() => {});
} else {
video.pause();
}
}
}
};
document.addEventListener("keydown", spaceHandler);
// Cleanup ob zaprtju modala
overlay._cleanup = () => {
document.removeEventListener("keydown", spaceHandler);
};
// Update playhead during playback + re-render če videoDuration manjkalo
if (video) {
video.addEventListener("timeupdate", renderPlayhead);
@ -1325,6 +1397,8 @@
requestAnimationFrame(() => {
renderTrim();
renderPlayhead();
// Nastavi 1x kot aktiven gumb
applyZoom(1);
console.log("[EditModal] after renderTrim", {
leftStyle: handleL.style.left,
rightStyle: handleR.style.left,
@ -1433,6 +1507,10 @@
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) {