diff --git a/editor.py b/editor.py
index 3751f89..4805861 100644
--- a/editor.py
+++ b/editor.py
@@ -288,6 +288,10 @@ h1 { margin:0 0 6px; font-size:28px; }
.playhead { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:#ffcc33; box-shadow:0 0 5px #000; z-index:4; pointer-events:none; }
.handle { position:absolute; top:0; bottom:0; width:18px; margin-left:-9px; cursor:ew-resize; z-index:3; touch-action:none; }
.handle::after { content:""; position:absolute; left:7px; top:0; bottom:0; width:4px; background:#fff; border-radius:2px; box-shadow:0 0 5px #000; }
+.view-scroll { position:relative; width:100%; height:14px; margin-top:5px; background:#292929; border-radius:7px; overflow:hidden; touch-action:none; cursor:grab; }
+.view-scroll-thumb { position:absolute; top:2px; bottom:2px; left:0; min-width:14px; background:#777; border-radius:5px; cursor:grab; touch-action:none; }
+.view-scroll-thumb:hover { background:#999; }
+.view-scroll-thumb.dragging { background:#aaa; cursor:grabbing; }
.time-row { display:flex; justify-content:space-between; margin-top:8px; color:#aaa; font-size:14px; }
.controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:18px; }
button, a.button { border:0; border-radius:6px; padding:11px 16px; background:#333; color:#fff; cursor:pointer; text-decoration:none; font-size:15px; }
@@ -314,6 +318,8 @@ button:disabled { opacity:.45; cursor:not-allowed; }
body { padding:12px; }
.editor { padding:12px; }
.wave-wrap { height:180px; }
+ .view-scroll { height:18px; }
+ .view-scroll-thumb { min-width:28px; }
.zoom { width:100%; margin-left:0; }
.values { grid-template-columns:1fr; }
.position { width:100%; margin-left:0; }
@@ -335,6 +341,9 @@ button:disabled { opacity:.45; cursor:not-allowed; }
+
00:00:00
{{ duration }}
@@ -392,6 +401,12 @@ const endHandle = document.getElementById('endHandle');
const selection = document.getElementById('selection');
const playhead = document.getElementById('playhead');
const audio = document.getElementById('audio');
+const viewScroll = document.getElementById('viewScroll');
+const viewScrollThumb = document.getElementById('viewScrollThumb');
+let scrollbarDragging = false;
+let scrollbarPointerId = null;
+let scrollbarStartX = 0;
+let scrollbarStartView = 0;
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
@@ -428,6 +443,14 @@ function render() {
document.getElementById('viewStart').textContent = fmt(viewStart);
document.getElementById('viewEnd').textContent = fmt(viewEnd);
document.getElementById('zoomValue').textContent = 'Zoom: '+Math.round(zoom*100)+' %';
+
+ // The scrollbar thumb represents the currently visible part of the full recording.
+ const visiblePercent = Math.max(0, Math.min(100, ((viewEnd-viewStart)/duration)*100));
+ const offsetPercent = Math.max(0, Math.min(100-visiblePercent, (viewStart/duration)*100));
+ viewScrollThumb.style.width = visiblePercent+'%';
+ viewScrollThumb.style.left = offsetPercent+'%';
+ viewScrollThumb.style.pointerEvents = zoom > 1 ? 'auto' : 'none';
+ viewScrollThumb.classList.toggle('dragging', scrollbarDragging);
renderPlayhead();
}
function renderPlayhead() {
@@ -461,6 +484,60 @@ function resetZoom() {
viewEnd = duration;
render();
}
+
+function moveViewTo(newStart) {
+ const span = viewEnd-viewStart;
+ if (span >= duration) {
+ viewStart = 0;
+ viewEnd = duration;
+ } else {
+ viewStart = Math.max(0, Math.min(duration-span, newStart));
+ viewEnd = viewStart+span;
+ }
+ render();
+}
+
+viewScrollThumb.addEventListener('pointerdown', e => {
+ if (zoom <= 1) return;
+ e.preventDefault();
+ e.stopPropagation();
+ scrollbarDragging = true;
+ scrollbarPointerId = e.pointerId;
+ scrollbarStartX = e.clientX;
+ scrollbarStartView = viewStart;
+ viewScrollThumb.setPointerCapture(e.pointerId);
+ render();
+});
+viewScrollThumb.addEventListener('pointermove', e => {
+ if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
+ const trackWidth = viewScroll.clientWidth;
+ const thumbWidth = viewScrollThumb.offsetWidth;
+ const movableWidth = Math.max(1, trackWidth-thumbWidth);
+ const span = viewEnd-viewStart;
+ const deltaPixels = e.clientX-scrollbarStartX;
+ const deltaTime = (deltaPixels/movableWidth) * (duration-span);
+ moveViewTo(scrollbarStartView+deltaTime);
+ e.preventDefault();
+});
+function endScrollbarDrag(e) {
+ if (!scrollbarDragging || e.pointerId !== scrollbarPointerId) return;
+ scrollbarDragging = false;
+ scrollbarPointerId = null;
+ render();
+}
+viewScrollThumb.addEventListener('pointerup', endScrollbarDrag);
+viewScrollThumb.addEventListener('pointercancel', endScrollbarDrag);
+
+// Clicking/touching the empty part of the scrollbar moves the visible window
+// so that the clicked position becomes its center.
+viewScroll.addEventListener('pointerdown', e => {
+ if (zoom <= 1 || e.target === viewScrollThumb) return;
+ e.preventDefault();
+ const r = viewScroll.getBoundingClientRect();
+ const ratio = Math.max(0, Math.min(1, (e.clientX-r.left)/r.width));
+ const span = viewEnd-viewStart;
+ moveViewTo(ratio*duration-span/2);
+});
wave.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1/ZOOM_FACTOR;