Mobile remote: per-track trim (in/out) + waveform + times + scrub/seek + gap
- Queue cards expand to a detail panel with the waveform, elapsed/total/remaining times, draggable in/out handles, scrub-to-seek and a 'pausa dopo' gap. - /api/peaks now also returns exact duration (samples/8000) so non-playing tracks have correct times/trim without relying on the output media probe. - Service worker switched to network-first (+v2) so UI updates aren't masked by a stale cache; output.js probeDurations hardened (load()+timeout fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3f4d0f81e
commit
c0230ee1d6
5 files changed
+167
-19
No files matched your search
@@ -52,6 +52,20 @@ textarea { width: 100%; min-height: 90px; resize: vertical; }
|
||||
#fx-list { max-height: 52vh; overflow-y: auto; }
|
||||
#lib { max-height: 40vh; overflow-y: auto; }
|
||||
|
||||
.qcard { display: flex; flex-direction: column; gap: 0; }
|
||||
.qcard.active > .item { border-color: var(--accent); background: rgba(110,150,255,.18); }
|
||||
.qdetail { background: rgba(0,0,0,.25); border: 1px solid var(--line); border-top: none; border-radius: 0 0 11px 11px; padding: 10px; margin-top: -6px; }
|
||||
.qtimes { display: flex; gap: 10px; font-size: 10px; font-variant-numeric: tabular-nums; margin-bottom: 6px; }
|
||||
.qtimes .tt-el { color: #9be6a0; } .qtimes .tt-rem { color: #ffb3b3; margin-left: auto; }
|
||||
.qdetail .wave { display: block; width: 100%; height: 46px; background: rgba(0,0,0,.3); border-radius: 6px; touch-action: none; cursor: pointer; }
|
||||
.qtrim { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11px; flex-wrap: wrap; }
|
||||
.qtrim .lblg { color: #9be6a0; } .qtrim .lblr { color: #ffb3b3; }
|
||||
.qtrim b { font-variant-numeric: tabular-nums; }
|
||||
.qtrim button { padding: 6px 9px; }
|
||||
.qtrim .rst { margin-left: auto; }
|
||||
.qgap { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11px; opacity: .85; }
|
||||
.qgap input { width: 56px; text-align: center; padding: 6px 4px; }
|
||||
|
||||
.nowbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin-bottom: 12px; }
|
||||
.nowbar.hidden { display: none; }
|
||||
.np-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
|
||||
|
||||
+131
-10
@@ -60,6 +60,87 @@ $('#svg-sel').addEventListener('change', (e) => {
|
||||
|
||||
// ---- Media / Playlist ------------------------------------------------------
|
||||
let library = [], queue = [], qIndex = -1, playing = false, curDur = 0, curCur = 0;
|
||||
const durCache = {}; // url -> seconds
|
||||
const wavePeaks = {}; // file(base) -> peaks[] | 'loading' | null
|
||||
const WAVE_BUCKETS = 400;
|
||||
let openDetail = -1; // queue index whose trim/waveform panel is open
|
||||
let gapTimer = null;
|
||||
const baseName = (u) => decodeURIComponent((u || '').split('/').pop());
|
||||
|
||||
function probeDur(url) { if (url && !(url in durCache)) send({ type: 'probeDurations', paths: [url] }); }
|
||||
function ensureWave(it) {
|
||||
const file = baseName(it.url);
|
||||
if (!file || wavePeaks[file]) return;
|
||||
wavePeaks[file] = 'loading';
|
||||
djv.peaks(file, WAVE_BUCKETS).then((res) => {
|
||||
const peaks = Array.isArray(res) ? res : (res && res.peaks);
|
||||
wavePeaks[file] = (peaks && peaks.length) ? peaks : null;
|
||||
if (res && res.duration && !(it.url in durCache)) durCache[it.url] = res.duration; // exact duration from the server
|
||||
renderQueue();
|
||||
}).catch(() => { wavePeaks[file] = null; });
|
||||
}
|
||||
function drawWave(canvas, it, isCur) {
|
||||
const peaks = wavePeaks[baseName(it.url)];
|
||||
const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height, mid = h / 2;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!Array.isArray(peaks)) { ctx.fillStyle = 'rgba(255,255,255,.08)'; ctx.fillRect(0, mid - 1, w, 2); return; }
|
||||
const dur = durCache[it.url] || (isCur ? curDur : 0);
|
||||
const s = it.start || 0, e = it.end > 0 ? it.end : dur;
|
||||
const sx = dur > 0 ? s / dur * w : 0, ex = dur > 0 ? e / dur * w : w, N = peaks.length;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const bar = Math.max(1, peaks[Math.min(N - 1, Math.floor(x / w * N))] * h * 0.46);
|
||||
ctx.fillStyle = (x >= sx && x <= ex) ? 'rgba(140,182,255,.9)' : 'rgba(140,182,255,.2)';
|
||||
ctx.fillRect(x, mid - bar, 1, bar * 2);
|
||||
}
|
||||
if (dur > 0) { ctx.fillStyle = '#6ee7a0'; ctx.fillRect(sx, 0, 2, h); ctx.fillStyle = '#ff9a9a'; ctx.fillRect(ex - 2, 0, 2, h); }
|
||||
if (isCur && curDur > 0) { ctx.fillStyle = '#fff'; ctx.fillRect(Math.min(w, curCur / curDur * w), 0, 1.5, h); }
|
||||
}
|
||||
// Pointer on the waveform: grab a trim handle if near one, else seek/scrub.
|
||||
function wireWave(canvas, it, i) {
|
||||
const HANDLE = 12; let mode = null;
|
||||
const dur = () => durCache[it.url] || (i === qIndex ? curDur : 0) || 0;
|
||||
const tAt = (e) => { const r = canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * dur(); };
|
||||
const refreshLabels = () => { const d = canvas.parentNode; d.querySelector('.t-in').textContent = fmt(it.start || 0); d.querySelector('.t-out').textContent = it.end > 0 ? fmt(it.end) : 'fine'; };
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
const d = dur(), r = canvas.getBoundingClientRect(), x = e.clientX - r.left;
|
||||
const sx = d ? (it.start || 0) / d * r.width : 0, ex = d ? (it.end > 0 ? it.end : d) / d * r.width : r.width;
|
||||
if (d && Math.abs(x - sx) <= HANDLE) mode = 'start'; else if (d && Math.abs(x - ex) <= HANDLE) mode = 'end'; else mode = 'seek';
|
||||
canvas.setPointerCapture(e.pointerId); e.preventDefault();
|
||||
if (mode === 'seek' && i === qIndex) { curCur = tAt(e); send({ type: 'seek', time: curCur }); drawWave(canvas, it, true); }
|
||||
});
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (!mode) return; const t = tAt(e);
|
||||
if (mode === 'start') { it.start = Math.max(0, Math.min(t, (it.end > 0 ? it.end : dur()) - 0.5)); }
|
||||
else if (mode === 'end') { it.end = Math.min(dur(), Math.max(t, (it.start || 0) + 0.5)); }
|
||||
else { if (i === qIndex) { curCur = t; send({ type: 'seek', time: t }); drawWave(canvas, it, true); } return; }
|
||||
drawWave(canvas, it, i === qIndex); refreshLabels();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!mode) return;
|
||||
if (mode !== 'seek') { if (i === qIndex) send({ type: 'setTrim', start: it.start || 0, end: it.end || 0 }); }
|
||||
else if (i !== qIndex) playAt(i, tAt(e));
|
||||
mode = null;
|
||||
};
|
||||
canvas.addEventListener('pointerup', end);
|
||||
canvas.addEventListener('pointercancel', () => { mode = null; });
|
||||
}
|
||||
function clearGap() { if (gapTimer) { clearTimeout(gapTimer); gapTimer = null; } }
|
||||
function advanceQueue() { if (qIndex + 1 < queue.length) playAt(qIndex + 1); else { playing = false; $('#np-play').textContent = '▶'; } }
|
||||
function afterTrackEnd() {
|
||||
const it = queue[qIndex];
|
||||
if (it && it.gap > 0) gapTimer = setTimeout(() => { gapTimer = null; advanceQueue(); }, it.gap * 1000);
|
||||
else advanceQueue();
|
||||
}
|
||||
function updateOpenDetail() {
|
||||
if (openDetail !== qIndex || qIndex < 0) return;
|
||||
const card = document.querySelectorAll('#queue .qcard')[qIndex]; if (!card) return;
|
||||
const det = card.querySelector('.qdetail'); if (!det) return;
|
||||
const it = queue[qIndex], canvas = det.querySelector('.wave'); if (canvas) drawWave(canvas, it, true);
|
||||
const tot = Math.max(0, (it.end > 0 ? it.end : (durCache[it.url] || curDur)) - (it.start || 0));
|
||||
const el = Math.max(0, curCur - (it.start || 0));
|
||||
det.querySelector('.tt-el').textContent = '▶ ' + fmt(el);
|
||||
det.querySelector('.tt-rem').textContent = '⧗ -' + fmt(Math.max(0, tot - el));
|
||||
}
|
||||
function loadLibrary() { djv.listMedia().then((items) => { library = items; renderLib(); }); }
|
||||
function mediaIcon(k) { return k === 'video' ? '🎞 ' : (k === 'image' ? '🖼 ' : '🎵 '); }
|
||||
function renderLib() {
|
||||
@@ -83,19 +164,55 @@ function renderQueue() {
|
||||
$('#queue-count').textContent = queue.length;
|
||||
const el = $('#queue'); el.innerHTML = '';
|
||||
queue.forEach((it, i) => {
|
||||
const d = document.createElement('div'); d.className = 'item' + (i === qIndex ? ' active' : '');
|
||||
d.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
const card = document.createElement('div'); card.className = 'qcard' + (i === qIndex ? ' active' : '');
|
||||
const head = document.createElement('div'); head.className = 'item';
|
||||
head.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
head.addEventListener('click', () => playAt(i));
|
||||
if (it.kind !== 'image') {
|
||||
const cog = document.createElement('button'); cog.textContent = '⚙'; cog.title = 'Trim / forma d\'onda';
|
||||
cog.addEventListener('click', (ev) => { ev.stopPropagation(); openDetail = openDetail === i ? -1 : i; renderQueue(); });
|
||||
head.appendChild(cog);
|
||||
}
|
||||
const del = document.createElement('button'); del.textContent = '✕';
|
||||
del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; renderQueue(); });
|
||||
d.appendChild(del);
|
||||
d.addEventListener('click', () => playAt(i));
|
||||
el.appendChild(d);
|
||||
del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; if (openDetail === i) openDetail = -1; renderQueue(); });
|
||||
head.appendChild(del);
|
||||
card.appendChild(head);
|
||||
|
||||
if (openDetail === i && it.kind !== 'image') {
|
||||
probeDur(it.url); ensureWave(it);
|
||||
const dur = durCache[it.url] || (i === qIndex ? curDur : 0);
|
||||
const elp = i === qIndex ? Math.max(0, curCur - (it.start || 0)) : 0;
|
||||
const tot = Math.max(0, (it.end > 0 ? it.end : dur) - (it.start || 0));
|
||||
const det = document.createElement('div'); det.className = 'qdetail';
|
||||
det.innerHTML =
|
||||
'<div class="qtimes"><span class="tt-el">▶ ' + fmt(elp) + '</span>' +
|
||||
'<span class="tt-tot">⏱ ' + fmt(tot) + '</span>' +
|
||||
'<span class="tt-rem">⧗ -' + fmt(Math.max(0, tot - elp)) + '</span></div>' +
|
||||
'<canvas class="wave" width="' + WAVE_BUCKETS + '" height="46" title="Clic/trascina = sposta riproduzione · maniglie 🟢🔴 = inizio/fine"></canvas>' +
|
||||
'<div class="qtrim"><span class="lblg">Inizio <b class="t-in">' + fmt(it.start || 0) + '</b></span>' +
|
||||
'<button class="here-s" title="Inizio = punto attuale">📍</button>' +
|
||||
'<span class="lblr">Fine <b class="t-out">' + (it.end > 0 ? fmt(it.end) : 'fine') + '</b></span>' +
|
||||
'<button class="here-e" title="Fine = punto attuale">📍</button>' +
|
||||
'<button class="rst" title="Azzera">↺</button></div>' +
|
||||
'<div class="qgap">⏸ Pausa dopo <input class="gapv" type="number" min="0" step="1" value="' + (it.gap || 0) + '"> s</div>';
|
||||
card.appendChild(det);
|
||||
const canvas = det.querySelector('.wave');
|
||||
drawWave(canvas, it, i === qIndex); wireWave(canvas, it, i);
|
||||
det.querySelector('.here-s').addEventListener('click', () => { if (i === qIndex) { it.start = Math.max(0, curCur); send({ type: 'setTrim', start: it.start, end: it.end || 0 }); renderQueue(); } });
|
||||
det.querySelector('.here-e').addEventListener('click', () => { if (i === qIndex) { it.end = Math.max(0.5, curCur); send({ type: 'setTrim', start: it.start || 0, end: it.end }); renderQueue(); } });
|
||||
det.querySelector('.rst').addEventListener('click', () => { it.start = 0; it.end = 0; if (i === qIndex) send({ type: 'setTrim', start: 0, end: 0 }); renderQueue(); });
|
||||
det.querySelector('.gapv').addEventListener('change', (e) => { it.gap = Math.max(0, parseFloat(e.target.value) || 0); });
|
||||
}
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
function playAt(i) {
|
||||
function playAt(i, fromTime) {
|
||||
if (i < 0 || i >= queue.length) return;
|
||||
clearGap();
|
||||
qIndex = i; const it = queue[i]; playing = true;
|
||||
send({ type: it.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: it.url, start: 0, end: 0 });
|
||||
probeDur(it.url);
|
||||
const startAt = fromTime != null ? Math.max(0, fromTime) : (it.start || 0);
|
||||
send({ type: it.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: it.url, start: startAt, end: it.end || 0 });
|
||||
$('#np-name').textContent = it.name; $('#np-play').textContent = '⏸';
|
||||
$('#nowbar').classList.remove('hidden'); renderQueue();
|
||||
}
|
||||
@@ -183,13 +300,17 @@ djv.onReport((m) => {
|
||||
curCur = m.currentTime; curDur = m.duration;
|
||||
$('#np-time').textContent = fmt(curCur) + ' / ' + fmt(curDur);
|
||||
if (curDur > 0) $('#seek').value = Math.round(curCur / curDur * 1000);
|
||||
updateOpenDetail();
|
||||
break;
|
||||
case 'durations':
|
||||
(m.list || []).forEach((d) => { durCache[d.path] = d.duration; });
|
||||
if (openDetail >= 0) renderQueue();
|
||||
break;
|
||||
case 'playState':
|
||||
playing = m.playing; $('#np-play').textContent = playing ? '⏸' : '▶';
|
||||
break;
|
||||
case 'trackEnded':
|
||||
if (qIndex >= 0 && qIndex + 1 < queue.length) playAt(qIndex + 1);
|
||||
else { playing = false; $('#np-play').textContent = '▶'; }
|
||||
afterTrackEnd();
|
||||
break;
|
||||
case 'meters':
|
||||
setMeter('m-bass', m.bass); setMeter('m-mid', m.mid); setMeter('m-treble', m.treble);
|
||||
|
||||
+7
-3
@@ -287,10 +287,14 @@ function probeDurations(paths) {
|
||||
paths.forEach(p => {
|
||||
// A <video> element reads duration for both audio and video files.
|
||||
const a = document.createElement('video');
|
||||
a.preload = 'metadata';
|
||||
a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
|
||||
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
|
||||
a.preload = 'metadata'; a.muted = true;
|
||||
let done = false;
|
||||
const ok = (dur) => { if (done) return; done = true; results.push({ path: p, duration: isFinite(dur) && dur > 0 ? dur : 0 }); finish(); };
|
||||
a.onloadedmetadata = () => ok(a.duration);
|
||||
a.onerror = () => ok(0);
|
||||
setTimeout(() => ok(0), 6000); // never hang if metadata never fires
|
||||
a.src = toFileURL(p);
|
||||
a.load(); // some Chromium builds need an explicit load() for detached media
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -1,6 +1,7 @@
|
||||
// Minimal app-shell cache so the remote installs as a PWA and the UI loads fast.
|
||||
// API, media and websockets are always fetched from the network (never cached).
|
||||
const CACHE = 'djv-shell-v1';
|
||||
// App-shell caching for installability/offline. The server is local and fast,
|
||||
// so we use NETWORK-FIRST for the shell (always get the latest UI after a
|
||||
// redeploy) and fall back to cache only when offline. API/media never cached.
|
||||
const CACHE = 'djv-shell-v2';
|
||||
const SHELL = [
|
||||
'./', 'index.html', 'css/mobile.css',
|
||||
'js/net.js', 'js/control.js', 'js/engine/shaders.js', 'js/engine/effects.js',
|
||||
@@ -15,5 +16,11 @@ self.addEventListener('activate', (e) => {
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.pathname.startsWith('/api') || url.pathname.startsWith('/media')) return;
|
||||
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
|
||||
e.respondWith(
|
||||
fetch(e.request).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, copy)).catch(() => {});
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request))
|
||||
);
|
||||
});
|
||||
@@ -103,8 +103,10 @@ app.get('/api/peaks', (req, res) => {
|
||||
peaks[b] = m; if (m > top) top = m;
|
||||
}
|
||||
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
|
||||
peaksCache.set(key, peaks);
|
||||
res.json(peaks);
|
||||
// Exact duration from the decoded mono 8 kHz stream: samples / 8000.
|
||||
const out = { peaks, duration: n / 8000 };
|
||||
peaksCache.set(key, out);
|
||||
res.json(out);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user