diff --git a/index.html b/index.html index b16d67b..1943511 100644 --- a/index.html +++ b/index.html @@ -32,9 +32,8 @@ canvas { width: 100%; height: 100px; border-radius: 6px; display: block; } .big-time .sep { color: #6b7089; margin: 0 4px; } .canvas-area { display: flex; flex-direction: column; flex: 1; min-width: 200px; gap: 6px; } .progress-row { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #6b7089; font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; } -.progress-row .progress-bar { flex: 1; height: 6px; background: #272c42; border-radius: 3px; cursor: pointer; } -.progress-row .progress-bar .fill { height: 100%; background: #84a0c6; border-radius: 3px; width: 0%; } -.progress-row .progress-bar:hover .fill { background: #91acd1; } +.waveform-wrap { flex: 1; overflow-x: auto; overflow-y: hidden; cursor: pointer; position: relative; height: 42px; } +.waveform-wrap canvas { display: block; height: 42px; } #noiz-overlay { position: fixed; opacity: 0.75; inset: 0; display: flex; justify-content: center; align-items: center; background: transparent; pointer-events: none; z-index: 9999; animation: fadeout 750ms ease 750ms forwards; } #noiz-overlay img { max-width: 50vw; max-height: 50vh; filter: drop-shadow(0 8px 16px rgba(0,0,0,0.5)); } @keyframes fadeout { from { opacity: 0.75; transform: scale(1); } to { opacity: 0; transform: scale(1.75); visibility: hidden; } } @@ -141,8 +140,8 @@ footer .footer-track { flex: 1; min-width: 0; white-space: nowrap; overflow: hid
0:00 -
-
+
+
0:00
@@ -571,6 +570,91 @@ function drawSpectrum() { animId = requestAnimationFrame(drawSpectrum); } drawSpectrum(); +const WF_PPS = 6; +const WF_HEIGHT = 42; +function computeWaveform(t) { + if (t.waveform || !t.file) return; + fetch(t.url) + .then(r => r.arrayBuffer()) + .then(buf => { + if (!audioCtx) initAudioCtx(); + return audioCtx.decodeAudioData(buf); + }) + .then(ab => { + const ch = ab.getChannelData(0); + const len = Math.ceil(t.playtime * WF_PPS); + const step = Math.max(1, Math.floor(ch.length / len)); + const data = new Float32Array(len * 2); + for (let i = 0; i < len; i++) { + let mn = 0, mx = 0; + const start = i * step; + const end = Math.min(start + step, ch.length); + for (let j = start; j < end; j++) { + if (ch[j] < mn) mn = ch[j]; + if (ch[j] > mx) mx = ch[j]; + } + data[i * 2] = mn; + data[i * 2 + 1] = mx; + } + t.waveform = { len, data }; + drawWaveform(); + }) + .catch(() => {}); +} +function drawWaveform() { + const t = tracks[currentIndex]; + const dpr = window.devicePixelRatio || 1; + const wrap = waveformWrap; + const visWidth = wrap.clientWidth; + const canvas = waveformCanvas; + const h = WF_HEIGHT; + canvas.style.height = h + 'px'; + if (!t || !t.waveform) { + canvas.style.width = visWidth + 'px'; + canvas.width = Math.round(visWidth * dpr); + canvas.height = Math.round(h * dpr); + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, visWidth, h); + return; + } + const wf = t.waveform; + const totalWidth = wf.len; + const newWidth = Math.max(totalWidth, visWidth); + canvas.style.width = newWidth + 'px'; + canvas.style.height = h + 'px'; + canvas.width = Math.round(newWidth * dpr); + canvas.height = Math.round(h * dpr); + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, newWidth, h); + const halfH = h / 2; + const ampScale = halfH * 0.85; + let playheadX = -1; + if (audio && audio.duration && t.playtime) { + const pct = audio.currentTime / audio.duration; + playheadX = pct * totalWidth; + } + const data = wf.data; + for (let i = 0; i < wf.len; i++) { + const mn = data[i * 2]; + const mx = data[i * 2 + 1]; + const y1 = halfH + mn * ampScale; + const y2 = halfH + mx * ampScale; + ctx.fillStyle = playheadX >= 0 && i < playheadX ? '#6b88b0' : '#84a0c6'; + ctx.fillRect(i, y1, 1, Math.max(1, y2 - y1)); + } + if (playheadX >= 0) { + ctx.strokeStyle = '#e27878'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(playheadX, 2); + ctx.lineTo(playheadX, h - 2); + ctx.stroke(); + const targetScroll = playheadX - visWidth / 2; + wrap.scrollLeft = Math.max(0, Math.min(targetScroll, totalWidth - visWidth)); + } +} let tracks = []; let currentIndex = -1; let selectedIndices = new Set(); @@ -597,8 +681,8 @@ const trackArtist = document.getElementById('trackArtist'); const playBtn = document.getElementById('playBtn'); const prevBtn = document.getElementById('prevBtn'); const nextBtn = document.getElementById('nextBtn'); -const progressFill = document.getElementById('progressFill'); -const progressBar = document.getElementById('progressBar'); +const waveformCanvas = document.getElementById('waveform'); +const waveformWrap = document.getElementById('waveformWrap'); const timeCurrent = document.getElementById('timeCurrent'); const timeRemaining = document.getElementById('timeRemaining'); const progressRow = document.getElementById('progressRow'); @@ -1313,6 +1397,7 @@ function playTrack(i) { }, cd * 1000 + 100); updateUI(); renderTracks(); + drawWaveform(); }; audioNext.addEventListener('canplaythrough', onReady); } else { @@ -1324,6 +1409,7 @@ function playTrack(i) { initAudioCtx(); updateUI(); renderTracks(); + drawWaveform(); } } function updateUI() { @@ -1349,14 +1435,14 @@ function onLoadedmetadata(e) { if (t && audio.duration) { t.playtime = audio.duration; renderTracks(); + computeWaveform(t); } } function onTimeupdate(e) { if (e.target !== audio) return; if (!audio.duration) return; - const pct = (audio.currentTime / audio.duration) * 100; const remaining = audio.duration - audio.currentTime; - progressFill.style.width = pct + '%'; + drawWaveform(); timeCurrent.textContent = fmtTime(audio.currentTime); timeRemaining.textContent = '-' + fmtTime(remaining); bigTime.innerHTML = `${fmtTime(audio.currentTime)} / ${fmtTime(audio.duration)}`; @@ -1448,10 +1534,15 @@ function nextTrack() { playTrack(currentIndex >= tracks.length - 1 ? 0 : currentIndex + 1); } } -progressBar.addEventListener('click', (e) => { +waveformWrap.addEventListener('click', (e) => { if (!audio.duration) return; - const rect = progressBar.getBoundingClientRect(); - audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; + if (e.target !== waveformCanvas) return; + const t = tracks[currentIndex]; + if (!t || !t.waveform) return; + const rect = waveformCanvas.getBoundingClientRect(); + const x = e.clientX - rect.left + waveformWrap.scrollLeft; + const pct = x / t.waveform.length; + audio.currentTime = pct * audio.duration; }); volumeSlider.addEventListener('input', () => { const v = parseFloat(volumeSlider.value);