feat: AutoEQ and speaker EQ enhancements
Adds AutoEQ integration with interactive parametric EQ graph, speaker/room correction with shelf filters, and improved EQ persistence via IndexedDB.
This commit is contained in:
parent
6e98830fdd
commit
d4d1fe8494
13 changed files with 10305 additions and 1890 deletions
1
.github/workflows/lint.yml
vendored
1
.github/workflows/lint.yml
vendored
|
|
@ -19,6 +19,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||||
ref: ${{ github.head_ref || github.ref }}
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
|
|
||||||
978
index.html
978
index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1883,7 +1883,13 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
if (!isVideo) {
|
||||||
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal, postProcessingQuality);
|
blob = await applyAudioPostProcessing(
|
||||||
|
blob,
|
||||||
|
quality,
|
||||||
|
onProgress,
|
||||||
|
options.signal,
|
||||||
|
lookup.info?.audioQuality ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add metadata if track information is provided
|
// Add metadata if track information is provided
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,10 @@ class AudioContextManager {
|
||||||
// Create biquad filters for each frequency band
|
// Create biquad filters for each frequency band
|
||||||
this.filters = this.frequencies.map((freq, index) => {
|
this.filters = this.frequencies.map((freq, index) => {
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
filter.type = 'peaking';
|
filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
filter.frequency.value = freq;
|
filter.frequency.value = freq;
|
||||||
filter.Q.value = this._calculateQ(index);
|
filter.Q.value =
|
||||||
|
this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
filter.gain.value = this.currentGains[index] || 0;
|
filter.gain.value = this.currentGains[index] || 0;
|
||||||
return filter;
|
return filter;
|
||||||
});
|
});
|
||||||
|
|
@ -312,10 +313,10 @@ class AudioContextManager {
|
||||||
try {
|
try {
|
||||||
this.audioContext = new AudioContext(highResOptions);
|
this.audioContext = new AudioContext(highResOptions);
|
||||||
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
|
||||||
} catch (e) {
|
} catch {
|
||||||
try {
|
try {
|
||||||
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
this.audioContext = new AudioContext({ latencyHint: 'playback' });
|
||||||
} catch (e2) {
|
} catch {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +359,9 @@ class AudioContextManager {
|
||||||
if (this.source) {
|
if (this.source) {
|
||||||
try {
|
try {
|
||||||
this.source.disconnect();
|
this.source.disconnect();
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// node may already be disconnected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audio = audioElement;
|
this.audio = audioElement;
|
||||||
|
|
@ -386,7 +389,9 @@ class AudioContextManager {
|
||||||
// Disconnect everything first
|
// Disconnect everything first
|
||||||
try {
|
try {
|
||||||
this.source.disconnect();
|
this.source.disconnect();
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// node may already be disconnected
|
||||||
|
}
|
||||||
this.outputNode.disconnect();
|
this.outputNode.disconnect();
|
||||||
if (this.volumeNode) {
|
if (this.volumeNode) {
|
||||||
this.volumeNode.disconnect();
|
this.volumeNode.disconnect();
|
||||||
|
|
@ -405,16 +410,23 @@ class AudioContextManager {
|
||||||
|
|
||||||
// Apply mono audio if enabled
|
// Apply mono audio if enabled
|
||||||
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
if (this.isMonoAudioEnabled && this.monoMergerNode) {
|
||||||
// Create a gain node to mix channels before the merger
|
// Reuse persistent gain node to avoid leaking AudioNodes
|
||||||
const monoGain = this.audioContext.createGain();
|
if (!this.monoGainNode) {
|
||||||
monoGain.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
this.monoGainNode = this.audioContext.createGain();
|
||||||
|
this.monoGainNode.gain.value = 0.5; // Reduce volume to prevent clipping when mixing
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.monoGainNode.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* not connected */
|
||||||
|
}
|
||||||
|
|
||||||
// Connect source to mono gain
|
// Connect source to mono gain
|
||||||
this.source.connect(monoGain);
|
this.source.connect(this.monoGainNode);
|
||||||
|
|
||||||
// Connect mono gain to both inputs of the merger
|
// Connect mono gain to both inputs of the merger
|
||||||
monoGain.connect(this.monoMergerNode, 0, 0);
|
this.monoGainNode.connect(this.monoMergerNode, 0, 0);
|
||||||
monoGain.connect(this.monoMergerNode, 0, 1);
|
this.monoGainNode.connect(this.monoMergerNode, 0, 1);
|
||||||
|
|
||||||
lastNode = this.monoMergerNode;
|
lastNode = this.monoMergerNode;
|
||||||
console.log('[AudioContext] Mono audio enabled');
|
console.log('[AudioContext] Mono audio enabled');
|
||||||
|
|
@ -573,6 +585,57 @@ class AudioContextManager {
|
||||||
return equalizerSettings.getRange();
|
return equalizerSettings.getRange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate biquad filter magnitude response in dB at a given frequency
|
||||||
|
*/
|
||||||
|
_biquadResponseDb(f, band, sr) {
|
||||||
|
if (!band.enabled || !band.type) return 0;
|
||||||
|
const w = (2 * Math.PI * band.freq) / sr;
|
||||||
|
const p = (2 * Math.PI * f) / sr;
|
||||||
|
const s = Math.sin(w) / (2 * band.q);
|
||||||
|
const A = Math.pow(10, band.gain / 40);
|
||||||
|
const c = Math.cos(w);
|
||||||
|
let b0, b1, b2, a0, a1, a2;
|
||||||
|
const t = band.type[0];
|
||||||
|
if (t === 'p') {
|
||||||
|
b0 = 1 + s * A;
|
||||||
|
b1 = -2 * c;
|
||||||
|
b2 = 1 - s * A;
|
||||||
|
a0 = 1 + s / A;
|
||||||
|
a1 = -2 * c;
|
||||||
|
a2 = 1 - s / A;
|
||||||
|
} else if (t === 'l') {
|
||||||
|
const sq = 2 * Math.sqrt(A) * s;
|
||||||
|
b0 = A * (A + 1 - (A - 1) * c + sq);
|
||||||
|
b1 = 2 * A * (A - 1 - (A + 1) * c);
|
||||||
|
b2 = A * (A + 1 - (A - 1) * c - sq);
|
||||||
|
a0 = A + 1 + (A - 1) * c + sq;
|
||||||
|
a1 = -2 * (A - 1 + (A + 1) * c);
|
||||||
|
a2 = A + 1 + (A - 1) * c - sq;
|
||||||
|
} else if (t === 'h') {
|
||||||
|
const sq = 2 * Math.sqrt(A) * s;
|
||||||
|
b0 = A * (A + 1 + (A - 1) * c + sq);
|
||||||
|
b1 = -2 * A * (A - 1 + (A + 1) * c);
|
||||||
|
b2 = A * (A + 1 + (A - 1) * c - sq);
|
||||||
|
a0 = A + 1 - (A - 1) * c + sq;
|
||||||
|
a1 = 2 * (A - 1 - (A + 1) * c);
|
||||||
|
a2 = A + 1 - (A - 1) * c - sq;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const _a0 = 1 / a0;
|
||||||
|
const b0n = b0 * _a0,
|
||||||
|
b1n = b1 * _a0,
|
||||||
|
b2n = b2 * _a0;
|
||||||
|
const a1n = a1 * _a0,
|
||||||
|
a2n = a2 * _a0;
|
||||||
|
const cp = Math.cos(p),
|
||||||
|
c2p = Math.cos(2 * p);
|
||||||
|
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||||
|
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||||
|
return 10 * Math.log10(n / d);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clamp gain to valid range
|
* Clamp gain to valid range
|
||||||
*/
|
*/
|
||||||
|
|
@ -667,6 +730,8 @@ class AudioContextManager {
|
||||||
this.freqRange = equalizerSettings.getFreqRange();
|
this.freqRange = equalizerSettings.getFreqRange();
|
||||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
|
this.currentTypes = equalizerSettings.getBandTypes(this.bandCount);
|
||||||
|
this.currentQs = equalizerSettings.getBandQs(this.bandCount);
|
||||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||||
this.preamp = equalizerSettings.getPreamp();
|
this.preamp = equalizerSettings.getPreamp();
|
||||||
}
|
}
|
||||||
|
|
@ -697,7 +762,88 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the app enters the background (screen lock, app switch).
|
* Apply AutoEQ-generated bands to the equalizer
|
||||||
|
* Unlike regular presets, AutoEQ bands have specific frequencies, gains, and Q values
|
||||||
|
* @param {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>} bands
|
||||||
|
* @returns {string} Exported text representation of the applied EQ
|
||||||
|
*/
|
||||||
|
applyAutoEQBands(bands, skipPreamp = false) {
|
||||||
|
if (!bands || bands.length === 0) return '';
|
||||||
|
|
||||||
|
const enabledBands = bands.filter((b) => b.enabled);
|
||||||
|
const count = Math.max(equalizerSettings.MIN_BANDS, Math.min(equalizerSettings.MAX_BANDS, enabledBands.length));
|
||||||
|
|
||||||
|
// Calculate preamp: negative of cumulative peak gain across all bands to prevent clipping
|
||||||
|
let cumulativePeak = 0;
|
||||||
|
if (!skipPreamp) {
|
||||||
|
const sr = this.audioContext?.sampleRate ?? 48000;
|
||||||
|
// Sweep log-spaced frequencies (24 points/octave from 20-20kHz) to catch narrow peaks
|
||||||
|
for (let f = 20; f <= 20000; f *= Math.pow(2, 1 / 24)) {
|
||||||
|
let sum = 0;
|
||||||
|
for (const b of enabledBands) {
|
||||||
|
sum += this._biquadResponseDb(f, b, sr);
|
||||||
|
}
|
||||||
|
if (sum > cumulativePeak) cumulativePeak = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preamp = skipPreamp
|
||||||
|
? equalizerSettings.getPreamp()
|
||||||
|
: cumulativePeak > 0
|
||||||
|
? -Math.round(cumulativePeak * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Sort bands by frequency so index order is deterministic
|
||||||
|
const sortedBands = [...enabledBands].sort((a, b) => a.freq - b.freq);
|
||||||
|
|
||||||
|
// Build normalized band descriptor arrays
|
||||||
|
const newFrequencies = sortedBands
|
||||||
|
.slice(0, count)
|
||||||
|
.map((b) => Math.round(Math.min(b.freq, (this.audioContext?.sampleRate ?? 48000) / 2 - 1)));
|
||||||
|
const newTypes = sortedBands.slice(0, count).map((b) => b.type || 'peaking');
|
||||||
|
const newQs = sortedBands.slice(0, count).map((b) => b.q);
|
||||||
|
const newGains = sortedBands.slice(0, count).map((b) => this._clampGain(b.gain));
|
||||||
|
|
||||||
|
// Update band count via class setter to trigger equalizer-band-count-changed event
|
||||||
|
if (count !== this.bandCount) {
|
||||||
|
this.setBandCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override frequencies, types, and Qs with band-specific values
|
||||||
|
this.frequencies = newFrequencies;
|
||||||
|
this.currentTypes = newTypes;
|
||||||
|
this.currentQs = newQs;
|
||||||
|
this.currentGains = newGains;
|
||||||
|
|
||||||
|
// Rebuild EQ so _createEQ picks up the new types/Qs
|
||||||
|
if (this.isInitialized && this.audioContext) {
|
||||||
|
this._destroyEQ();
|
||||||
|
this._createEQ();
|
||||||
|
this._connectGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preamp (skip if caller manages preamp externally)
|
||||||
|
if (!skipPreamp) {
|
||||||
|
this.setPreamp(preamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist normalized band descriptors to settings store
|
||||||
|
equalizerSettings.setGains(this.currentGains);
|
||||||
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
|
||||||
|
// Generate export text using clamped gain values
|
||||||
|
const lines = [`Preamp: ${preamp.toFixed(1)} dB`];
|
||||||
|
sortedBands.forEach((band, index) => {
|
||||||
|
if (index >= count) return;
|
||||||
|
const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
||||||
|
lines.push(
|
||||||
|
`Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export equalizer settings to text format
|
* Export equalizer settings to text format
|
||||||
* @returns {string} Exported settings in text format
|
* @returns {string} Exported settings in text format
|
||||||
|
|
@ -709,8 +855,13 @@ class AudioContextManager {
|
||||||
|
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
|
const type = (this.currentTypes && this.currentTypes[index]) || 'peaking';
|
||||||
|
const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK';
|
||||||
|
const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
lines.push(
|
||||||
|
`Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|
@ -760,24 +911,42 @@ class AudioContextManager {
|
||||||
this.setPreamp(preamp);
|
this.setPreamp(preamp);
|
||||||
|
|
||||||
// If different number of bands, adjust
|
// If different number of bands, adjust
|
||||||
if (filters.length !== this.bandCount) {
|
const newCount = Math.max(
|
||||||
const newCount = Math.max(
|
equalizerSettings.MIN_BANDS,
|
||||||
equalizerSettings.MIN_BANDS,
|
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||||
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
);
|
||||||
);
|
if (newCount !== this.bandCount) {
|
||||||
this.setBandCount(newCount);
|
this.setBandCount(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract gains from filters
|
// Apply per-band frequencies, types, Qs, and gains from import
|
||||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
const sliced = filters.slice(0, this.bandCount);
|
||||||
this.setAllGains(gains);
|
const typeMap = {
|
||||||
|
PK: 'peaking',
|
||||||
|
LS: 'lowshelf',
|
||||||
|
LSC: 'lowshelf',
|
||||||
|
LSF: 'lowshelf',
|
||||||
|
HS: 'highshelf',
|
||||||
|
HSC: 'highshelf',
|
||||||
|
HSF: 'highshelf',
|
||||||
|
};
|
||||||
|
this.frequencies = sliced.map((f) => f.freq);
|
||||||
|
this.currentTypes = sliced.map((f) => typeMap[f.type] || 'peaking');
|
||||||
|
this.currentQs = sliced.map((f) => f.q);
|
||||||
|
this.currentGains = sliced.map((f) => this._clampGain(f.gain));
|
||||||
|
|
||||||
// Store filter frequencies if different
|
// Rebuild EQ chain to apply new frequencies, types, and Qs
|
||||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
if (this.isInitialized && this.audioContext) {
|
||||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
this._destroyEQ();
|
||||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
this._createEQ();
|
||||||
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist all band settings
|
||||||
|
equalizerSettings.setGains(this.currentGains);
|
||||||
|
equalizerSettings.setBandTypes(this.currentTypes);
|
||||||
|
equalizerSettings.setBandQs(this.currentQs);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[AudioContext] Failed to import EQ settings:', e);
|
console.warn('[AudioContext] Failed to import EQ settings:', e);
|
||||||
|
|
|
||||||
4396
js/autoeq-data.js
Normal file
4396
js/autoeq-data.js
Normal file
File diff suppressed because it is too large
Load diff
221
js/autoeq-engine.js
Normal file
221
js/autoeq-engine.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// js/autoeq-engine.js
|
||||||
|
// AutoEQ Algorithm - Ported from Seap Engine AutoEqEngine.ts
|
||||||
|
// Iterative peak-flattening parametric EQ optimization
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const MAX_BOOST = 30.0;
|
||||||
|
const MAX_CUT = 30.0;
|
||||||
|
const MIN_Q = 0.6;
|
||||||
|
const DEFAULT_SR = 48000;
|
||||||
|
const PI = Math.PI;
|
||||||
|
const DB_BASE = 10;
|
||||||
|
const DB_DIVISOR = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate biquad filter magnitude response at a given frequency
|
||||||
|
* @param {number} f - Frequency to evaluate (Hz)
|
||||||
|
* @param {object} band - EQ band {type, freq, gain, q, enabled}
|
||||||
|
* @param {number} sr - Sample rate
|
||||||
|
* @returns {number} Magnitude in dB
|
||||||
|
*/
|
||||||
|
function calculateBiquadResponse(f, band, sr = DEFAULT_SR) {
|
||||||
|
if (!band.enabled) return 0;
|
||||||
|
if (!band.type || band.type.length === 0) return 0;
|
||||||
|
const w = 2 * PI * band.freq / sr;
|
||||||
|
const p = 2 * PI * f / sr;
|
||||||
|
const s = Math.sin(w) / (2 * band.q);
|
||||||
|
const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR);
|
||||||
|
const c = Math.cos(w);
|
||||||
|
let b0 = 0, b1 = 0, b2 = 0, a0 = 0, a1 = 0, a2 = 0;
|
||||||
|
|
||||||
|
const t = band.type[0];
|
||||||
|
|
||||||
|
if (t === 'p') {
|
||||||
|
b0 = 1 + s * A; b1 = -2 * c; b2 = 1 - s * A;
|
||||||
|
a0 = 1 + s / A; a1 = -2 * c; a2 = 1 - s / A;
|
||||||
|
} else if (t === 'l') {
|
||||||
|
const sq = 2 * Math.sqrt(A) * s;
|
||||||
|
b0 = A * ((A + 1) - (A - 1) * c + sq);
|
||||||
|
b1 = 2 * A * ((A - 1) - (A + 1) * c);
|
||||||
|
b2 = A * ((A + 1) - (A - 1) * c - sq);
|
||||||
|
a0 = (A + 1) + (A - 1) * c + sq;
|
||||||
|
a1 = -2 * ((A - 1) + (A + 1) * c);
|
||||||
|
a2 = (A + 1) + (A - 1) * c - sq;
|
||||||
|
} else if (t === 'h') {
|
||||||
|
const sq = 2 * Math.sqrt(A) * s;
|
||||||
|
b0 = A * ((A + 1) + (A - 1) * c + sq);
|
||||||
|
b1 = -2 * A * ((A - 1) + (A + 1) * c);
|
||||||
|
b2 = A * ((A + 1) + (A - 1) * c - sq);
|
||||||
|
a0 = (A + 1) - (A - 1) * c + sq;
|
||||||
|
a1 = 2 * ((A - 1) - (A + 1) * c);
|
||||||
|
a2 = (A + 1) - (A - 1) * c - sq;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _a0 = 1 / a0;
|
||||||
|
const b0n = b0 * _a0, b1n = b1 * _a0, b2n = b2 * _a0;
|
||||||
|
const a1n = a1 * _a0, a2n = a2 * _a0;
|
||||||
|
const cp = Math.cos(p), c2p = Math.cos(2 * p);
|
||||||
|
const n = b0n * b0n + b1n * b1n + b2n * b2n + 2 * (b0n * b1n + b1n * b2n) * cp + 2 * b0n * b2n * c2p;
|
||||||
|
const d = 1 + a1n * a1n + a2n * a2n + 2 * (a1n + a1n * a2n) * cp + 2 * a2n * c2p;
|
||||||
|
return 10 * Math.log10(n / d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear interpolation on frequency response data
|
||||||
|
* @param {number} freq - Frequency to interpolate at
|
||||||
|
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
||||||
|
* @returns {number} Interpolated gain value
|
||||||
|
*/
|
||||||
|
function interpolate(freq, data) {
|
||||||
|
if (data.length === 0) return 0;
|
||||||
|
if (freq <= data[0].freq) return data[0].gain;
|
||||||
|
if (freq >= data[data.length - 1].freq) return data[data.length - 1].gain;
|
||||||
|
for (let i = 0; i < data.length - 1; i++) {
|
||||||
|
if (freq >= data[i].freq && freq <= data[i + 1].freq) {
|
||||||
|
return data[i].gain + (freq - data[i].freq) / (data[i + 1].freq - data[i].freq) * (data[i + 1].gain - data[i].gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate normalization offset based on midrange average (250-2500 Hz)
|
||||||
|
* @param {Array<{freq: number, gain: number}>} data - Frequency response data
|
||||||
|
* @returns {number} Average gain in midrange
|
||||||
|
*/
|
||||||
|
function getNormalizationOffset(data) {
|
||||||
|
let sum = 0, count = 0;
|
||||||
|
for (const p of data) {
|
||||||
|
if (p.freq >= 250 && p.freq <= 2500) {
|
||||||
|
sum += p.gain;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count > 0 ? sum / count : interpolate(1000, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the AutoEQ algorithm to generate parametric EQ bands
|
||||||
|
* Iterative peak-flattening: finds largest error, places a corrective filter, repeats
|
||||||
|
*
|
||||||
|
* @param {Array<{freq: number, gain: number}>} measurement - Headphone frequency response
|
||||||
|
* @param {Array<{freq: number, gain: number}>} target - Target frequency response curve
|
||||||
|
* @param {number} bandCount - Number of EQ bands to generate
|
||||||
|
* @param {number} maxFreq - Maximum frequency limit (Hz)
|
||||||
|
* @param {number} minFreq - Minimum frequency limit (Hz)
|
||||||
|
* @param {number} maxQ - Maximum Q factor
|
||||||
|
* @returns {Array<{id: number, type: string, freq: number, gain: number, q: number, enabled: boolean}>}
|
||||||
|
*/
|
||||||
|
function runAutoEqAlgorithm(measurement, target, bandCount, maxFreq = 16000, minFreq = 20, maxQ = 5.0, sampleRate = DEFAULT_SR) {
|
||||||
|
if (minFreq > maxFreq) return [];
|
||||||
|
const off = getNormalizationOffset(target) - getNormalizationOffset(measurement);
|
||||||
|
let err = measurement.map(p => ({ freq: p.freq, gain: (p.gain + off) - interpolate(p.freq, target) }));
|
||||||
|
|
||||||
|
const hasInRangePoints = err.some(p => p.freq >= minFreq && p.freq <= maxFreq);
|
||||||
|
if (!hasInRangePoints) return [];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < bandCount; i++) {
|
||||||
|
let maxDev = 0, maxWeightedDev = 0, peakFreq = 1000, peakIdx = 0;
|
||||||
|
|
||||||
|
// Scan for maximum weighted error
|
||||||
|
for (let j = 0; j < err.length; j++) {
|
||||||
|
const p = err[j];
|
||||||
|
if (p.freq < minFreq || p.freq > maxFreq) continue;
|
||||||
|
|
||||||
|
// 3-point smoothing
|
||||||
|
let v = p.gain;
|
||||||
|
if (j > 0 && j < err.length - 1) {
|
||||||
|
v = (err[j - 1].gain + v + err[j + 1].gain) / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency-dependent weighting
|
||||||
|
let w = 1.0;
|
||||||
|
if (p.freq < 300) w = 1.5;
|
||||||
|
else if (p.freq < 4000) w = 1.0;
|
||||||
|
else if (p.freq < 8000) w = 0.5;
|
||||||
|
else w = 0.25;
|
||||||
|
|
||||||
|
if (Math.abs(v * w) > Math.abs(maxWeightedDev)) {
|
||||||
|
maxWeightedDev = Math.abs(v * w);
|
||||||
|
maxDev = v;
|
||||||
|
peakFreq = p.freq;
|
||||||
|
peakIdx = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain = -maxDev;
|
||||||
|
|
||||||
|
// Safety clamps - reduce max boost at higher frequencies
|
||||||
|
let safeBoost = MAX_BOOST;
|
||||||
|
if (peakFreq > 3000) safeBoost = 6.0;
|
||||||
|
if (peakFreq > 6000) safeBoost = 3.0;
|
||||||
|
if (gain > safeBoost) gain = safeBoost;
|
||||||
|
if (gain < -MAX_CUT) gain = -MAX_CUT;
|
||||||
|
if (Math.abs(gain) < 0.2) break;
|
||||||
|
|
||||||
|
// Q factor calculation from error bandwidth (half-gain points)
|
||||||
|
let upperFreq = peakFreq, lowerFreq = peakFreq;
|
||||||
|
let foundLower = false, foundUpper = false;
|
||||||
|
const thresholdError = maxDev / 2;
|
||||||
|
for (let k = peakIdx; k >= 0; k--) {
|
||||||
|
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||||
|
lowerFreq = err[k].freq;
|
||||||
|
foundLower = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = peakIdx; k < err.length; k++) {
|
||||||
|
if (Math.abs(err[k].gain) < Math.abs(thresholdError)) {
|
||||||
|
upperFreq = err[k].freq;
|
||||||
|
foundUpper = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If half-gain boundary not found on one side, mirror the other side
|
||||||
|
// to avoid degenerate bandwidth = 0 producing extremely narrow filters
|
||||||
|
if (!foundLower && foundUpper) {
|
||||||
|
lowerFreq = peakFreq * peakFreq / upperFreq;
|
||||||
|
} else if (!foundUpper && foundLower) {
|
||||||
|
upperFreq = peakFreq * peakFreq / lowerFreq;
|
||||||
|
} else if (!foundLower && !foundUpper) {
|
||||||
|
// Neither boundary found — use 1 octave default
|
||||||
|
lowerFreq = peakFreq / Math.SQRT2;
|
||||||
|
upperFreq = peakFreq * Math.SQRT2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bandwidth = Math.log2(upperFreq / Math.max(1, lowerFreq));
|
||||||
|
if (bandwidth < 0.1) bandwidth = 0.1;
|
||||||
|
let q = Math.sqrt(Math.pow(2, bandwidth)) / (Math.pow(2, bandwidth) - 1);
|
||||||
|
q = Math.max(MIN_Q, Math.min(maxQ, q));
|
||||||
|
if (peakFreq > 5000 && q > 3.0) q = 3.0;
|
||||||
|
if (gain > 0 && q > 2.0) q = 2.0;
|
||||||
|
|
||||||
|
const newBand = { id: i, type: 'peaking', freq: peakFreq, gain, q, enabled: true };
|
||||||
|
|
||||||
|
// Check cumulative gain at the peak frequency across all existing bands + this one
|
||||||
|
let cumulativeGain = gain;
|
||||||
|
for (const existing of out) {
|
||||||
|
cumulativeGain += calculateBiquadResponse(peakFreq, existing, sampleRate);
|
||||||
|
}
|
||||||
|
// If cumulative boost exceeds safe limits, reduce this band's gain
|
||||||
|
const cumulativeLimit = MAX_BOOST;
|
||||||
|
if (cumulativeGain > cumulativeLimit) {
|
||||||
|
newBand.gain = gain - (cumulativeGain - cumulativeLimit);
|
||||||
|
if (newBand.gain < 0.2) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(newBand);
|
||||||
|
|
||||||
|
// Update error curve by applying the new band's response
|
||||||
|
err = err.map(p => ({ ...p, gain: p.gain + calculateBiquadResponse(p.freq, newBand, sampleRate) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) => a.freq - b.freq).map((b, i) => ({ ...b, id: i }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm };
|
||||||
219
js/autoeq-importer.js
Normal file
219
js/autoeq-importer.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// js/autoeq-importer.js
|
||||||
|
// Headphone Database Browser - Fetches from AutoEq GitHub repository
|
||||||
|
// Provides access to 4000+ headphone measurement profiles
|
||||||
|
|
||||||
|
import { parseRawData } from './autoeq-data.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
|
||||||
|
const CACHE_KEY = 'autoeq_index_v4';
|
||||||
|
const OLD_LS_CACHE_KEY = 'monochrome_autoeq_index_v4';
|
||||||
|
const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// 5 most popular headphones — pre-loaded as defaults and shown in the headphone select
|
||||||
|
// All measured on Rtings B&K 5128 rig for consistency
|
||||||
|
const POPULAR_HEADPHONES = [
|
||||||
|
{ name: 'Sony WH-1000XM5 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sony WH-1000XM5', fileName: 'Sony WH-1000XM5.csv' },
|
||||||
|
{ name: 'Apple AirPods Pro2 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Apple AirPods Pro2', fileName: 'Apple AirPods Pro2.csv' },
|
||||||
|
{ name: 'Sony WF-1000XM5 (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' },
|
||||||
|
{ name: 'Samsung Galaxy Buds3 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' },
|
||||||
|
{ name: 'Sennheiser HD 600 (Rtings)', type: 'over-ear', path: 'Rtings/Bruel & Kjaer 5128 over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Static fallback list in case GitHub API fails — popular picks + additional well-known models
|
||||||
|
const FALLBACK_INDEX = [
|
||||||
|
...POPULAR_HEADPHONES,
|
||||||
|
{ name: 'Sennheiser HD 600 (Filk)', type: 'over-ear', path: 'Filk/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||||
|
{ name: 'Sennheiser HD 600 (Innerfidelity)', type: 'over-ear', path: 'Innerfidelity/over-ear/Sennheiser HD 600', fileName: 'Sennheiser HD 600.csv' },
|
||||||
|
{ name: 'Samsung Galaxy Buds2 Pro (Rtings)', type: 'in-ear', path: 'Rtings/Bruel & Kjaer 5128 in-ear/Samsung Galaxy Buds2 Pro', fileName: 'Samsung Galaxy Buds2 Pro.csv' },
|
||||||
|
{ name: 'Sony WF-1000XM5 (Kazi)', type: 'in-ear', path: 'Kazi/in-ear/Sony WF-1000XM5', fileName: 'Sony WF-1000XM5.csv' },
|
||||||
|
{ name: 'Samsung Galaxy Buds3 Pro (DHRME)', type: 'in-ear', path: 'DHRME/in-ear/Samsung Galaxy Buds3 Pro', fileName: 'Samsung Galaxy Buds3 Pro.csv' },
|
||||||
|
{ name: 'Apple AirPods Pro (Super Review)', type: 'in-ear', path: 'Super Review/in-ear/Apple AirPods Pro', fileName: 'Apple AirPods Pro.csv' },
|
||||||
|
{ name: 'Sennheiser HD 600 (2020) (Kuulokenurkka)', type: 'over-ear', path: 'Kuulokenurkka/over-ear/Sennheiser HD 600 (2020)', fileName: 'Sennheiser HD 600 (2020).csv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the full AutoEq headphone index from GitHub
|
||||||
|
* Uses GitHub API to get the repository tree, then parses it for measurement files
|
||||||
|
* Caches results in localStorage for 24 hours
|
||||||
|
* @returns {Promise<Array<{name: string, type: string, path: string, fileName: string}>>}
|
||||||
|
*/
|
||||||
|
async function fetchAutoEqIndex() {
|
||||||
|
// Migrate: remove old localStorage cache to free quota
|
||||||
|
try { localStorage.removeItem(OLD_LS_CACHE_KEY); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
// 1. Try loading from IndexedDB cache
|
||||||
|
try {
|
||||||
|
const cached = await db.getSetting(CACHE_KEY);
|
||||||
|
if (cached && cached.timestamp && cached.data) {
|
||||||
|
if (Date.now() - cached.timestamp < CACHE_EXPIRY) {
|
||||||
|
console.log('[AutoEQ] Loaded index from cache');
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AutoEQ] Failed to read cache:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch from GitHub API
|
||||||
|
try {
|
||||||
|
console.log('[AutoEQ] Fetching index from GitHub...');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch('https://api.github.com/repos/jaakkopasanen/AutoEq/git/trees/master?recursive=1', { signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const cached = await db.getSetting(CACHE_KEY);
|
||||||
|
if (cached?.data) {
|
||||||
|
console.warn('[AutoEQ] GitHub API limit reached. Using stale cache.');
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
console.warn('[AutoEQ] GitHub API error. Using fallback.');
|
||||||
|
return FALLBACK_INDEX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const item of data.tree) {
|
||||||
|
if (!item.path.startsWith('results/')) continue;
|
||||||
|
if (!item.path.endsWith('.csv') && !item.path.endsWith('.txt')) continue;
|
||||||
|
|
||||||
|
const parts = item.path.split('/');
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
|
||||||
|
const fileName = parts.pop();
|
||||||
|
const fileNameLower = fileName.toLowerCase();
|
||||||
|
|
||||||
|
// Skip non-measurement files (EQ presets, not raw frequency response)
|
||||||
|
if (fileNameLower.includes('parametriceq') ||
|
||||||
|
fileNameLower.includes('fixedbandeq') ||
|
||||||
|
fileNameLower.includes('graphiceq') ||
|
||||||
|
fileNameLower.includes('convolution') ||
|
||||||
|
fileNameLower.includes('fixed band eq') ||
|
||||||
|
fileNameLower.includes('parametric eq') ||
|
||||||
|
fileNameLower.includes('graphic eq')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headphoneName = parts[parts.length - 1];
|
||||||
|
const folderPath = parts.slice(1).join('/');
|
||||||
|
const source = parts[1];
|
||||||
|
|
||||||
|
let type = 'over-ear';
|
||||||
|
const lowerPath = item.path.toLowerCase();
|
||||||
|
if (lowerPath.includes('in-ear') || lowerPath.includes('iem')) {
|
||||||
|
type = 'in-ear';
|
||||||
|
} else if (lowerPath.includes('earbud')) {
|
||||||
|
type = 'in-ear';
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name: `${headphoneName} (${source})`,
|
||||||
|
type,
|
||||||
|
path: folderPath,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) return FALLBACK_INDEX;
|
||||||
|
|
||||||
|
const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// 3. Save to IndexedDB cache
|
||||||
|
try {
|
||||||
|
await db.saveSetting(CACHE_KEY, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: sortedEntries,
|
||||||
|
});
|
||||||
|
console.log(`[AutoEQ] Cached ${sortedEntries.length} entries`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AutoEQ] Failed to save cache:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedEntries;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
console.warn('[AutoEQ] GitHub API request timed out. Falling back to cache or fallback index.');
|
||||||
|
try {
|
||||||
|
const cached = await db.getSetting(CACHE_KEY);
|
||||||
|
if (cached?.data) return cached.data;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
console.error('[AutoEQ] Failed to fetch index:', err);
|
||||||
|
}
|
||||||
|
return FALLBACK_INDEX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the frequency response measurement data for a specific headphone
|
||||||
|
* Tries raw GitHub first, falls back to jsDelivr CDN
|
||||||
|
* @param {object} entry - AutoEq entry {name, type, path, fileName}
|
||||||
|
* @returns {Promise<Array<{freq: number, gain: number}>>}
|
||||||
|
*/
|
||||||
|
async function fetchHeadphoneData(entry) {
|
||||||
|
const encodedPath = entry.path.split('/').map(encodeURIComponent).join('/');
|
||||||
|
const encodedFileName = encodeURIComponent(entry.fileName);
|
||||||
|
|
||||||
|
const urls = [
|
||||||
|
`https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/${encodedPath}/${encodedFileName}`,
|
||||||
|
`https://cdn.jsdelivr.net/gh/jaakkopasanen/AutoEq@master/results/${encodedPath}/${encodedFileName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
if (!response.ok) continue;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
// Validate it's not an HTML error page
|
||||||
|
if (text.trim().startsWith('<!') || text.trim().startsWith('<html')) continue;
|
||||||
|
|
||||||
|
const points = parseRawData(text);
|
||||||
|
if (points.length > 0) return points;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[AutoEQ] Fetch failed for ${url}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to fetch data for ${entry.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search/filter headphone entries by query and optional type filter
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @param {Array} entries - Full list of entries
|
||||||
|
* @param {string} typeFilter - Optional type filter ('all', 'over-ear', 'in-ear')
|
||||||
|
* @param {number} limit - Maximum results to return
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function searchHeadphones(query, entries, typeFilter = 'all', limit = 100) {
|
||||||
|
let filtered = entries;
|
||||||
|
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(e => e.type === typeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const lower = query.toLowerCase().trim();
|
||||||
|
filtered = filtered.filter(e => e.name.toLowerCase().includes(lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES };
|
||||||
|
|
@ -621,8 +621,9 @@ export class Equalizer {
|
||||||
|
|
||||||
this.frequencies.forEach((freq, index) => {
|
this.frequencies.forEach((freq, index) => {
|
||||||
const gain = this.currentGains[index] || 0;
|
const gain = this.currentGains[index] || 0;
|
||||||
|
const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index);
|
||||||
const filterNum = index + 1;
|
const filterNum = index + 1;
|
||||||
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|
@ -680,16 +681,25 @@ export class Equalizer {
|
||||||
this.setBandCount(newCount);
|
this.setBandCount(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract gains from filters
|
// Apply imported filter frequencies directly instead of regenerating
|
||||||
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
const sliced = filters.slice(0, this.bandCount);
|
||||||
this.setAllGains(gains);
|
const newFreqs = sliced.map((f) => f.freq);
|
||||||
|
this.frequencies = newFreqs;
|
||||||
|
this.frequencyLabels = generateFrequencyLabels(newFreqs);
|
||||||
|
|
||||||
// Store filter frequencies if different
|
// Update filter frequencies on the actual biquad nodes
|
||||||
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
if (this.filters.length === newFreqs.length) {
|
||||||
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
newFreqs.forEach((freq, i) => {
|
||||||
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
if (this.filters[i]) {
|
||||||
|
this.filters[i].frequency.value = freq;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract and apply gains
|
||||||
|
const gains = sliced.map((f) => f.gain);
|
||||||
|
this.setAllGains(gains);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Equalizer] Failed to import settings:', e);
|
console.warn('[Equalizer] Failed to import settings:', e);
|
||||||
|
|
|
||||||
30
js/player.js
30
js/player.js
|
|
@ -16,7 +16,6 @@ import {
|
||||||
exponentialVolumeSettings,
|
exponentialVolumeSettings,
|
||||||
audioEffectsSettings,
|
audioEffectsSettings,
|
||||||
radioSettings,
|
radioSettings,
|
||||||
playbackSettings,
|
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
import { isIos, isSafari } from './platform-detection.js';
|
import { isIos, isSafari } from './platform-detection.js';
|
||||||
|
|
@ -49,7 +48,6 @@ export class Player {
|
||||||
this.repeatMode = REPEAT_MODE.OFF;
|
this.repeatMode = REPEAT_MODE.OFF;
|
||||||
this.preloadCache = new Map();
|
this.preloadCache = new Map();
|
||||||
this.preloadAbortController = null;
|
this.preloadAbortController = null;
|
||||||
this._lastPreloadTime = null;
|
|
||||||
this.currentTrack = null;
|
this.currentTrack = null;
|
||||||
this.currentRgValues = null;
|
this.currentRgValues = null;
|
||||||
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
|
||||||
|
|
@ -108,6 +106,7 @@ export class Player {
|
||||||
bufferingGoal: 30,
|
bufferingGoal: 30,
|
||||||
rebufferingGoal: 2,
|
rebufferingGoal: 2,
|
||||||
bufferBehind: 30,
|
bufferBehind: 30,
|
||||||
|
jumpLargeGaps: true,
|
||||||
},
|
},
|
||||||
abr: {
|
abr: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -151,6 +150,7 @@ export class Player {
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
const el = this.activeElement;
|
const el = this.activeElement;
|
||||||
if (document.visibilityState === 'visible' && !el.paused) {
|
if (document.visibilityState === 'visible' && !el.paused) {
|
||||||
|
// Ensure audio context is resumed when user returns to the app
|
||||||
if (!audioContextManager.isReady()) {
|
if (!audioContextManager.isReady()) {
|
||||||
audioContextManager.init(el);
|
audioContextManager.init(el);
|
||||||
}
|
}
|
||||||
|
|
@ -162,17 +162,6 @@ export class Player {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Time-based preload trigger for Safari background playback
|
|
||||||
this._timeUpdateHandler = this._handleTimeUpdateForPreload.bind(this);
|
|
||||||
this.audio.addEventListener('timeupdate', this._timeUpdateHandler);
|
|
||||||
if (this.video) {
|
|
||||||
this.video.addEventListener('timeupdate', this._timeUpdateHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('preload-time-change', () => {
|
|
||||||
this._lastPreloadTime = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._setupVideoSync();
|
this._setupVideoSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,21 +516,6 @@ export class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleTimeUpdateForPreload() {
|
|
||||||
const el = this.activeElement;
|
|
||||||
if (!el || !el.duration || el.paused) return;
|
|
||||||
|
|
||||||
const preloadTime = playbackSettings.getPreloadTime();
|
|
||||||
const timeRemaining = el.duration - el.currentTime;
|
|
||||||
if (timeRemaining <= preloadTime && timeRemaining > 0) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (!this._lastPreloadTime || now - this._lastPreloadTime > 5000) {
|
|
||||||
this._lastPreloadTime = now;
|
|
||||||
this.preloadNextTracks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupHlsVideo(video, result, fallbackImg) {
|
async setupHlsVideo(video, result, fallbackImg) {
|
||||||
const url = result.videoUrl || result.hlsUrl || result;
|
const url = result.videoUrl || result.hlsUrl || result;
|
||||||
const Hls = (await import('hls.js')).default;
|
const Hls = (await import('hls.js')).default;
|
||||||
|
|
|
||||||
4122
js/settings.js
4122
js/settings.js
File diff suppressed because it is too large
Load diff
228
js/storage.js
228
js/storage.js
|
|
@ -999,39 +999,11 @@ export const visualizerSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playbackSettings = {
|
|
||||||
FULLSCREEN_TILT_KEY: 'playback-fullscreen-tilt',
|
|
||||||
PRELOAD_TIME_KEY: 'playback-preload-time',
|
|
||||||
|
|
||||||
isFullscreenTiltEnabled() {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(this.FULLSCREEN_TILT_KEY) !== 'false';
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setFullscreenTiltEnabled(enabled) {
|
|
||||||
localStorage.setItem(this.FULLSCREEN_TILT_KEY, enabled ? 'true' : 'false');
|
|
||||||
},
|
|
||||||
|
|
||||||
getPreloadTime() {
|
|
||||||
try {
|
|
||||||
const val = localStorage.getItem(this.PRELOAD_TIME_KEY);
|
|
||||||
return val ? parseInt(val, 10) : 15;
|
|
||||||
} catch {
|
|
||||||
return 15;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setPreloadTime(seconds) {
|
|
||||||
localStorage.setItem(this.PRELOAD_TIME_KEY, seconds.toString());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const equalizerSettings = {
|
export const equalizerSettings = {
|
||||||
ENABLED_KEY: 'equalizer-enabled',
|
ENABLED_KEY: 'equalizer-enabled',
|
||||||
GAINS_KEY: 'equalizer-gains',
|
GAINS_KEY: 'equalizer-gains',
|
||||||
|
BAND_TYPES_KEY: 'equalizer-band-types',
|
||||||
|
BAND_QS_KEY: 'equalizer-band-qs',
|
||||||
PRESET_KEY: 'equalizer-preset',
|
PRESET_KEY: 'equalizer-preset',
|
||||||
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
CUSTOM_PRESETS_KEY: 'equalizer-custom-presets',
|
||||||
BAND_COUNT_KEY: 'equalizer-band-count',
|
BAND_COUNT_KEY: 'equalizer-band-count',
|
||||||
|
|
@ -1308,6 +1280,62 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getBandTypes(bandCount) {
|
||||||
|
const count = bandCount || this.getBandCount();
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.BAND_TYPES_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const types = JSON.parse(stored);
|
||||||
|
if (Array.isArray(types) && types.length === count) {
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return new Array(count).fill('peaking');
|
||||||
|
},
|
||||||
|
|
||||||
|
setBandTypes(types) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(types) && types.length >= this.MIN_BANDS && types.length <= this.MAX_BANDS) {
|
||||||
|
localStorage.setItem(this.BAND_TYPES_KEY, JSON.stringify(types));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EQ] Failed to save band types:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getBandQs(bandCount) {
|
||||||
|
const count = bandCount || this.getBandCount();
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.BAND_QS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const qs = JSON.parse(stored);
|
||||||
|
if (Array.isArray(qs) && qs.length === count) {
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
// Interpolate stored Qs to match requested band count instead of discarding
|
||||||
|
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) {
|
||||||
|
return this._interpolateGains(qs, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setBandQs(qs) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(qs) && qs.length >= this.MIN_BANDS && qs.length <= this.MAX_BANDS) {
|
||||||
|
localStorage.setItem(this.BAND_QS_KEY, JSON.stringify(qs));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EQ] Failed to save band Qs:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpolate gains array to match target band count
|
* Interpolate gains array to match target band count
|
||||||
*/
|
*/
|
||||||
|
|
@ -1440,6 +1468,130 @@ export const equalizerSettings = {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// AutoEQ Profile Storage
|
||||||
|
// ========================================
|
||||||
|
AUTOEQ_PROFILES_KEY: 'autoeq-saved-profiles',
|
||||||
|
AUTOEQ_ACTIVE_PROFILE_KEY: 'autoeq-active-profile',
|
||||||
|
AUTOEQ_SAMPLE_RATE_KEY: 'autoeq-sample-rate',
|
||||||
|
|
||||||
|
getAutoEQProfiles() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.AUTOEQ_PROFILES_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveAutoEQProfile(profile) {
|
||||||
|
try {
|
||||||
|
const profiles = this.getAutoEQProfiles();
|
||||||
|
const id = profile.id || 'autoeq_' + Date.now();
|
||||||
|
const profileCopy = { ...profile, id };
|
||||||
|
profiles[id] = profileCopy;
|
||||||
|
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||||
|
return id;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AutoEQ] Failed to save profile:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAutoEQProfile(profileId) {
|
||||||
|
try {
|
||||||
|
const profiles = this.getAutoEQProfiles();
|
||||||
|
if (profiles[profileId]) {
|
||||||
|
delete profiles[profileId];
|
||||||
|
localStorage.setItem(this.AUTOEQ_PROFILES_KEY, JSON.stringify(profiles));
|
||||||
|
if (this.getActiveAutoEQProfile() === profileId) {
|
||||||
|
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AutoEQ] Failed to delete profile:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveAutoEQProfile() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.AUTOEQ_ACTIVE_PROFILE_KEY) || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveAutoEQProfile(profileId) {
|
||||||
|
if (profileId) {
|
||||||
|
localStorage.setItem(this.AUTOEQ_ACTIVE_PROFILE_KEY, profileId);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(this.AUTOEQ_ACTIVE_PROFILE_KEY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSampleRate() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.AUTOEQ_SAMPLE_RATE_KEY);
|
||||||
|
const val = parseInt(stored, 10);
|
||||||
|
return [44100, 48000, 96000].includes(val) ? val : 48000;
|
||||||
|
} catch {
|
||||||
|
return 48000;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSampleRate(rate) {
|
||||||
|
localStorage.setItem(this.AUTOEQ_SAMPLE_RATE_KEY, rate.toString());
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Last Selected Headphone Persistence
|
||||||
|
// ========================================
|
||||||
|
AUTOEQ_LAST_HEADPHONE_KEY: 'autoeq-last-headphone',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the last selected headphone entry + its measurement data
|
||||||
|
* so it persists across page reloads without re-fetching from GitHub
|
||||||
|
* @param {object} entry - {name, type, path, fileName}
|
||||||
|
* @param {Array} measurementData - [{freq, gain}, ...]
|
||||||
|
*/
|
||||||
|
setLastHeadphone(entry, measurementData) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
this.AUTOEQ_LAST_HEADPHONE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
entry,
|
||||||
|
measurementData,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AutoEQ] Failed to save last headphone:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the last selected headphone entry + cached measurement data
|
||||||
|
* @returns {{entry: object, measurementData: Array}|null}
|
||||||
|
*/
|
||||||
|
getLastHeadphone() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed && parsed.entry && parsed.measurementData) return parsed;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLastHeadphone() {
|
||||||
|
localStorage.removeItem(this.AUTOEQ_LAST_HEADPHONE_KEY);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const monoAudioSettings = {
|
export const monoAudioSettings = {
|
||||||
|
|
@ -2573,7 +2725,7 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isArtistBlocked(artistId) {
|
isArtistBlocked(artistId) {
|
||||||
if (!artistId) return false;
|
if (!artistId) return false;
|
||||||
return this.getBlockedArtists().some((a) => a.id == artistId);
|
return this.getBlockedArtists().some((a) => String(a.id) === String(artistId));
|
||||||
},
|
},
|
||||||
|
|
||||||
blockArtist(artist) {
|
blockArtist(artist) {
|
||||||
|
|
@ -2590,7 +2742,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockArtist(artistId) {
|
unblockArtist(artistId) {
|
||||||
const blocked = this.getBlockedArtists().filter((a) => a.id != artistId);
|
const blocked = this.getBlockedArtists().filter((a) => a.id !== artistId);
|
||||||
this.setBlockedArtists(blocked);
|
this.setBlockedArtists(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -2610,13 +2762,13 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isTrackBlocked(trackId) {
|
isTrackBlocked(trackId) {
|
||||||
if (!trackId) return false;
|
if (!trackId) return false;
|
||||||
return this.getBlockedTracks().some((t) => t.id == trackId);
|
return this.getBlockedTracks().some((t) => t.id === trackId);
|
||||||
},
|
},
|
||||||
|
|
||||||
blockTrack(track) {
|
blockTrack(track) {
|
||||||
if (!track || !track.id) return;
|
if (!track || !track.id) return;
|
||||||
const blocked = this.getBlockedTracks();
|
const blocked = this.getBlockedTracks();
|
||||||
if (!blocked.some((t) => t.id == track.id)) {
|
if (!blocked.some((t) => t.id === track.id)) {
|
||||||
blocked.push({
|
blocked.push({
|
||||||
id: track.id,
|
id: track.id,
|
||||||
title: track.title || 'Unknown Track',
|
title: track.title || 'Unknown Track',
|
||||||
|
|
@ -2628,7 +2780,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockTrack(trackId) {
|
unblockTrack(trackId) {
|
||||||
const blocked = this.getBlockedTracks().filter((t) => t.id != trackId);
|
const blocked = this.getBlockedTracks().filter((t) => t.id !== trackId);
|
||||||
this.setBlockedTracks(blocked);
|
this.setBlockedTracks(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -2648,13 +2800,13 @@ export const contentBlockingSettings = {
|
||||||
|
|
||||||
isAlbumBlocked(albumId) {
|
isAlbumBlocked(albumId) {
|
||||||
if (!albumId) return false;
|
if (!albumId) return false;
|
||||||
return this.getBlockedAlbums().some((a) => a.id == albumId);
|
return this.getBlockedAlbums().some((a) => a.id === albumId);
|
||||||
},
|
},
|
||||||
|
|
||||||
blockAlbum(album) {
|
blockAlbum(album) {
|
||||||
if (!album || !album.id) return;
|
if (!album || !album.id) return;
|
||||||
const blocked = this.getBlockedAlbums();
|
const blocked = this.getBlockedAlbums();
|
||||||
if (!blocked.some((a) => a.id == album.id)) {
|
if (!blocked.some((a) => a.id === album.id)) {
|
||||||
blocked.push({
|
blocked.push({
|
||||||
id: album.id,
|
id: album.id,
|
||||||
title: album.title || 'Unknown Album',
|
title: album.title || 'Unknown Album',
|
||||||
|
|
@ -2666,7 +2818,7 @@ export const contentBlockingSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
unblockAlbum(albumId) {
|
unblockAlbum(albumId) {
|
||||||
const blocked = this.getBlockedAlbums().filter((a) => a.id != albumId);
|
const blocked = this.getBlockedAlbums().filter((a) => a.id !== albumId);
|
||||||
this.setBlockedAlbums(blocked);
|
this.setBlockedAlbums(blocked);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
52
js/ui.js
52
js/ui.js
|
|
@ -26,7 +26,6 @@ import {
|
||||||
fontSettings,
|
fontSettings,
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
settingsUiState,
|
settingsUiState,
|
||||||
playbackSettings,
|
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { db } from './db.js';
|
import { db } from './db.js';
|
||||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||||
|
|
@ -151,9 +150,6 @@ export class UIRenderer {
|
||||||
this.lastRecommendedTracks = [];
|
this.lastRecommendedTracks = [];
|
||||||
this.currentArtistId = null;
|
this.currentArtistId = null;
|
||||||
|
|
||||||
this._handleTiltMove = this._handleTiltMove.bind(this);
|
|
||||||
this._handleTiltLeave = this._handleTiltLeave.bind(this);
|
|
||||||
|
|
||||||
// Listen for dynamic color reset events
|
// Listen for dynamic color reset events
|
||||||
window.addEventListener('reset-dynamic-color', () => {
|
window.addEventListener('reset-dynamic-color', () => {
|
||||||
this.resetVibrantColor();
|
this.resetVibrantColor();
|
||||||
|
|
@ -1231,14 +1227,6 @@ export class UIRenderer {
|
||||||
|
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
|
|
||||||
// Apply vanilla-tilt effect to fullscreen cover if enabled
|
|
||||||
this._applyFullscreenTilt(overlay);
|
|
||||||
|
|
||||||
// Listen for tilt setting changes
|
|
||||||
window.addEventListener('fullscreen-tilt-toggle', (e) => {
|
|
||||||
this._applyFullscreenTilt(overlay, e.detail.enabled);
|
|
||||||
});
|
|
||||||
|
|
||||||
const startVisualizer = async () => {
|
const startVisualizer = async () => {
|
||||||
if (!visualizerSettings.isEnabled()) {
|
if (!visualizerSettings.isEnabled()) {
|
||||||
if (this.visualizer) this.visualizer.stop();
|
if (this.visualizer) this.visualizer.stop();
|
||||||
|
|
@ -1332,46 +1320,6 @@ export class UIRenderer {
|
||||||
clearTimeout(this.uiToggleMouseTimer);
|
clearTimeout(this.uiToggleMouseTimer);
|
||||||
this.uiToggleMouseTimer = null;
|
this.uiToggleMouseTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up vanilla-tilt if applied
|
|
||||||
this._removeFullscreenTilt();
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyFullscreenTilt(overlay, enabled = playbackSettings.isFullscreenTiltEnabled()) {
|
|
||||||
const image = document.getElementById('fullscreen-cover-image');
|
|
||||||
if (!image) return;
|
|
||||||
|
|
||||||
this._removeFullscreenTilt();
|
|
||||||
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
image.addEventListener('mousemove', this._handleTiltMove);
|
|
||||||
image.addEventListener('mouseleave', this._handleTiltLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleTiltMove(e) {
|
|
||||||
const image = e.target;
|
|
||||||
const rect = image.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const centerX = rect.width / 2;
|
|
||||||
const centerY = rect.height / 2;
|
|
||||||
const rotateX = ((y - centerY) / centerY) * -10;
|
|
||||||
const rotateY = ((x - centerX) / centerX) * 10;
|
|
||||||
|
|
||||||
image.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleTiltLeave(e) {
|
|
||||||
e.target.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) scale(1)';
|
|
||||||
}
|
|
||||||
|
|
||||||
_removeFullscreenTilt() {
|
|
||||||
const image = document.getElementById('fullscreen-cover-image');
|
|
||||||
if (!image) return;
|
|
||||||
image.removeEventListener('mousemove', this._handleTiltMove);
|
|
||||||
image.removeEventListener('mouseleave', this._handleTiltLeave);
|
|
||||||
image.style.transform = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupUIToggleButton(overlay) {
|
setupUIToggleButton(overlay) {
|
||||||
|
|
|
||||||
1693
styles.css
1693
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue