mirror of
https://codeberg.org/armin/noiz.git
synced 2026-09-01 05:50:52 +02:00
feat: replace progress bar with scrollable waveform display
This commit is contained in:
parent
b4cae8eac2
commit
c07220e809
1 changed files with 103 additions and 12 deletions
115
index.html
115
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
|
|||
<canvas id="spectrum" width="480" height="200"></canvas>
|
||||
<div class="progress-row" id="progressRow">
|
||||
<span id="timeCurrent">0:00</span>
|
||||
<div class="progress-bar" id="progressBar">
|
||||
<div class="fill" id="progressFill"></div>
|
||||
<div class="waveform-wrap" id="waveformWrap">
|
||||
<canvas id="waveform"></canvas>
|
||||
</div>
|
||||
<span id="timeRemaining">0:00</span>
|
||||
</div>
|
||||
|
|
@ -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)} <span class="sep">/</span> ${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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue