WIP: waveform

This commit is contained in:
Julien Maille 2026-01-09 20:02:56 +01:00
parent 02dc2e0ba6
commit 5ea6f69cb1
6 changed files with 269 additions and 2 deletions

View file

@ -458,6 +458,16 @@
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Waveform Seekbar</span>
<span class="description">Show a visual waveform of the track in the progress bar (Experimental)</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="waveform-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Album Cover Background</span>

View file

@ -1,11 +1,15 @@
//js/events.js
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename, getTrackTitle, formatTime } from './utils.js';
import { lastFMStorage } from './storage.js';
import { lastFMStorage, waveformSettings } from './storage.js';
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings } from './storage.js';
import { updateTabTitle } from './router.js';
import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
import { waveformGenerator } from './waveform.js';
let currentWaveformPeaks = null;
let currentTrackIdForWaveform = null;
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const playPauseBtn = document.querySelector('.play-pause-btn');
@ -40,6 +44,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
scrobbler.updateNowPlaying(player.currentTrack);
}
updateWaveform();
}
playPauseBtn.innerHTML = SVG_PAUSE;
@ -144,6 +149,106 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
// Waveform Masking Logic
const updateWaveform = async () => {
const progressBar = document.getElementById('progress-bar');
const playerControls = document.querySelector('.player-controls');
if (!waveformSettings.isEnabled() || !player.currentTrack) {
if (progressBar) {
progressBar.style.webkitMaskImage = '';
progressBar.style.maskImage = '';
progressBar.classList.remove('has-waveform', 'waveform-loaded');
}
if (playerControls) {
playerControls.classList.remove('waveform-loaded');
}
currentTrackIdForWaveform = null;
return;
}
if (progressBar && currentTrackIdForWaveform !== player.currentTrack.id) {
currentTrackIdForWaveform = player.currentTrack.id;
progressBar.classList.add('has-waveform');
progressBar.classList.remove('waveform-loaded');
if (playerControls) {
playerControls.classList.remove('waveform-loaded');
}
// Clear current mask while loading
progressBar.style.webkitMaskImage = '';
progressBar.style.maskImage = '';
try {
const streamUrl = await player.api.getStreamUrl(player.currentTrack.id, 'LOW');
const waveformData = await waveformGenerator.getWaveform(streamUrl, player.currentTrack.id);
if (waveformData && currentTrackIdForWaveform === player.currentTrack.id) {
let { peaks, duration } = waveformData;
const trackDuration = player.currentTrack.duration;
// Padding logic for sync
if (trackDuration && duration && duration < trackDuration) {
const diff = trackDuration - duration;
if (diff > 0.5) { // If difference is significant (> 500ms)
// Calculate how many peaks represent the missing time
// peaks.length represents 'duration'
// X peaks represent 'diff'
const peaksPerSecond = peaks.length / duration;
const paddingPeaksCount = Math.floor(diff * peaksPerSecond);
if (paddingPeaksCount > 0) {
const newPeaks = new Float32Array(peaks.length + paddingPeaksCount);
// Fill start with 0s (implied by new Float32Array)
newPeaks.set(peaks, paddingPeaksCount);
peaks = newPeaks;
}
}
}
// Create a temporary canvas to generate the mask
const canvas = document.createElement('canvas');
const rect = progressBar.getBoundingClientRect();
canvas.width = rect.width || 500;
canvas.height = 28; // Fixed height for mask generation
waveformGenerator.drawWaveform(canvas, peaks);
const dataUrl = canvas.toDataURL();
progressBar.style.webkitMaskImage = `url(${dataUrl})`;
progressBar.style.webkitMaskSize = '100% 100%';
progressBar.style.webkitMaskRepeat = 'no-repeat';
progressBar.style.maskImage = `url(${dataUrl})`;
progressBar.style.maskSize = '100% 100%';
progressBar.style.maskRepeat = 'no-repeat';
progressBar.classList.add('waveform-loaded');
if (playerControls) {
playerControls.classList.add('waveform-loaded');
}
}
} catch (e) {
console.error('Failed to load waveform mask:', e);
}
}
};
window.addEventListener('waveform-toggle', (e) => {
if (!e.detail.enabled) {
const progressBar = document.getElementById('progress-bar');
const playerControls = document.querySelector('.player-controls');
if (progressBar) {
progressBar.style.webkitMaskImage = '';
progressBar.style.maskImage = '';
progressBar.classList.remove('has-waveform', 'waveform-loaded');
}
if (playerControls) {
playerControls.classList.remove('waveform-loaded');
}
}
updateWaveform();
});
const updateVolumeUI = () => {
const { volume, muted } = audioPlayer;
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;

View file

@ -7,6 +7,7 @@ import {
backgroundSettings,
trackListSettings,
cardSettings,
waveformSettings,
} from "./storage.js";
import { db } from "./db.js";
import { authManager } from "./firebase/auth.js";
@ -344,6 +345,17 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
// Waveform Toggle
const waveformToggle = document.getElementById("waveform-toggle");
if (waveformToggle) {
waveformToggle.checked = waveformSettings.isEnabled();
waveformToggle.addEventListener("change", (e) => {
waveformSettings.setEnabled(e.target.checked);
window.dispatchEvent(new CustomEvent("waveform-toggle", { detail: { enabled: e.target.checked } }));
});
}
// Filename template setting
const filenameTemplate = document.getElementById("filename-template");
if (filenameTemplate) {

View file

@ -493,6 +493,22 @@ export const cardSettings = {
}
};
export const waveformSettings = {
STORAGE_KEY: 'waveform-seekbar-enabled',
isEnabled() {
try {
return localStorage.getItem(this.STORAGE_KEY) === 'true';
} catch (e) {
return false;
}
},
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
}
};
export const queueManager = {
STORAGE_KEY: 'monochrome-queue',

100
js/waveform.js Normal file
View file

@ -0,0 +1,100 @@
// js/waveform.js
export class WaveformGenerator {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.cache = new Map();
}
async getWaveform(url, trackId) {
if (this.cache.has(trackId)) {
return this.cache.get(trackId);
}
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
const peaks = this.extractPeaks(audioBuffer);
const result = { peaks, duration: audioBuffer.duration };
this.cache.set(trackId, result);
return result;
} catch (error) {
console.error('Waveform generation failed:', error);
return null;
}
}
extractPeaks(audioBuffer) {
const { numberOfChannels, length, sampleRate } = audioBuffer;
const numPeaks = Math.floor(4*length/sampleRate);
const peaks = new Float32Array(numPeaks);
const chanData = audioBuffer.getChannelData(0); // Use first channel
const step = Math.floor(length / numPeaks);
for (let i = 0; i < numPeaks; i++) {
let max = 0;
for (let j = 0; j < step; j++) {
const datum = chanData[i * step + j];
if (datum > max) {
max = datum;
} else if (-datum > max) {
max = -datum;
}
}
peaks[i] = max;
}
// Normalize peaks so the highest peak is 1.0
let maxPeak = 0;
for (let i = 0; i < numPeaks; i++) {
if (peaks[i] > maxPeak) maxPeak = peaks[i];
}
if (maxPeak > 0) {
for (let i = 0; i < numPeaks; i++) {
peaks[i] /= maxPeak;
}
}
return peaks;
}
drawWaveform(canvas, peaks) {
if (!canvas || !peaks) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
const step = width / peaks.length;
const centerY = height / 2;
ctx.fillStyle = '#000'; // Mask color (opaque part)
ctx.beginPath();
// Draw top half
ctx.moveTo(0, centerY);
for (let i = 0; i < peaks.length; i++) {
const peak = peaks[i];
const barHeight = Math.max(1.5, peak * height * 0.9);
ctx.lineTo(i * step, centerY - barHeight / 2);
}
// Draw bottom half (backwards)
for (let i = peaks.length - 1; i >= 0; i--) {
const peak = peaks[i];
const barHeight = Math.max(1.5, peak * height * 0.9);
ctx.lineTo(i * step, centerY + barHeight / 2);
}
ctx.closePath();
ctx.fill();
}
// Removed drawRoundedRect as it's no longer used for continuous paths
}
export const waveformGenerator = new WaveformGenerator();

View file

@ -355,7 +355,7 @@ kbd {
grid-area: player;
background-color: var(--card);
border-top: 1px solid var(--border);
padding: var(--spacing-md) var(--spacing-lg);
padding: var(--spacing-sm) var(--spacing-md);
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
@ -1383,6 +1383,11 @@ input:checked + .slider::before {
gap: var(--spacing-sm);
}
.player-controls.waveform-loaded {
gap: 0.1rem;
margin-bottom: -0.4rem;
}
.player-controls .buttons {
display: flex;
align-items: center;
@ -1501,6 +1506,21 @@ input:checked + .slider::before {
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.progress-bar.has-waveform.waveform-loaded {
height: 28px;
}
.progress-bar.has-waveform.waveform-loaded .progress-fill {
background-color: var(--primary);
}
.progress-bar.has-waveform.waveform-loaded .progress-fill::after {
display: none;
}
#waveform-canvas {
display: none;
}
.volume-controls {
display: flex;
@ -3216,6 +3236,10 @@ img:not([src]), img[src=''] {
position: relative !important;
padding-bottom: 50px !important;
}
.player-controls.waveform-loaded {
gap: 0;
}
}
/* --- Responsive & Media Queries --- */