WIP: waveform
This commit is contained in:
parent
02dc2e0ba6
commit
5ea6f69cb1
6 changed files with 269 additions and 2 deletions
10
index.html
10
index.html
|
|
@ -458,6 +458,16 @@
|
||||||
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
|
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</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="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Album Cover Background</span>
|
<span class="label">Album Cover Background</span>
|
||||||
|
|
|
||||||
107
js/events.js
107
js/events.js
|
|
@ -1,11 +1,15 @@
|
||||||
//js/events.js
|
//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 { 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 { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
||||||
import { lyricsSettings } from './storage.js';
|
import { lyricsSettings } from './storage.js';
|
||||||
import { updateTabTitle } from './router.js';
|
import { updateTabTitle } from './router.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { syncManager } from './firebase/sync.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) {
|
export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
const playPauseBtn = document.querySelector('.play-pause-btn');
|
const playPauseBtn = document.querySelector('.play-pause-btn');
|
||||||
|
|
@ -40,6 +44,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled()) {
|
||||||
scrobbler.updateNowPlaying(player.currentTrack);
|
scrobbler.updateNowPlaying(player.currentTrack);
|
||||||
}
|
}
|
||||||
|
updateWaveform();
|
||||||
}
|
}
|
||||||
|
|
||||||
playPauseBtn.innerHTML = SVG_PAUSE;
|
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||||
|
|
@ -144,6 +149,106 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
|
||||||
const volumeFill = document.getElementById('volume-fill');
|
const volumeFill = document.getElementById('volume-fill');
|
||||||
const volumeBtn = document.getElementById('volume-btn');
|
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 updateVolumeUI = () => {
|
||||||
const { volume, muted } = audioPlayer;
|
const { volume, muted } = audioPlayer;
|
||||||
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
|
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
trackListSettings,
|
trackListSettings,
|
||||||
cardSettings,
|
cardSettings,
|
||||||
|
waveformSettings,
|
||||||
} from "./storage.js";
|
} from "./storage.js";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { authManager } from "./firebase/auth.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
|
// Filename template setting
|
||||||
const filenameTemplate = document.getElementById("filename-template");
|
const filenameTemplate = document.getElementById("filename-template");
|
||||||
if (filenameTemplate) {
|
if (filenameTemplate) {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const queueManager = {
|
||||||
STORAGE_KEY: 'monochrome-queue',
|
STORAGE_KEY: 'monochrome-queue',
|
||||||
|
|
||||||
|
|
|
||||||
100
js/waveform.js
Normal file
100
js/waveform.js
Normal 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();
|
||||||
26
styles.css
26
styles.css
|
|
@ -355,7 +355,7 @@ kbd {
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
background-color: var(--card);
|
background-color: var(--card);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2fr 1fr;
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1383,6 +1383,11 @@ input:checked + .slider::before {
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-controls.waveform-loaded {
|
||||||
|
gap: 0.1rem;
|
||||||
|
margin-bottom: -0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.player-controls .buttons {
|
.player-controls .buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1501,6 +1506,21 @@ input:checked + .slider::before {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
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 {
|
.volume-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -3216,6 +3236,10 @@ img:not([src]), img[src=''] {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
padding-bottom: 50px !important;
|
padding-bottom: 50px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-controls.waveform-loaded {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Responsive & Media Queries --- */
|
/* --- Responsive & Media Queries --- */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue