diff --git a/index.html b/index.html
index 3f0d8d2..aff1d27 100644
--- a/index.html
+++ b/index.html
@@ -458,6 +458,16 @@
+
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 --- */