From 5ea6f69cb1320a12fbe4395dfc1ddff261ce3a56 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Fri, 9 Jan 2026 20:02:56 +0100 Subject: [PATCH] WIP: waveform --- index.html | 10 +++++ js/events.js | 107 ++++++++++++++++++++++++++++++++++++++++++++++++- js/settings.js | 12 ++++++ js/storage.js | 16 ++++++++ js/waveform.js | 100 +++++++++++++++++++++++++++++++++++++++++++++ styles.css | 26 +++++++++++- 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 js/waveform.js diff --git a/index.html b/index.html index 3f0d8d2..aff1d27 100644 --- a/index.html +++ b/index.html @@ -458,6 +458,16 @@ +
+
+ Waveform Seekbar + Show a visual waveform of the track in the progress bar (Experimental) +
+ +
Album Cover Background diff --git a/js/events.js b/js/events.js index 7644435..41f342a 100644 --- a/js/events.js +++ b/js/events.js @@ -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; diff --git a/js/settings.js b/js/settings.js index 64d9642..c99e149 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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) { diff --git a/js/storage.js b/js/storage.js index 4e388a4..39bfcb4 100644 --- a/js/storage.js +++ b/js/storage.js @@ -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', diff --git a/js/waveform.js b/js/waveform.js new file mode 100644 index 0000000..8db5978 --- /dev/null +++ b/js/waveform.js @@ -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(); diff --git a/styles.css b/styles.css index 1c4cb16..e5a335a 100644 --- a/styles.css +++ b/styles.css @@ -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 --- */