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>
|
||||
</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>
|
||||
|
|
|
|||
107
js/events.js
107
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
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;
|
||||
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 --- */
|
||||
|
|
|
|||
Loading…
Reference in a new issue