diff --git a/public/css/mobile.css b/public/css/mobile.css index 64aa66c..f06deb9 100644 --- a/public/css/mobile.css +++ b/public/css/mobile.css @@ -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; } diff --git a/public/js/control.js b/public/js/control.js index 1f32c3b..8d1d36e 100644 --- a/public/js/control.js +++ b/public/js/control.js @@ -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 = '' + mediaIcon(it.kind) + it.name + ''; + const card = document.createElement('div'); card.className = 'qcard' + (i === qIndex ? ' active' : ''); + const head = document.createElement('div'); head.className = 'item'; + head.innerHTML = '' + mediaIcon(it.kind) + it.name + ''; + 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 = + '