6043 lines
252 KiB
JavaScript
6043 lines
252 KiB
JavaScript
//js/settings
|
|
import {
|
|
themeManager,
|
|
lastFMStorage,
|
|
nowPlayingSettings,
|
|
fullscreenCoverClickSettings,
|
|
lyricsSettings,
|
|
backgroundSettings,
|
|
dynamicColorSettings,
|
|
cardSettings,
|
|
waveformSettings,
|
|
replayGainSettings,
|
|
downloadQualitySettings,
|
|
losslessContainerSettings,
|
|
coverArtSizeSettings,
|
|
qualityBadgeSettings,
|
|
trackDateSettings,
|
|
visualizerSettings,
|
|
playlistSettings,
|
|
equalizerSettings,
|
|
listenBrainzSettings,
|
|
malojaSettings,
|
|
libreFmSettings,
|
|
homePageSettings,
|
|
sidebarSectionSettings,
|
|
fontSettings,
|
|
monoAudioSettings,
|
|
exponentialVolumeSettings,
|
|
audioEffectsSettings,
|
|
settingsUiState,
|
|
pwaUpdateSettings,
|
|
contentBlockingSettings,
|
|
musicProviderSettings,
|
|
gaplessPlaybackSettings,
|
|
analyticsSettings,
|
|
modalSettings,
|
|
preferDolbyAtmosSettings,
|
|
} from './storage.js';
|
|
import { audioContextManager, getPresetsForBandCount } from './audio-context.js';
|
|
import { calculateBiquadResponse, interpolate, getNormalizationOffset, runAutoEqAlgorithm } from './autoeq-engine.js';
|
|
import { parseRawData, TARGETS, SPEAKER_TARGETS } from './autoeq-data.js';
|
|
import { fetchAutoEqIndex, fetchHeadphoneData, searchHeadphones, POPULAR_HEADPHONES } from './autoeq-importer.js';
|
|
import { db } from './db.js';
|
|
import { authManager } from './accounts/auth.js';
|
|
import { syncManager } from './accounts/pocketbase.js';
|
|
import { containerFormats, customFormats } from './ffmpegFormats.ts';
|
|
import { modernSettings } from './ModernSettings.js';
|
|
|
|
async function getButterchurnPresets(...args) {
|
|
const butterchurnModule = await import('./visualizers/butterchurn.js');
|
|
return butterchurnModule.getButterchurnPresets(...args);
|
|
}
|
|
|
|
// Module-level state for AutoEQ (persists across re-initializations)
|
|
let _autoeqIndex = [];
|
|
|
|
export async function initializeSettings(scrobbler, player, api, ui) {
|
|
// Restore last active settings tab
|
|
const savedTab = settingsUiState.getActiveTab();
|
|
const settingsTab = document.querySelector(`.settings-tab[data-tab="${savedTab}"]`);
|
|
if (settingsTab) {
|
|
document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
|
|
document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
|
|
settingsTab.classList.add('active');
|
|
document.getElementById(`settings-tab-${savedTab}`)?.classList.add('active');
|
|
}
|
|
|
|
// Initialize account system UI & Settings
|
|
authManager.updateUI(authManager.user);
|
|
|
|
// Email Auth UI Logic
|
|
const toggleEmailBtn = document.getElementById('toggle-email-auth-btn');
|
|
const authModalCloseBtn = document.getElementById('email-auth-modal-close');
|
|
const authModal = document.getElementById('email-auth-modal');
|
|
const emailInput = document.getElementById('auth-email');
|
|
const passwordInput = document.getElementById('auth-password');
|
|
const signInBtn = document.getElementById('email-signin-btn');
|
|
const signUpBtn = document.getElementById('email-signup-btn');
|
|
const resetPasswordBtn = document.getElementById('reset-password-btn');
|
|
|
|
if (toggleEmailBtn && authModal) {
|
|
toggleEmailBtn.addEventListener('click', () => {
|
|
authModal.classList.add('active');
|
|
});
|
|
}
|
|
|
|
if (authModal) {
|
|
const closeAuthModal = () => authModal.classList.remove('active');
|
|
authModalCloseBtn?.addEventListener('click', closeAuthModal);
|
|
authModal.querySelector('.modal-overlay')?.addEventListener('click', closeAuthModal);
|
|
}
|
|
|
|
if (signInBtn) {
|
|
signInBtn.addEventListener('click', async () => {
|
|
const email = emailInput.value;
|
|
const password = passwordInput.value;
|
|
if (!email || !password) {
|
|
alert('Please enter both email and password.');
|
|
return;
|
|
}
|
|
try {
|
|
await authManager.signInWithEmail(email, password);
|
|
authModal.classList.remove('active');
|
|
emailInput.value = '';
|
|
passwordInput.value = '';
|
|
} catch {
|
|
// Error handled in authManager
|
|
}
|
|
});
|
|
}
|
|
|
|
if (signUpBtn) {
|
|
signUpBtn.addEventListener('click', async () => {
|
|
const email = emailInput.value;
|
|
const password = passwordInput.value;
|
|
if (!email || !password) {
|
|
alert('Please enter both email and password.');
|
|
return;
|
|
}
|
|
try {
|
|
await authManager.signUpWithEmail(email, password);
|
|
authModal.classList.remove('active');
|
|
emailInput.value = '';
|
|
passwordInput.value = '';
|
|
} catch {
|
|
// Error handled in authManager
|
|
}
|
|
});
|
|
}
|
|
|
|
if (resetPasswordBtn) {
|
|
resetPasswordBtn.addEventListener('click', async () => {
|
|
const email = emailInput.value;
|
|
if (!email) {
|
|
alert('Please enter your email address to reset your password.');
|
|
return;
|
|
}
|
|
try {
|
|
await authManager.sendPasswordReset(email);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
|
const lastfmStatus = document.getElementById('lastfm-status');
|
|
const lastfmToggle = document.getElementById('lastfm-toggle');
|
|
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
|
|
const lastfmLoveToggle = document.getElementById('lastfm-love-toggle');
|
|
const lastfmLoveSetting = document.getElementById('lastfm-love-setting');
|
|
const lastfmCustomCredsToggle = document.getElementById('lastfm-custom-creds-toggle');
|
|
const lastfmCustomCredsToggleSetting = document.getElementById('lastfm-custom-creds-toggle-setting');
|
|
const lastfmCustomCredsSetting = document.getElementById('lastfm-custom-creds-setting');
|
|
const lastfmCustomApiKey = document.getElementById('lastfm-custom-api-key');
|
|
const lastfmCustomApiSecret = document.getElementById('lastfm-custom-api-secret');
|
|
const lastfmSaveCustomCreds = document.getElementById('lastfm-save-custom-creds');
|
|
const lastfmClearCustomCreds = document.getElementById('lastfm-clear-custom-creds');
|
|
const lastfmCredentialAuth = document.getElementById('lastfm-credential-auth');
|
|
const lastfmCredentialForm = document.getElementById('lastfm-credential-form');
|
|
const lastfmUsernameInput = document.getElementById('lastfm-username');
|
|
const lastfmPasswordInput = document.getElementById('lastfm-password');
|
|
const lastfmLoginCredentialsBtn = document.getElementById('lastfm-login-credentials');
|
|
const lastfmUseOAuthBtn = document.getElementById('lastfm-use-oauth');
|
|
|
|
function updateLastFMUI() {
|
|
if (scrobbler.lastfm.isAuthenticated()) {
|
|
lastfmStatus.textContent = `Connected as ${scrobbler.lastfm.username}`;
|
|
lastfmConnectBtn.textContent = 'Disconnect';
|
|
lastfmConnectBtn.classList.add('danger');
|
|
lastfmToggleSetting.style.display = 'flex';
|
|
lastfmLoveSetting.style.display = 'flex';
|
|
lastfmToggle.checked = lastFMStorage.isEnabled();
|
|
lastfmLoveToggle.checked = lastFMStorage.shouldLoveOnLike();
|
|
lastfmCustomCredsToggleSetting.style.display = 'flex';
|
|
lastfmCustomCredsToggle.checked = lastFMStorage.useCustomCredentials();
|
|
updateCustomCredsUI();
|
|
hideCredentialAuth();
|
|
} else {
|
|
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
|
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
|
lastfmConnectBtn.classList.remove('danger');
|
|
lastfmToggleSetting.style.display = 'none';
|
|
lastfmLoveSetting.style.display = 'none';
|
|
lastfmCustomCredsToggleSetting.style.display = 'none';
|
|
lastfmCustomCredsSetting.style.display = 'none';
|
|
// Hide credential auth by default - only show on OAuth failure
|
|
hideCredentialAuth();
|
|
}
|
|
}
|
|
|
|
function showCredentialAuth() {
|
|
if (lastfmCredentialAuth) lastfmCredentialAuth.style.display = 'block';
|
|
if (lastfmCredentialForm) lastfmCredentialForm.style.display = 'block';
|
|
// Focus on username field
|
|
if (lastfmUsernameInput) lastfmUsernameInput.focus();
|
|
}
|
|
|
|
function hideCredentialAuth() {
|
|
if (lastfmCredentialAuth) lastfmCredentialAuth.style.display = 'none';
|
|
if (lastfmCredentialForm) lastfmCredentialForm.style.display = 'none';
|
|
if (lastfmUsernameInput) lastfmUsernameInput.value = '';
|
|
if (lastfmPasswordInput) lastfmPasswordInput.value = '';
|
|
}
|
|
|
|
function updateCustomCredsUI() {
|
|
const useCustom = lastFMStorage.useCustomCredentials();
|
|
lastfmCustomCredsSetting.style.display = useCustom ? 'flex' : 'none';
|
|
|
|
if (useCustom) {
|
|
lastfmCustomApiKey.value = lastFMStorage.getCustomApiKey();
|
|
lastfmCustomApiSecret.value = lastFMStorage.getCustomApiSecret();
|
|
|
|
const hasCreds = lastFMStorage.getCustomApiKey() && lastFMStorage.getCustomApiSecret();
|
|
lastfmClearCustomCreds.style.display = hasCreds ? 'inline-block' : 'none';
|
|
}
|
|
}
|
|
|
|
updateLastFMUI();
|
|
|
|
lastfmConnectBtn?.addEventListener('click', async () => {
|
|
if (scrobbler.lastfm.isAuthenticated()) {
|
|
if (confirm('Disconnect from Last.fm?')) {
|
|
scrobbler.lastfm.disconnect();
|
|
updateLastFMUI();
|
|
}
|
|
return;
|
|
}
|
|
|
|
let authWindow = window.open('', '_blank');
|
|
|
|
lastfmConnectBtn.disabled = true;
|
|
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
|
|
|
try {
|
|
const { token, url } = await scrobbler.lastfm.getAuthUrl();
|
|
|
|
if (authWindow) {
|
|
authWindow.location.href = url;
|
|
} else {
|
|
alert('Popup blocked! Please allow popups.');
|
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
|
lastfmConnectBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 5;
|
|
|
|
const checkAuth = setInterval(async () => {
|
|
attempts++;
|
|
|
|
if (attempts > maxAttempts) {
|
|
clearInterval(checkAuth);
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
|
lastfmConnectBtn.disabled = false;
|
|
// Ask user if they want to use credentials instead
|
|
if (
|
|
confirm('Authorization timed out. Would you like to login with username and password instead?')
|
|
) {
|
|
showCredentialAuth();
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await scrobbler.lastfm.completeAuthentication(token);
|
|
|
|
if (result.success) {
|
|
clearInterval(checkAuth);
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
lastFMStorage.setEnabled(true);
|
|
lastfmToggle.checked = true;
|
|
updateLastFMUI();
|
|
lastfmConnectBtn.disabled = false;
|
|
}
|
|
} catch {
|
|
// Still waiting
|
|
}
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Last.fm connection failed:', error);
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
|
lastfmConnectBtn.disabled = false;
|
|
// Ask user if they want to use credentials instead
|
|
if (confirm('Failed to connect to Last.fm. Would you like to login with username and password instead?')) {
|
|
showCredentialAuth();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Last.fm Toggles
|
|
if (lastfmToggle) {
|
|
lastfmToggle.addEventListener('change', (e) => {
|
|
lastFMStorage.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
if (lastfmLoveToggle) {
|
|
lastfmLoveToggle.addEventListener('change', (e) => {
|
|
lastFMStorage.setLoveOnLike(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Custom Credentials Toggle
|
|
if (lastfmCustomCredsToggle) {
|
|
lastfmCustomCredsToggle.addEventListener('change', (e) => {
|
|
lastFMStorage.setUseCustomCredentials(e.target.checked);
|
|
updateCustomCredsUI();
|
|
|
|
// Reload credentials in the scrobbler
|
|
scrobbler.lastfm.reloadCredentials();
|
|
|
|
// If credentials are being disabled, clear any existing session
|
|
if (!e.target.checked && scrobbler.lastfm.isAuthenticated()) {
|
|
scrobbler.lastfm.disconnect();
|
|
updateLastFMUI();
|
|
alert('Switched to default API credentials. Please reconnect to Last.fm.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Save Custom Credentials
|
|
if (lastfmSaveCustomCreds) {
|
|
lastfmSaveCustomCreds.addEventListener('click', () => {
|
|
const apiKey = lastfmCustomApiKey.value.trim();
|
|
const apiSecret = lastfmCustomApiSecret.value.trim();
|
|
|
|
if (!apiKey || !apiSecret) {
|
|
alert('Please enter both API Key and API Secret');
|
|
return;
|
|
}
|
|
|
|
lastFMStorage.setCustomApiKey(apiKey);
|
|
lastFMStorage.setCustomApiSecret(apiSecret);
|
|
|
|
// Reload credentials
|
|
scrobbler.lastfm.reloadCredentials();
|
|
|
|
updateCustomCredsUI();
|
|
alert('Custom API credentials saved! Please reconnect to Last.fm to use them.');
|
|
|
|
// Disconnect current session if authenticated
|
|
if (scrobbler.lastfm.isAuthenticated()) {
|
|
scrobbler.lastfm.disconnect();
|
|
updateLastFMUI();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Clear Custom Credentials
|
|
if (lastfmClearCustomCreds) {
|
|
lastfmClearCustomCreds.addEventListener('click', () => {
|
|
if (confirm('Clear custom API credentials?')) {
|
|
lastFMStorage.clearCustomCredentials();
|
|
lastfmCustomApiKey.value = '';
|
|
lastfmCustomApiSecret.value = '';
|
|
lastfmCustomCredsToggle.checked = false;
|
|
|
|
// Reload credentials
|
|
scrobbler.lastfm.reloadCredentials();
|
|
|
|
updateCustomCredsUI();
|
|
|
|
// Disconnect current session if authenticated
|
|
if (scrobbler.lastfm.isAuthenticated()) {
|
|
scrobbler.lastfm.disconnect();
|
|
updateLastFMUI();
|
|
alert(
|
|
'Custom credentials cleared. Switched to default API credentials. Please reconnect to Last.fm.'
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Last.fm Credential Auth - Login with credentials
|
|
if (lastfmLoginCredentialsBtn) {
|
|
lastfmLoginCredentialsBtn.addEventListener('click', async () => {
|
|
const username = lastfmUsernameInput?.value?.trim();
|
|
const password = lastfmPasswordInput?.value;
|
|
|
|
if (!username || !password) {
|
|
alert('Please enter both username and password.');
|
|
return;
|
|
}
|
|
|
|
lastfmLoginCredentialsBtn.disabled = true;
|
|
lastfmLoginCredentialsBtn.textContent = 'Logging in...';
|
|
|
|
try {
|
|
const result = await scrobbler.lastfm.authenticateWithCredentials(username, password);
|
|
if (result.success) {
|
|
lastFMStorage.setEnabled(true);
|
|
lastfmToggle.checked = true;
|
|
updateLastFMUI();
|
|
// Clear password for security
|
|
if (lastfmPasswordInput) lastfmPasswordInput.value = '';
|
|
}
|
|
} catch (error) {
|
|
console.error('Last.fm credential login failed:', error);
|
|
alert('Failed to login: ' + error.message);
|
|
} finally {
|
|
lastfmLoginCredentialsBtn.disabled = false;
|
|
lastfmLoginCredentialsBtn.textContent = 'Login';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Last.fm Credential Auth - Switch back to OAuth
|
|
if (lastfmUseOAuthBtn) {
|
|
lastfmUseOAuthBtn.addEventListener('click', () => {
|
|
hideCredentialAuth();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Global Scrobble Settings
|
|
// ========================================
|
|
const scrobblePercentageSlider = document.getElementById('scrobble-percentage-slider');
|
|
const scrobblePercentageInput = document.getElementById('scrobble-percentage-input');
|
|
|
|
if (scrobblePercentageSlider && scrobblePercentageInput) {
|
|
const percentage = lastFMStorage.getScrobblePercentage();
|
|
scrobblePercentageSlider.value = percentage;
|
|
scrobblePercentageInput.value = percentage;
|
|
|
|
scrobblePercentageSlider.addEventListener('input', (e) => {
|
|
const newPercentage = parseInt(e.target.value, 10);
|
|
scrobblePercentageInput.value = newPercentage;
|
|
lastFMStorage.setScrobblePercentage(newPercentage);
|
|
});
|
|
|
|
scrobblePercentageInput.addEventListener('change', (e) => {
|
|
let newPercentage = parseInt(e.target.value, 10);
|
|
newPercentage = Math.max(1, Math.min(100, newPercentage || 75));
|
|
scrobblePercentageSlider.value = newPercentage;
|
|
scrobblePercentageInput.value = newPercentage;
|
|
lastFMStorage.setScrobblePercentage(newPercentage);
|
|
});
|
|
|
|
scrobblePercentageInput.addEventListener('input', (e) => {
|
|
let newPercentage = parseInt(e.target.value, 10);
|
|
if (!isNaN(newPercentage) && newPercentage >= 1 && newPercentage <= 100) {
|
|
scrobblePercentageSlider.value = newPercentage;
|
|
lastFMStorage.setScrobblePercentage(newPercentage);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// ListenBrainz Settings
|
|
// ========================================
|
|
const lbToggle = document.getElementById('listenbrainz-enabled-toggle');
|
|
const lbTokenSetting = document.getElementById('listenbrainz-token-setting');
|
|
const lbCustomUrlSetting = document.getElementById('listenbrainz-custom-url-setting');
|
|
const lbLoveSetting = document.getElementById('listenbrainz-love-setting');
|
|
const lbLoveToggle = document.getElementById('listenbrainz-love-toggle');
|
|
const lbTokenInput = document.getElementById('listenbrainz-token-input');
|
|
const lbCustomUrlInput = document.getElementById('listenbrainz-custom-url-input');
|
|
|
|
const updateListenBrainzUI = () => {
|
|
const isEnabled = listenBrainzSettings.isEnabled();
|
|
if (lbToggle) lbToggle.checked = isEnabled;
|
|
if (lbTokenSetting) lbTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
|
if (lbCustomUrlSetting) lbCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
|
if (lbLoveSetting) lbLoveSetting.style.display = isEnabled ? 'flex' : 'none';
|
|
if (lbTokenInput) lbTokenInput.value = listenBrainzSettings.getToken();
|
|
if (lbCustomUrlInput) lbCustomUrlInput.value = listenBrainzSettings.getCustomUrl();
|
|
if (lbLoveToggle) lbLoveToggle.checked = listenBrainzSettings.shouldLoveOnLike();
|
|
};
|
|
|
|
updateListenBrainzUI();
|
|
|
|
if (lbToggle) {
|
|
lbToggle.addEventListener('change', (e) => {
|
|
const enabled = e.target.checked;
|
|
listenBrainzSettings.setEnabled(enabled);
|
|
updateListenBrainzUI();
|
|
});
|
|
}
|
|
|
|
if (lbTokenInput) {
|
|
lbTokenInput.addEventListener('change', (e) => {
|
|
listenBrainzSettings.setToken(e.target.value.trim());
|
|
});
|
|
}
|
|
|
|
if (lbCustomUrlInput) {
|
|
lbCustomUrlInput.addEventListener('change', (e) => {
|
|
listenBrainzSettings.setCustomUrl(e.target.value.trim());
|
|
});
|
|
}
|
|
|
|
if (lbLoveToggle) {
|
|
lbLoveToggle.addEventListener('change', (e) => {
|
|
listenBrainzSettings.setLoveOnLike(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Maloja Settings
|
|
// ========================================
|
|
const malojaToggle = document.getElementById('maloja-enabled-toggle');
|
|
const malojaTokenSetting = document.getElementById('maloja-token-setting');
|
|
const malojaCustomUrlSetting = document.getElementById('maloja-custom-url-setting');
|
|
const malojaTokenInput = document.getElementById('maloja-token-input');
|
|
const malojaCustomUrlInput = document.getElementById('maloja-custom-url-input');
|
|
|
|
const updateMalojaUI = () => {
|
|
const isEnabled = malojaSettings.isEnabled();
|
|
if (malojaToggle) malojaToggle.checked = isEnabled;
|
|
if (malojaTokenSetting) malojaTokenSetting.style.display = isEnabled ? 'flex' : 'none';
|
|
if (malojaCustomUrlSetting) malojaCustomUrlSetting.style.display = isEnabled ? 'flex' : 'none';
|
|
if (malojaTokenInput) malojaTokenInput.value = malojaSettings.getToken();
|
|
if (malojaCustomUrlInput) malojaCustomUrlInput.value = malojaSettings.getCustomUrl();
|
|
};
|
|
|
|
updateMalojaUI();
|
|
|
|
if (malojaToggle) {
|
|
malojaToggle.addEventListener('change', (e) => {
|
|
const enabled = e.target.checked;
|
|
malojaSettings.setEnabled(enabled);
|
|
updateMalojaUI();
|
|
});
|
|
}
|
|
|
|
if (malojaTokenInput) {
|
|
malojaTokenInput.addEventListener('change', (e) => {
|
|
malojaSettings.setToken(e.target.value.trim());
|
|
});
|
|
}
|
|
|
|
if (malojaCustomUrlInput) {
|
|
malojaCustomUrlInput.addEventListener('change', (e) => {
|
|
malojaSettings.setCustomUrl(e.target.value.trim());
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Libre.fm Settings
|
|
// ========================================
|
|
const librefmConnectBtn = document.getElementById('librefm-connect-btn');
|
|
const librefmStatus = document.getElementById('librefm-status');
|
|
const librefmToggle = document.getElementById('librefm-toggle');
|
|
const librefmToggleSetting = document.getElementById('librefm-toggle-setting');
|
|
const librefmLoveToggle = document.getElementById('librefm-love-toggle');
|
|
const librefmLoveSetting = document.getElementById('librefm-love-setting');
|
|
|
|
function updateLibreFmUI() {
|
|
if (scrobbler.librefm.isAuthenticated()) {
|
|
librefmStatus.textContent = `Connected as ${scrobbler.librefm.username}`;
|
|
librefmConnectBtn.textContent = 'Disconnect';
|
|
librefmConnectBtn.classList.add('danger');
|
|
librefmToggleSetting.style.display = 'flex';
|
|
librefmLoveSetting.style.display = 'flex';
|
|
librefmToggle.checked = libreFmSettings.isEnabled();
|
|
librefmLoveToggle.checked = libreFmSettings.shouldLoveOnLike();
|
|
} else {
|
|
librefmStatus.textContent = 'Connect your Libre.fm account to scrobble tracks';
|
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
|
librefmConnectBtn.classList.remove('danger');
|
|
librefmToggleSetting.style.display = 'none';
|
|
librefmLoveSetting.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
if (librefmConnectBtn) {
|
|
updateLibreFmUI();
|
|
|
|
librefmConnectBtn.addEventListener('click', async () => {
|
|
if (scrobbler.librefm.isAuthenticated()) {
|
|
if (confirm('Disconnect from Libre.fm?')) {
|
|
scrobbler.librefm.disconnect();
|
|
updateLibreFmUI();
|
|
}
|
|
return;
|
|
}
|
|
|
|
let authWindow = window.open('', '_blank');
|
|
|
|
librefmConnectBtn.disabled = true;
|
|
librefmConnectBtn.textContent = 'Opening Libre.fm...';
|
|
|
|
try {
|
|
const { token, url } = await scrobbler.librefm.getAuthUrl();
|
|
|
|
if (authWindow) {
|
|
authWindow.location.href = url;
|
|
} else {
|
|
alert('Popup blocked! Please allow popups.');
|
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
|
librefmConnectBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
librefmConnectBtn.textContent = 'Waiting for authorization...';
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 30;
|
|
|
|
const checkAuth = setInterval(async () => {
|
|
attempts++;
|
|
|
|
if (attempts > maxAttempts) {
|
|
clearInterval(checkAuth);
|
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
|
librefmConnectBtn.disabled = false;
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
alert('Authorization timed out. Please try again.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await scrobbler.librefm.completeAuthentication(token);
|
|
|
|
if (result.success) {
|
|
clearInterval(checkAuth);
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
libreFmSettings.setEnabled(true);
|
|
librefmToggle.checked = true;
|
|
updateLibreFmUI();
|
|
librefmConnectBtn.disabled = false;
|
|
alert(`Successfully connected to Libre.fm as ${result.username}!`);
|
|
}
|
|
} catch {
|
|
// Still waiting
|
|
}
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Libre.fm connection failed:', error);
|
|
alert('Failed to connect to Libre.fm: ' + error.message);
|
|
librefmConnectBtn.textContent = 'Connect Libre.fm';
|
|
librefmConnectBtn.disabled = false;
|
|
if (authWindow && !authWindow.closed) authWindow.close();
|
|
}
|
|
});
|
|
|
|
// Libre.fm Toggles
|
|
if (librefmToggle) {
|
|
librefmToggle.addEventListener('change', (e) => {
|
|
libreFmSettings.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
if (librefmLoveToggle) {
|
|
librefmLoveToggle.addEventListener('change', (e) => {
|
|
libreFmSettings.setLoveOnLike(e.target.checked);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Theme picker
|
|
const themePicker = document.getElementById('theme-picker');
|
|
const currentTheme = themeManager.getTheme();
|
|
|
|
themePicker.querySelectorAll('.theme-option').forEach((option) => {
|
|
if (option.dataset.theme === currentTheme) {
|
|
option.classList.add('active');
|
|
}
|
|
|
|
option.addEventListener('click', () => {
|
|
const theme = option.dataset.theme;
|
|
|
|
themePicker.querySelectorAll('.theme-option').forEach((opt) => opt.classList.remove('active'));
|
|
option.classList.add('active');
|
|
|
|
if (theme === 'custom') {
|
|
document.getElementById('custom-theme-editor').classList.add('show');
|
|
renderCustomThemeEditor();
|
|
themeManager.setTheme('custom');
|
|
} else {
|
|
document.getElementById('custom-theme-editor').classList.remove('show');
|
|
themeManager.setTheme(theme);
|
|
}
|
|
});
|
|
});
|
|
|
|
const communityThemeContainer = document.getElementById('applied-community-theme-container');
|
|
const communityThemeBtn = document.getElementById('applied-community-theme-btn');
|
|
const communityThemeDetails = document.getElementById('community-theme-details-panel');
|
|
const communityThemeUnapplyBtn = document.getElementById('ct-unapply-btn');
|
|
const appliedThemeName = document.getElementById('applied-theme-name');
|
|
const ctDetailsTitle = document.getElementById('ct-details-title');
|
|
const ctDetailsAuthor = document.getElementById('ct-details-author');
|
|
|
|
function updateCommunityThemeUI() {
|
|
const metadataStr = localStorage.getItem('community-theme');
|
|
if (metadataStr) {
|
|
try {
|
|
const metadata = JSON.parse(metadataStr);
|
|
if (communityThemeContainer) communityThemeContainer.style.display = 'block';
|
|
if (appliedThemeName) appliedThemeName.textContent = metadata.name;
|
|
if (ctDetailsTitle) ctDetailsTitle.textContent = metadata.name;
|
|
if (ctDetailsAuthor) ctDetailsAuthor.textContent = `by ${metadata.author}`;
|
|
} catch {
|
|
if (communityThemeContainer) communityThemeContainer.style.display = 'none';
|
|
}
|
|
} else {
|
|
if (communityThemeContainer) communityThemeContainer.style.display = 'none';
|
|
if (communityThemeDetails) communityThemeDetails.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
updateCommunityThemeUI();
|
|
window.addEventListener('theme-changed', updateCommunityThemeUI);
|
|
|
|
if (communityThemeBtn) {
|
|
communityThemeBtn.addEventListener('click', () => {
|
|
const isVisible = communityThemeDetails.style.display === 'block';
|
|
communityThemeDetails.style.display = isVisible ? 'none' : 'block';
|
|
});
|
|
}
|
|
|
|
if (communityThemeUnapplyBtn) {
|
|
communityThemeUnapplyBtn.addEventListener('click', () => {
|
|
if (confirm('Unapply this community theme?')) {
|
|
localStorage.removeItem('custom_theme_css');
|
|
localStorage.removeItem('community-theme');
|
|
const styleEl = document.getElementById('custom-theme-style');
|
|
if (styleEl) styleEl.remove();
|
|
themeManager.setTheme('system');
|
|
|
|
const themePicker = document.getElementById('theme-picker');
|
|
if (themePicker) {
|
|
themePicker.querySelectorAll('.theme-option').forEach((opt) => opt.classList.remove('active'));
|
|
themePicker.querySelector('[data-theme="system"]')?.classList.add('active');
|
|
}
|
|
document.getElementById('custom-theme-editor').classList.remove('show');
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderCustomThemeEditor() {
|
|
const grid = document.getElementById('theme-color-grid');
|
|
const customTheme = themeManager.getCustomTheme() || {
|
|
background: '#000000',
|
|
foreground: '#fafafa',
|
|
primary: '#ffffff',
|
|
secondary: '#27272a',
|
|
muted: '#27272a',
|
|
border: '#27272a',
|
|
highlight: '#ffffff',
|
|
};
|
|
|
|
grid.innerHTML = Object.entries(customTheme)
|
|
.map(
|
|
([key, value]) => `
|
|
<div class="theme-color-input">
|
|
<label>${key}</label>
|
|
<input type="color" data-color="${key}" value="${value}">
|
|
</div>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
|
|
const colors = {};
|
|
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach((input) => {
|
|
colors[input.dataset.color] = input.value;
|
|
});
|
|
themeManager.setCustomTheme(colors);
|
|
});
|
|
|
|
document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
|
|
renderCustomThemeEditor();
|
|
});
|
|
|
|
// Music Provider setting
|
|
const musicProviderSetting = document.getElementById('music-provider-setting');
|
|
if (musicProviderSetting) {
|
|
musicProviderSetting.value = musicProviderSettings.getProvider();
|
|
musicProviderSetting.addEventListener('change', (e) => {
|
|
musicProviderSettings.setProvider(e.target.value);
|
|
// Reload page to apply changes
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
// Streaming Quality setting
|
|
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
|
|
if (streamingQualitySetting) {
|
|
const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto';
|
|
|
|
// Map the stored auto state to the dropdown, or if it doesn't match an option, use the playback-quality value
|
|
const optionExists = Array.from(streamingQualitySetting.options).some(
|
|
(opt) => opt.value === savedAdaptiveQuality
|
|
);
|
|
streamingQualitySetting.value = optionExists
|
|
? savedAdaptiveQuality
|
|
: localStorage.getItem('playback-quality') || 'auto';
|
|
|
|
// Apply initially
|
|
if (player.forceQuality) player.forceQuality(streamingQualitySetting.value);
|
|
const apiQuality = streamingQualitySetting.value === 'auto' ? 'HI_RES_LOSSLESS' : streamingQualitySetting.value;
|
|
player.setQuality(localStorage.getItem('playback-quality') || apiQuality);
|
|
|
|
streamingQualitySetting.addEventListener('change', (e) => {
|
|
const val = e.target.value;
|
|
|
|
// Set adaptive DASH quality
|
|
localStorage.setItem('adaptive-playback-quality', val);
|
|
if (player.forceQuality) player.forceQuality(val);
|
|
|
|
// Set fallback API quality
|
|
const newApiQuality = val === 'auto' ? 'HI_RES_LOSSLESS' : val;
|
|
player.setQuality(newApiQuality);
|
|
localStorage.setItem('playback-quality', newApiQuality);
|
|
});
|
|
}
|
|
|
|
// Download Quality setting
|
|
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
|
if (downloadQualitySetting) {
|
|
// Assign categories to the static (native) options already in the HTML
|
|
const staticCategories = {
|
|
HI_RES_LOSSLESS: 'Lossless',
|
|
LOSSLESS: 'Lossless',
|
|
HIGH: 'AAC',
|
|
LOW: 'AAC',
|
|
};
|
|
|
|
// Collect static options first (preserving their original order)
|
|
const allOptions = Array.from(downloadQualitySetting.options).map((opt) => ({
|
|
value: opt.value,
|
|
text: opt.textContent,
|
|
category: staticCategories[opt.value] || 'Other',
|
|
}));
|
|
|
|
// Append custom (ffmpeg-transcoded) format options
|
|
for (const [key, fmt] of Object.entries(customFormats)) {
|
|
allOptions.push({ value: key, text: fmt.displayName, category: fmt.category });
|
|
}
|
|
|
|
// Sort by category order first, then by bitrate descending within each category
|
|
// so higher-quality options always appear before lower-quality ones.
|
|
// Options without an explicit kbps value (lossless) use Infinity so they
|
|
// sort to the top; ties fall back to display-name descending.
|
|
const getBitrate = (text) => {
|
|
const m = text.match(/(\d+)\s*kbps/i);
|
|
return m ? parseInt(m[1], 10) : Infinity;
|
|
};
|
|
const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
|
|
allOptions.sort((a, b) => {
|
|
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
|
|
const ai = categoryOrder.indexOf(a.category);
|
|
const bi = categoryOrder.indexOf(b.category);
|
|
const categoryDiff = (ai === -1 ? categoryOrder.length : ai) - (bi === -1 ? categoryOrder.length : bi);
|
|
if (categoryDiff !== 0) return categoryDiff;
|
|
const bitrateA = getBitrate(a.text);
|
|
const bitrateB = getBitrate(b.text);
|
|
if (bitrateA !== bitrateB) return bitrateB - bitrateA;
|
|
return b.text.localeCompare(a.text);
|
|
});
|
|
|
|
// Rebuild the select with optgroup elements per category
|
|
downloadQualitySetting.innerHTML = '';
|
|
let currentGroup = null;
|
|
let currentCategory = null;
|
|
for (const opt of allOptions) {
|
|
if (opt.category !== currentCategory) {
|
|
currentCategory = opt.category;
|
|
currentGroup = document.createElement('optgroup');
|
|
currentGroup.label = opt.category;
|
|
downloadQualitySetting.appendChild(currentGroup);
|
|
}
|
|
const option = document.createElement('option');
|
|
option.value = opt.value;
|
|
option.textContent = opt.text;
|
|
currentGroup.appendChild(option);
|
|
}
|
|
|
|
downloadQualitySetting.value = downloadQualitySettings.getQuality();
|
|
|
|
downloadQualitySetting.addEventListener('change', (e) => {
|
|
downloadQualitySettings.setQuality(e.target.value);
|
|
updateLosslessContainerVisibility();
|
|
});
|
|
}
|
|
|
|
const prefersAtmosSetting = document.getElementById('dolby-atmos-toggle');
|
|
if (prefersAtmosSetting) {
|
|
prefersAtmosSetting.checked = preferDolbyAtmosSettings.isEnabled();
|
|
prefersAtmosSetting.addEventListener('change', (e) => {
|
|
preferDolbyAtmosSettings.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
|
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
|
|
|
/** Shows/hides the Lossless Container setting based on the selected quality */
|
|
function updateLosslessContainerVisibility() {
|
|
if (!losslessContainerSettingItem) return;
|
|
const quality = downloadQualitySettings.getQuality();
|
|
const isLossless = quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS';
|
|
losslessContainerSettingItem.style.display = isLossless ? '' : 'none';
|
|
}
|
|
|
|
if (losslessContainerSetting) {
|
|
const noChangeOption = losslessContainerSetting.querySelector('option:last-child');
|
|
noChangeOption.remove();
|
|
|
|
for (const [internalName, { displayName }] of Object.entries(containerFormats)) {
|
|
const option = document.createElement('option');
|
|
option.value = internalName;
|
|
option.textContent = displayName;
|
|
losslessContainerSetting.appendChild(option);
|
|
}
|
|
|
|
losslessContainerSetting.append(noChangeOption);
|
|
|
|
losslessContainerSetting.value = losslessContainerSettings.getContainer();
|
|
|
|
losslessContainerSetting.addEventListener('change', (e) => {
|
|
losslessContainerSettings.setContainer(e.target.value);
|
|
});
|
|
}
|
|
|
|
updateLosslessContainerVisibility();
|
|
|
|
// Cover Art Size setting
|
|
const coverArtSizeSetting = document.getElementById('cover-art-size-setting');
|
|
if (coverArtSizeSetting) {
|
|
coverArtSizeSetting.value = coverArtSizeSettings.getSize();
|
|
|
|
coverArtSizeSetting.addEventListener('change', (e) => {
|
|
coverArtSizeSettings.setSize(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Quality Badge Settings
|
|
const showQualityBadgesToggle = document.getElementById('show-quality-badges-toggle');
|
|
if (showQualityBadgesToggle) {
|
|
showQualityBadgesToggle.checked = qualityBadgeSettings.isEnabled();
|
|
showQualityBadgesToggle.addEventListener('change', (e) => {
|
|
qualityBadgeSettings.setEnabled(e.target.checked);
|
|
// Re-render queue if available, but don't force navigation to library
|
|
if (window.renderQueueFunction) window.renderQueueFunction();
|
|
});
|
|
}
|
|
|
|
// Track Date Settings
|
|
const useAlbumReleaseYearToggle = document.getElementById('use-album-release-year-toggle');
|
|
if (useAlbumReleaseYearToggle) {
|
|
useAlbumReleaseYearToggle.checked = trackDateSettings.useAlbumYear();
|
|
useAlbumReleaseYearToggle.addEventListener('change', (e) => {
|
|
trackDateSettings.setUseAlbumYear(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const forceZipBlobToggle = document.getElementById('force-zip-blob-toggle');
|
|
const forceZipBlobSettingItem = forceZipBlobToggle?.closest('.setting-item');
|
|
const hasFileSystemAccess =
|
|
'showSaveFilePicker' in window &&
|
|
typeof FileSystemFileHandle !== 'undefined' &&
|
|
'createWritable' in FileSystemFileHandle.prototype;
|
|
const hasFolderPicker = 'showDirectoryPicker' in window;
|
|
|
|
const rememberFolderSetting = document.getElementById('remember-folder-setting');
|
|
const rememberFolderToggle = document.getElementById('remember-folder-toggle');
|
|
const resetSavedFolderSetting = document.getElementById('reset-saved-folder-setting');
|
|
const resetSavedFolderBtn = document.getElementById('reset-saved-folder-btn');
|
|
const singleToFolderSetting = document.getElementById('single-to-folder-setting');
|
|
const singleToFolderToggle = document.getElementById('single-to-folder-toggle');
|
|
|
|
/** Shows/hides the Force ZIP as Blob setting based on method and browser support */
|
|
function updateForceZipBlobVisibility() {
|
|
if (!forceZipBlobSettingItem) return;
|
|
const method = modernSettings.bulkDownloadMethod;
|
|
// Only relevant when zip method is selected and the browser supports streaming
|
|
const visible = method === 'zip' && hasFileSystemAccess;
|
|
forceZipBlobSettingItem.style.display = visible ? '' : 'none';
|
|
}
|
|
|
|
/** Shows/hides folder-picker-specific and folder-method settings */
|
|
async function updateFolderMethodVisibility() {
|
|
const method = modernSettings.bulkDownloadMethod;
|
|
const isFolderMethod = method === 'folder';
|
|
const isFolderOrLocal = isFolderMethod || method === 'local';
|
|
|
|
if (rememberFolderSetting) {
|
|
rememberFolderSetting.style.display = isFolderMethod && hasFolderPicker ? '' : 'none';
|
|
}
|
|
|
|
// Reset button: only visible when folder method + remember enabled + valid saved handle exists
|
|
if (resetSavedFolderSetting) {
|
|
let showReset = false;
|
|
if (isFolderMethod && hasFolderPicker && modernSettings.rememberBulkDownloadFolder) {
|
|
const savedHandle = modernSettings.bulkDownloadFolder;
|
|
showReset = !!savedHandle;
|
|
}
|
|
resetSavedFolderSetting.style.display = showReset ? '' : 'none';
|
|
}
|
|
|
|
if (singleToFolderSetting) {
|
|
singleToFolderSetting.style.display = isFolderOrLocal ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
const bulkDownloadMethod = document.getElementById('bulk-download-method');
|
|
if (bulkDownloadMethod) {
|
|
// Remove the folder picker option if the browser doesn't support it
|
|
if (!hasFolderPicker) {
|
|
const folderOption = bulkDownloadMethod.querySelector('option[value="folder"]');
|
|
if (folderOption) {
|
|
folderOption.remove();
|
|
}
|
|
const localOption = bulkDownloadMethod.querySelector('option[value="local"]');
|
|
if (localOption) {
|
|
localOption.remove();
|
|
}
|
|
// If the stored method is 'folder' or 'local' without native support, fall back to 'zip'
|
|
const currentMethod = modernSettings.bulkDownloadMethod;
|
|
if (currentMethod === 'folder' || currentMethod === 'local') {
|
|
modernSettings.bulkDownloadMethod = 'zip';
|
|
}
|
|
}
|
|
bulkDownloadMethod.value = modernSettings.bulkDownloadMethod;
|
|
bulkDownloadMethod.addEventListener('change', async (e) => {
|
|
const previousMethod = modernSettings.bulkDownloadMethod;
|
|
const newMethod = e.target.value;
|
|
modernSettings.bulkDownloadMethod = newMethod;
|
|
|
|
// When switching to 'local', prompt to select the local media folder if not yet configured
|
|
if (newMethod === 'local') {
|
|
const existingHandle = await db.getSetting('local_folder_handle');
|
|
if (!existingHandle) {
|
|
let picked = false;
|
|
try {
|
|
if (hasFolderPicker) {
|
|
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
if (handle) {
|
|
picked = true;
|
|
await db.saveSetting('local_folder_handle', handle);
|
|
}
|
|
}
|
|
} catch {
|
|
// User cancelled the picker
|
|
}
|
|
|
|
if (!picked) {
|
|
// Revert to the previous method since no folder was selected.
|
|
// Guard against the edge case where the previousMethod option
|
|
// no longer exists in the dropdown (e.g. removed due to no API support).
|
|
if (bulkDownloadMethod.querySelector(`option[value="${previousMethod}"]`)) {
|
|
modernSettings.bulkDownloadMethod = previousMethod;
|
|
bulkDownloadMethod.value = previousMethod;
|
|
} else {
|
|
// Fall back to zip which is always present
|
|
modernSettings.bulkDownloadMethod = 'zip';
|
|
bulkDownloadMethod.value = 'zip';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await modernSettings.waitPending();
|
|
|
|
updateForceZipBlobVisibility();
|
|
await updateFolderMethodVisibility();
|
|
});
|
|
}
|
|
|
|
if (rememberFolderToggle) {
|
|
rememberFolderToggle.checked = modernSettings.rememberBulkDownloadFolder;
|
|
rememberFolderToggle.addEventListener('change', async (e) => {
|
|
modernSettings.rememberBulkDownloadFolder = !!e.target.checked;
|
|
await modernSettings.waitPending();
|
|
await updateFolderMethodVisibility();
|
|
});
|
|
}
|
|
|
|
if (resetSavedFolderBtn) {
|
|
resetSavedFolderBtn.addEventListener('click', async () => {
|
|
modernSettings.bulkDownloadFolder = null;
|
|
await modernSettings.waitPending();
|
|
await updateFolderMethodVisibility();
|
|
});
|
|
}
|
|
|
|
if (singleToFolderToggle) {
|
|
singleToFolderToggle.checked = modernSettings.downloadSinglesToFolder;
|
|
singleToFolderToggle.addEventListener('change', (e) => {
|
|
modernSettings.downloadSinglesToFolder = !!e.target.checked;
|
|
});
|
|
}
|
|
|
|
if (forceZipBlobToggle) {
|
|
forceZipBlobToggle.checked = modernSettings.forceZipBlob;
|
|
forceZipBlobToggle.addEventListener('change', (e) => {
|
|
modernSettings.forceZipBlob = !!e.target.checked;
|
|
});
|
|
}
|
|
|
|
updateForceZipBlobVisibility();
|
|
await updateFolderMethodVisibility();
|
|
|
|
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
|
if (includeCoverToggle) {
|
|
includeCoverToggle.checked = playlistSettings.shouldIncludeCover();
|
|
includeCoverToggle.addEventListener('change', (e) => {
|
|
playlistSettings.setIncludeCover(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const gaplessPlaybackToggle = document.getElementById('gapless-playback-toggle');
|
|
if (gaplessPlaybackToggle) {
|
|
gaplessPlaybackToggle.checked = gaplessPlaybackSettings.isEnabled();
|
|
gaplessPlaybackToggle.addEventListener('change', (e) => {
|
|
gaplessPlaybackSettings.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// ReplayGain Settings
|
|
const replayGainMode = document.getElementById('replay-gain-mode');
|
|
if (replayGainMode) {
|
|
replayGainMode.value = replayGainSettings.getMode();
|
|
replayGainMode.addEventListener('change', (e) => {
|
|
replayGainSettings.setMode(e.target.value);
|
|
player.applyReplayGain();
|
|
});
|
|
}
|
|
|
|
const replayGainPreamp = document.getElementById('replay-gain-preamp');
|
|
if (replayGainPreamp) {
|
|
replayGainPreamp.value = replayGainSettings.getPreamp();
|
|
replayGainPreamp.addEventListener('change', (e) => {
|
|
replayGainSettings.setPreamp(parseFloat(e.target.value) || 3);
|
|
player.applyReplayGain();
|
|
});
|
|
}
|
|
|
|
// Mono Audio Toggle
|
|
const monoAudioToggle = document.getElementById('mono-audio-toggle');
|
|
if (monoAudioToggle) {
|
|
monoAudioToggle.checked = monoAudioSettings.isEnabled();
|
|
monoAudioToggle.addEventListener('change', (e) => {
|
|
const enabled = e.target.checked;
|
|
monoAudioSettings.setEnabled(enabled);
|
|
audioContextManager.toggleMonoAudio(enabled);
|
|
});
|
|
}
|
|
|
|
// Exponential Volume Toggle
|
|
const exponentialVolumeToggle = document.getElementById('exponential-volume-toggle');
|
|
if (exponentialVolumeToggle) {
|
|
exponentialVolumeToggle.checked = exponentialVolumeSettings.isEnabled();
|
|
exponentialVolumeToggle.addEventListener('change', (e) => {
|
|
exponentialVolumeSettings.setEnabled(e.target.checked);
|
|
// Re-apply current volume to use new curve
|
|
player.applyReplayGain();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Audio Effects (Playback Speed)
|
|
// ========================================
|
|
const playbackSpeedSlider = document.getElementById('playback-speed-slider');
|
|
const playbackSpeedInput = document.getElementById('playback-speed-input');
|
|
const playbackSpeedReset = document.getElementById('playback-speed-reset');
|
|
|
|
if (playbackSpeedSlider && playbackSpeedInput) {
|
|
// Helper function to update both controls
|
|
const updatePlaybackSpeedControls = (speed) => {
|
|
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
|
playbackSpeedInput.value = validSpeed;
|
|
// Only update slider if value is within slider range
|
|
if (validSpeed >= 0.25 && validSpeed <= 4.0) {
|
|
playbackSpeedSlider.value = validSpeed;
|
|
}
|
|
return validSpeed;
|
|
};
|
|
|
|
// Initialize with current value
|
|
const currentSpeed = audioEffectsSettings.getSpeed();
|
|
updatePlaybackSpeedControls(currentSpeed);
|
|
|
|
playbackSpeedSlider.addEventListener('input', (e) => {
|
|
const speed = parseFloat(e.target.value);
|
|
playbackSpeedInput.value = speed;
|
|
audioEffectsSettings.setSpeed(speed);
|
|
player.setPlaybackSpeed(speed);
|
|
});
|
|
|
|
playbackSpeedInput.addEventListener('input', (e) => {
|
|
const speed = parseFloat(e.target.value);
|
|
if (!isNaN(speed) && speed >= 0.01 && speed <= 100) {
|
|
if (speed >= 0.25 && speed <= 4.0) {
|
|
playbackSpeedSlider.value = speed;
|
|
}
|
|
audioEffectsSettings.setSpeed(speed);
|
|
player.setPlaybackSpeed(speed);
|
|
}
|
|
});
|
|
|
|
playbackSpeedInput.addEventListener('change', (e) => {
|
|
const speed = parseFloat(e.target.value);
|
|
const validSpeed = updatePlaybackSpeedControls(speed);
|
|
audioEffectsSettings.setSpeed(validSpeed);
|
|
player.setPlaybackSpeed(validSpeed);
|
|
});
|
|
|
|
if (playbackSpeedReset) {
|
|
playbackSpeedReset.addEventListener('click', () => {
|
|
const defaultSpeed = audioEffectsSettings.resetSpeed();
|
|
updatePlaybackSpeedControls(defaultSpeed);
|
|
player.setPlaybackSpeed(defaultSpeed);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Preserve Pitch Toggle
|
|
// ========================================
|
|
const preservePitchToggle = document.getElementById('preserve-pitch-toggle');
|
|
if (preservePitchToggle) {
|
|
preservePitchToggle.checked = audioEffectsSettings.isPreservePitchEnabled();
|
|
|
|
preservePitchToggle.addEventListener('change', (e) => {
|
|
player.setPreservePitch(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Precision AutoEQ — Redesigned Equalizer
|
|
// ========================================
|
|
const eqToggle = document.getElementById('equalizer-enabled-toggle');
|
|
const eqContainer = document.getElementById('equalizer-container');
|
|
const eqPreampSlider = document.getElementById('eq-preamp-slider');
|
|
|
|
// AutoEQ State (kept when switching modes)
|
|
let autoeqSelectedMeasurement = null;
|
|
let autoeqSelectedEntry = null;
|
|
let autoeqCurrentBands = null; // AutoEQ-generated bands
|
|
let autoeqCorrectedCurve = null;
|
|
let currentPreamp = equalizerSettings.getPreamp();
|
|
|
|
// Parametric EQ State (separate from AutoEQ, kept when switching modes)
|
|
let parametricBands = null;
|
|
|
|
// Interactive graph state
|
|
let draggedNode = null;
|
|
let hoveredNode = null;
|
|
let graphAnimFrame = null;
|
|
|
|
// dB zoom state (half-range values, user-adjustable via scroll on Y axis)
|
|
let graphDbHalfAutoEQ = 25;
|
|
let graphDbHalfParametric = 35;
|
|
|
|
/** Get the active bands for the current mode */
|
|
const getActiveBands = () => {
|
|
if (currentMode === 'parametric') return parametricBands;
|
|
if (currentMode === 'speaker') return speakerChannels[speakerActiveChannel]?.bands || null;
|
|
return autoeqCurrentBands;
|
|
};
|
|
/** Set the active bands for the current mode */
|
|
const setActiveBands = (bands) => {
|
|
if (currentMode === 'parametric') parametricBands = bands;
|
|
else if (currentMode === 'speaker') speakerChannels[speakerActiveChannel].bands = bands;
|
|
else autoeqCurrentBands = bands;
|
|
};
|
|
|
|
// DOM Elements
|
|
const autoeqCanvas = document.getElementById('autoeq-response-canvas');
|
|
const autoeqGraphWrapper = document.getElementById('autoeq-graph-wrapper');
|
|
const autoeqHeadphoneSelect = document.getElementById('autoeq-headphone-select');
|
|
const autoeqTargetSelect = document.getElementById('autoeq-target-select');
|
|
const autoeqBandCount = document.getElementById('autoeq-band-count');
|
|
const autoeqMaxFreq = document.getElementById('autoeq-max-freq');
|
|
const autoeqSampleRate = document.getElementById('autoeq-sample-rate');
|
|
|
|
// Safely set band count dropdown, ensuring the value matches an available option
|
|
const setAutoeqBandCount = (count, bands) => {
|
|
if (!autoeqBandCount) return;
|
|
const val = String(count || (bands && bands.length) || 10);
|
|
autoeqBandCount.value = val;
|
|
// If value didn't match any option (dropdown shows blank), add it or fall back
|
|
if (autoeqBandCount.value !== val) {
|
|
// Try using actual band count from the bands array
|
|
if (bands && bands.length) {
|
|
const bandsVal = String(bands.length);
|
|
autoeqBandCount.value = bandsVal;
|
|
if (autoeqBandCount.value === bandsVal) return;
|
|
}
|
|
// Fall back to default
|
|
autoeqBandCount.value = '10';
|
|
}
|
|
};
|
|
const autoeqRunBtn = document.getElementById('autoeq-run-btn');
|
|
const autoeqDownloadBtn = document.getElementById('autoeq-download-btn');
|
|
const autoeqStatus = document.getElementById('autoeq-status');
|
|
const autoeqImportBtn = document.getElementById('autoeq-import-measurement-btn');
|
|
const autoeqImportFile = document.getElementById('autoeq-import-measurement-file');
|
|
const autoeqSavedGrid = document.getElementById('autoeq-saved-grid');
|
|
const autoeqSavedCount = document.getElementById('autoeq-saved-count');
|
|
const autoeqProfileNameInput = document.getElementById('autoeq-profile-name');
|
|
const autoeqSaveBtn = document.getElementById('autoeq-save-btn');
|
|
const autoeqSavedCollapse = document.getElementById('autoeq-saved-collapse');
|
|
const autoeqDatabaseList = document.getElementById('autoeq-database-list');
|
|
const autoeqDatabaseCount = document.getElementById('autoeq-database-count');
|
|
const autoeqFiltersToggle = document.getElementById('autoeq-filters-toggle');
|
|
const autoeqFiltersContent = document.getElementById('autoeq-filters-content');
|
|
const autoeqFiltersCollapse = document.getElementById('autoeq-filters-collapse');
|
|
const autoeqBandsList = document.getElementById('autoeq-bands-list');
|
|
const autoeqPreampValue = document.getElementById('autoeq-preamp-value');
|
|
|
|
// Populate headphone select with popular models
|
|
if (autoeqHeadphoneSelect) {
|
|
const optgroup = document.createElement('optgroup');
|
|
optgroup.label = 'Popular';
|
|
for (const hp of POPULAR_HEADPHONES) {
|
|
const opt = document.createElement('option');
|
|
opt.value = hp.name;
|
|
opt.textContent = hp.name.replace(/\s*\([^)]*\)\s*$/, ''); // strip source suffix for clean display
|
|
opt.dataset.type = hp.type;
|
|
optgroup.appendChild(opt);
|
|
}
|
|
// Insert after the placeholder option
|
|
autoeqHeadphoneSelect.appendChild(optgroup);
|
|
|
|
// When user picks a popular headphone from the dropdown, load it
|
|
autoeqHeadphoneSelect.addEventListener('change', () => {
|
|
const selected = autoeqHeadphoneSelect.value;
|
|
if (!selected) return;
|
|
const popularEntry = POPULAR_HEADPHONES.find((hp) => hp.name === selected);
|
|
if (popularEntry && (!autoeqSelectedEntry || autoeqSelectedEntry.name !== selected)) {
|
|
loadHeadphoneEntry(popularEntry);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Frequency Response Graph Renderer
|
|
// ========================================
|
|
const FREQ_MIN = 20;
|
|
const FREQ_MAX = 20000;
|
|
const GRAPH_FREQS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
|
const LOG_MIN = Math.log10(FREQ_MIN);
|
|
const LOG_MAX = Math.log10(FREQ_MAX);
|
|
const LOG_RANGE = LOG_MAX - LOG_MIN;
|
|
|
|
const freqToX = (freq, width) => ((Math.log10(Math.max(FREQ_MIN, freq)) - LOG_MIN) / LOG_RANGE) * width;
|
|
const xToFreq = (x, width) => Math.pow(10, (x / width) * LOG_RANGE + LOG_MIN);
|
|
const dbToY = (db, height, dbMin, dbMax) => height - ((db - dbMin) / (dbMax - dbMin)) * height;
|
|
const yToDb = (y, height, dbMin, dbMax) => dbMin + (1 - y / height) * (dbMax - dbMin);
|
|
|
|
const formatFreq = (freq) => {
|
|
if (freq >= 1000) return (freq / 1000).toFixed(freq % 1000 === 0 ? 0 : 1) + 'k';
|
|
return Math.round(freq).toString();
|
|
};
|
|
|
|
/**
|
|
* Draw the frequency response graph with Original, Target, and Corrected curves
|
|
*/
|
|
const drawAutoEQGraph = () => {
|
|
if (!autoeqCanvas) return;
|
|
const activeBands = getActiveBands();
|
|
const ctx = autoeqCanvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
autoeqCanvas.width = rect.width * dpr;
|
|
autoeqCanvas.height = rect.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const padLeft = 40,
|
|
padRight = 10,
|
|
padTop = 10,
|
|
padBottom = 30;
|
|
const w = rect.width - padLeft - padRight;
|
|
const h = rect.height - padTop - padBottom;
|
|
|
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
// dB scale: fixed 75dB center for AutoEQ, 0dB center for Parametric
|
|
const isParametricMode = currentMode === 'parametric';
|
|
const dbCenter = isParametricMode ? 0 : 75;
|
|
const dbHalfRange = isParametricMode ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
|
const dbMin = dbCenter - dbHalfRange;
|
|
const dbMax = dbCenter + dbHalfRange;
|
|
|
|
// Helper mappings (local to graph area)
|
|
const gx = (freq) => padLeft + freqToX(freq, w);
|
|
const gy = (db) => padTop + dbToY(db, h, dbMin, dbMax);
|
|
|
|
// Fixed curve colors (work across all themes)
|
|
const gridColor = 'rgba(255,255,255,0.06)';
|
|
const textColor = 'rgba(255,255,255,0.4)';
|
|
const originalColor = '#3b82f6'; // Blue
|
|
const targetColor = 'rgba(255,255,255,0.5)'; // White/gray dashed
|
|
const correctedColor = '#f472b6'; // Pink
|
|
|
|
// Draw grid
|
|
ctx.strokeStyle = gridColor;
|
|
ctx.lineWidth = 1;
|
|
// Horizontal grid lines (dB)
|
|
for (let db = dbMin; db <= dbMax; db += 5) {
|
|
const y = gy(db);
|
|
ctx.beginPath();
|
|
ctx.moveTo(padLeft, y);
|
|
ctx.lineTo(padLeft + w, y);
|
|
ctx.stroke();
|
|
}
|
|
// Vertical grid lines (freq)
|
|
for (const freq of GRAPH_FREQS) {
|
|
const x = gx(freq);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, padTop);
|
|
ctx.lineTo(x, padTop + h);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Y axis labels
|
|
ctx.fillStyle = textColor;
|
|
ctx.font = '10px system-ui, sans-serif';
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'middle';
|
|
for (let db = dbMin; db <= dbMax; db += 5) {
|
|
ctx.fillText(db.toString(), padLeft - 5, gy(db));
|
|
}
|
|
|
|
// X axis labels
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
for (const freq of GRAPH_FREQS) {
|
|
ctx.fillText(formatFreq(freq), gx(freq), padTop + h + 8);
|
|
}
|
|
|
|
// Draw curve helper
|
|
const drawCurve = (data, color, lineWidth, dashed = false) => {
|
|
if (!data || data.length < 2) return;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = lineWidth;
|
|
if (dashed) ctx.setLineDash([6, 4]);
|
|
let started = false;
|
|
for (const p of data) {
|
|
if (p.freq < FREQ_MIN || p.freq > FREQ_MAX) continue;
|
|
const x = gx(p.freq);
|
|
const y = gy(p.gain);
|
|
if (!started) {
|
|
ctx.moveTo(x, y);
|
|
started = true;
|
|
} else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
};
|
|
|
|
// Normalize all data to center around dbCenter
|
|
let targetId, targetEntry, targetData, graphMeasurement;
|
|
if (currentMode === 'speaker') {
|
|
const sCh = speakerChannels[speakerActiveChannel];
|
|
targetId = sCh?.targetId || 'harman_room';
|
|
targetEntry = SPEAKER_TARGETS.find((t) => t.id === targetId);
|
|
targetData = targetEntry?.data;
|
|
graphMeasurement = sCh?.measurement;
|
|
} else {
|
|
targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018';
|
|
targetEntry = TARGETS.find((t) => t.id === targetId);
|
|
targetData = targetEntry?.data;
|
|
graphMeasurement = autoeqSelectedMeasurement;
|
|
}
|
|
|
|
let graphShift = 0;
|
|
|
|
if (isParametricMode) {
|
|
// Parametric mode: flat 0dB reference line
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(padLeft, gy(0));
|
|
ctx.lineTo(padLeft + w, gy(0));
|
|
ctx.stroke();
|
|
|
|
if (activeBands && activeBands.length > 0) {
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
const nodeColors = [
|
|
'#f472b6',
|
|
'#fb923c',
|
|
'#facc15',
|
|
'#4ade80',
|
|
'#22d3ee',
|
|
'#818cf8',
|
|
'#c084fc',
|
|
'#f87171',
|
|
'#34d399',
|
|
'#60a5fa',
|
|
'#a78bfa',
|
|
'#fb7185',
|
|
'#fbbf24',
|
|
'#2dd4bf',
|
|
'#38bdf8',
|
|
'#a3e635',
|
|
];
|
|
|
|
// Draw individual band bell curves (filled)
|
|
activeBands.forEach((band, i) => {
|
|
if (!band.enabled || Math.abs(band.gain) < 0.1) return;
|
|
const color = nodeColors[i % nodeColors.length];
|
|
const r = parseInt(color.slice(1, 3), 16);
|
|
const g2 = parseInt(color.slice(3, 5), 16);
|
|
const b2 = parseInt(color.slice(5, 7), 16);
|
|
|
|
// Draw filled bell shape
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.moveTo(padLeft, gy(0));
|
|
for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) {
|
|
const resp = calculateBiquadResponse(f, band, sampleRate);
|
|
ctx.lineTo(gx(f), gy(resp));
|
|
}
|
|
ctx.lineTo(padLeft + w, gy(0));
|
|
ctx.closePath();
|
|
ctx.fillStyle = `rgba(${r},${g2},${b2},0.12)`;
|
|
ctx.fill();
|
|
|
|
// Draw bell curve outline
|
|
ctx.beginPath();
|
|
let started = false;
|
|
for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) {
|
|
const resp = calculateBiquadResponse(f, band, sampleRate);
|
|
const bx = gx(f);
|
|
const by = gy(resp);
|
|
if (!started) {
|
|
ctx.moveTo(bx, by);
|
|
started = true;
|
|
} else ctx.lineTo(bx, by);
|
|
}
|
|
ctx.strokeStyle = `rgba(${r},${g2},${b2},0.5)`;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
});
|
|
|
|
// Draw combined EQ response curve (sum of all bands)
|
|
const eqCurve = [];
|
|
for (let f = FREQ_MIN; f <= FREQ_MAX; f *= 1.02) {
|
|
let totalGain = 0;
|
|
for (const band of activeBands) {
|
|
if (band.enabled) totalGain += calculateBiquadResponse(f, band, sampleRate);
|
|
}
|
|
eqCurve.push({ freq: f, gain: totalGain });
|
|
}
|
|
drawCurve(eqCurve, 'rgba(255,255,255,0.8)', 2);
|
|
}
|
|
} else {
|
|
// AutoEQ / Speaker mode: draw measurement, target, corrected
|
|
if (targetData) {
|
|
const targetMidAvg = getNormalizationOffset(targetData);
|
|
graphShift = dbCenter - targetMidAvg;
|
|
} else if (graphMeasurement) {
|
|
const measMidAvg = getNormalizationOffset(graphMeasurement);
|
|
graphShift = dbCenter - measMidAvg;
|
|
}
|
|
|
|
// Draw Target curve (shifted)
|
|
if (targetData) {
|
|
const shiftedTarget = targetData.map((p) => ({ freq: p.freq, gain: p.gain + graphShift }));
|
|
drawCurve(shiftedTarget, targetColor, 1.5, true);
|
|
}
|
|
|
|
// Draw Original measurement (normalized + shifted)
|
|
if (graphMeasurement) {
|
|
const normOff = targetData ? getNormalizationOffset(graphMeasurement, targetData) : 0;
|
|
const normalized = graphMeasurement.map((p) => ({ freq: p.freq, gain: p.gain + normOff + graphShift }));
|
|
drawCurve(normalized, originalColor, 1.5);
|
|
}
|
|
|
|
// Draw Corrected curve (shifted)
|
|
if (autoeqCorrectedCurve) {
|
|
const shiftedCorrected = autoeqCorrectedCurve.map((p) => ({ freq: p.freq, gain: p.gain + graphShift }));
|
|
drawCurve(shiftedCorrected, correctedColor, 2);
|
|
}
|
|
}
|
|
|
|
// Speaker EQ: draw bass limit & room limit markers
|
|
if (currentMode === 'speaker') {
|
|
const bassHz = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40;
|
|
const roomHz = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500;
|
|
|
|
// Shaded regions outside EQ range
|
|
ctx.fillStyle = 'rgba(34, 211, 238, 0.04)';
|
|
ctx.fillRect(padLeft, padTop, gx(bassHz) - padLeft, h);
|
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.04)';
|
|
ctx.fillRect(gx(roomHz), padTop, padLeft + w - gx(roomHz), h);
|
|
|
|
// Bass limit line (cyan dashed)
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.strokeStyle = 'rgba(34, 211, 238, 0.6)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.moveTo(gx(bassHz), padTop);
|
|
ctx.lineTo(gx(bassHz), padTop + h);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// Bass limit label
|
|
ctx.save();
|
|
ctx.font = 'bold 9px system-ui';
|
|
ctx.fillStyle = 'rgba(34, 211, 238, 0.7)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(bassHz + ' Hz', gx(bassHz), padTop - 2);
|
|
ctx.restore();
|
|
|
|
// Room limit line (amber dashed)
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.strokeStyle = 'rgba(245, 158, 11, 0.6)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.moveTo(gx(roomHz), padTop);
|
|
ctx.lineTo(gx(roomHz), padTop + h);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// Room limit label
|
|
ctx.save();
|
|
ctx.font = 'bold 9px system-ui';
|
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.7)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(roomHz + ' Hz', gx(roomHz), padTop - 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Draw interactive nodes
|
|
if (activeBands && activeBands.length > 0 && (autoeqCorrectedCurve || isParametricMode)) {
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
activeBands.forEach((band, i) => {
|
|
if (!band.enabled) return;
|
|
const x = gx(band.freq);
|
|
// In parametric mode: node Y = band's individual response at its freq (basically its gain)
|
|
// In AutoEQ mode: node Y = corrected curve value at band freq (shifted)
|
|
let nodeGain;
|
|
if (isParametricMode) {
|
|
// Sum all bands' response at this frequency
|
|
let totalGain = 0;
|
|
for (const b of activeBands) {
|
|
if (b.enabled) totalGain += calculateBiquadResponse(band.freq, b, sampleRate);
|
|
}
|
|
nodeGain = totalGain;
|
|
} else {
|
|
nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift;
|
|
}
|
|
const y = gy(nodeGain);
|
|
|
|
// Draw node circle with unique color per band
|
|
const nodeColors = [
|
|
'#f472b6',
|
|
'#fb923c',
|
|
'#facc15',
|
|
'#4ade80',
|
|
'#22d3ee',
|
|
'#818cf8',
|
|
'#c084fc',
|
|
'#f87171',
|
|
'#34d399',
|
|
'#60a5fa',
|
|
'#a78bfa',
|
|
'#fb7185',
|
|
'#fbbf24',
|
|
'#2dd4bf',
|
|
'#38bdf8',
|
|
'#a3e635',
|
|
];
|
|
const nodeColor = nodeColors[i % nodeColors.length];
|
|
const isHovered = i === hoveredNode;
|
|
const isDragged = i === draggedNode;
|
|
const radius = isDragged ? 9 : isHovered ? 7 : 5;
|
|
|
|
// Glow effect on hover/drag
|
|
if (isHovered || isDragged) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius + 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = nodeColor.replace(')', ', 0.25)').replace('rgb', 'rgba').replace('#', '');
|
|
// Use hex to rgba
|
|
const r2 = parseInt(nodeColor.slice(1, 3), 16);
|
|
const g2 = parseInt(nodeColor.slice(3, 5), 16);
|
|
const b2 = parseInt(nodeColor.slice(5, 7), 16);
|
|
ctx.fillStyle = `rgba(${r2},${g2},${b2},0.25)`;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = isDragged ? '#fff' : nodeColor;
|
|
ctx.fill();
|
|
ctx.strokeStyle = isDragged ? nodeColor : 'rgba(0,0,0,0.5)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
|
|
// Show tooltip on drag
|
|
if (isDragged) {
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(0,0,0,0.8)';
|
|
const txt = `${Math.round(band.freq)} Hz ${band.gain > 0 ? '+' : ''}${band.gain.toFixed(1)} dB Q${band.q.toFixed(2)}`;
|
|
ctx.font = 'bold 11px system-ui, sans-serif';
|
|
const tw = ctx.measureText(txt).width + 12;
|
|
const tx = Math.min(x - tw / 2, rect.width - tw - 5);
|
|
const ty = y - 28;
|
|
ctx.fillRect(tx, ty, tw, 20);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(txt, tx + tw / 2, ty + 10);
|
|
ctx.restore();
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Compute corrected curve from measurement + bands
|
|
*/
|
|
const computeCorrectedCurve = () => {
|
|
let measurement, bands, tId, tList;
|
|
if (currentMode === 'speaker') {
|
|
const sCh = speakerChannels[speakerActiveChannel];
|
|
measurement = sCh?.measurement;
|
|
bands = sCh?.bands;
|
|
tId = sCh?.targetId || 'harman_room';
|
|
tList = SPEAKER_TARGETS;
|
|
} else {
|
|
measurement = autoeqSelectedMeasurement;
|
|
bands = autoeqCurrentBands;
|
|
tId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018';
|
|
tList = TARGETS;
|
|
}
|
|
|
|
if (!measurement || !bands) {
|
|
autoeqCorrectedCurve = null;
|
|
return;
|
|
}
|
|
const targetEntry = tList.find((t) => t.id === tId);
|
|
const targetData = targetEntry?.data;
|
|
const normOff = targetData ? getNormalizationOffset(measurement, targetData) : 0;
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
|
|
autoeqCorrectedCurve = measurement.map((p) => {
|
|
let correction = 0;
|
|
for (const band of bands) {
|
|
if (band.enabled) correction += calculateBiquadResponse(p.freq, band, sampleRate);
|
|
}
|
|
return { freq: p.freq, gain: p.gain + normOff + correction };
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get canvas coordinates from mouse event
|
|
*/
|
|
const getCanvasCoords = (e) => {
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
};
|
|
|
|
/**
|
|
* Find closest node to coordinates
|
|
*/
|
|
const findClosestNode = (mx, my, threshold = 15) => {
|
|
const activeBands = getActiveBands();
|
|
if (!activeBands || !autoeqCanvas) return -1;
|
|
const isParam = currentMode === 'parametric';
|
|
if (!isParam && !autoeqCorrectedCurve) return -1;
|
|
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
const padLeft = 40,
|
|
padRight = 10,
|
|
padTop = 10,
|
|
padBottom = 30;
|
|
const w = rect.width - padLeft - padRight;
|
|
const h = rect.height - padTop - padBottom;
|
|
|
|
const dbCenter = isParam ? 0 : 75;
|
|
const dbHalfRange = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
|
const dbMin = dbCenter - dbHalfRange;
|
|
const dbMax = dbCenter + dbHalfRange;
|
|
|
|
let graphShift = 0;
|
|
if (!isParam) {
|
|
let tId, tList, meas;
|
|
if (currentMode === 'speaker') {
|
|
const sCh = speakerChannels[speakerActiveChannel];
|
|
tId = sCh?.targetId || 'harman_room';
|
|
tList = SPEAKER_TARGETS;
|
|
meas = sCh?.measurement;
|
|
} else {
|
|
tId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018';
|
|
tList = TARGETS;
|
|
meas = autoeqSelectedMeasurement;
|
|
}
|
|
const targetEntry = tList.find((t) => t.id === tId);
|
|
const targetData = targetEntry?.data;
|
|
if (targetData) graphShift = 75 - getNormalizationOffset(targetData);
|
|
else if (meas) graphShift = 75 - getNormalizationOffset(meas);
|
|
}
|
|
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
let closest = -1,
|
|
closestDist = Infinity;
|
|
activeBands.forEach((band, i) => {
|
|
if (!band.enabled) return;
|
|
const x = padLeft + freqToX(band.freq, w);
|
|
let nodeGain;
|
|
if (isParam) {
|
|
nodeGain = 0;
|
|
for (const b of activeBands) {
|
|
if (b.enabled) nodeGain += calculateBiquadResponse(band.freq, b, sampleRate);
|
|
}
|
|
} else {
|
|
nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift;
|
|
}
|
|
const y = padTop + dbToY(nodeGain, h, dbMin, dbMax);
|
|
const dist = Math.sqrt((mx - x) ** 2 + (my - y) ** 2);
|
|
if (dist < threshold && dist < closestDist) {
|
|
closest = i;
|
|
closestDist = dist;
|
|
}
|
|
});
|
|
return closest;
|
|
};
|
|
|
|
/**
|
|
* Auto preamp compensation state
|
|
*/
|
|
let autoPreampEnabled = false;
|
|
const autoPreampToggle = document.getElementById('autoeq-auto-preamp-toggle');
|
|
|
|
/**
|
|
* Apply current bands to audio engine
|
|
*/
|
|
const applyBandsToAudio = (bands) => {
|
|
if (bands && bands.length > 0) {
|
|
// Pass skipPreamp=true when auto preamp is off so the engine doesn't override manual preamp
|
|
audioContextManager.applyAutoEQBands(bands, !autoPreampEnabled);
|
|
currentPreamp = equalizerSettings.getPreamp();
|
|
if (eqPreampSlider) eqPreampSlider.value = currentPreamp;
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = `${currentPreamp} dB`;
|
|
}
|
|
};
|
|
|
|
// ========================================
|
|
// Interactive Graph Mouse/Touch Handlers
|
|
// ========================================
|
|
if (autoeqCanvas) {
|
|
autoeqCanvas.addEventListener('mousedown', (e) => {
|
|
const coords = getCanvasCoords(e);
|
|
const nodeIdx = findClosestNode(coords.x, coords.y, 18);
|
|
if (nodeIdx >= 0) {
|
|
draggedNode = nodeIdx;
|
|
autoeqCanvas.style.cursor = 'grabbing';
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
autoeqCanvas.addEventListener('mousemove', (e) => {
|
|
const coords = getCanvasCoords(e);
|
|
const bands = getActiveBands();
|
|
if (draggedNode !== null && bands) {
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
const padLeft = 40,
|
|
padRight = 10,
|
|
padTop = 10,
|
|
padBottom = 30;
|
|
const w = rect.width - padLeft - padRight;
|
|
const h = rect.height - padTop - padBottom;
|
|
|
|
const isParam = currentMode === 'parametric';
|
|
const dbCenter = isParam ? 0 : 75;
|
|
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
|
const dbMin = dbCenter - dbHalf;
|
|
const dbMax = dbCenter + dbHalf;
|
|
|
|
const freq = xToFreq(coords.x - padLeft, w);
|
|
bands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
|
|
|
|
if (isParam) {
|
|
const newGain = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
|
bands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
|
} else {
|
|
const corrGain = interpolate(bands[draggedNode].freq, autoeqCorrectedCurve || []);
|
|
const newDb = yToDb(coords.y - padTop, h, dbMin, dbMax);
|
|
const gainDelta = newDb - corrGain;
|
|
bands[draggedNode].gain = Math.max(-30, Math.min(30, bands[draggedNode].gain + gainDelta * 0.3));
|
|
}
|
|
|
|
if (!graphAnimFrame) {
|
|
graphAnimFrame = requestAnimationFrame(() => {
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
drawAutoEQGraph();
|
|
renderBandControls(bands);
|
|
graphAnimFrame = null;
|
|
});
|
|
}
|
|
} else {
|
|
const padLeft = 40;
|
|
if (coords.x <= padLeft + 10) {
|
|
autoeqCanvas.style.cursor = 'ns-resize';
|
|
if (hoveredNode !== null) {
|
|
hoveredNode = null;
|
|
drawAutoEQGraph();
|
|
}
|
|
} else {
|
|
const newHovered = findClosestNode(coords.x, coords.y, 18);
|
|
if (newHovered !== hoveredNode) {
|
|
hoveredNode = newHovered;
|
|
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
|
drawAutoEQGraph();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
autoeqCanvas.addEventListener('mouseup', () => {
|
|
draggedNode = null;
|
|
autoeqCanvas.style.cursor = hoveredNode >= 0 ? 'grab' : 'crosshair';
|
|
});
|
|
|
|
autoeqCanvas.addEventListener('mouseleave', () => {
|
|
draggedNode = null;
|
|
hoveredNode = null;
|
|
autoeqCanvas.style.cursor = 'crosshair';
|
|
drawAutoEQGraph();
|
|
});
|
|
|
|
autoeqCanvas.addEventListener('dblclick', (e) => {
|
|
e.preventDefault();
|
|
const coords = getCanvasCoords(e);
|
|
const isParam = currentMode === 'parametric';
|
|
|
|
// getActiveBands() returns null in autoeq mode before first run — init to empty array
|
|
let bands = getActiveBands();
|
|
if (!bands) {
|
|
if (currentMode === 'autoeq') {
|
|
autoeqCurrentBands = [];
|
|
bands = autoeqCurrentBands;
|
|
} else return;
|
|
}
|
|
|
|
// findClosestNode needs autoeqCorrectedCurve in non-parametric modes.
|
|
// Fall back to frequency-only (X-axis) matching when corrected curve is absent.
|
|
let nodeIdx = findClosestNode(coords.x, coords.y, 18);
|
|
if (nodeIdx < 0 && !isParam && !autoeqCorrectedCurve && bands.length > 0) {
|
|
const rect2 = autoeqCanvas.getBoundingClientRect();
|
|
const w2 = rect2.width - 40 - 10;
|
|
let best = Infinity;
|
|
bands.forEach((band, i) => {
|
|
const dx = Math.abs(coords.x - (40 + freqToX(band.freq, w2)));
|
|
if (dx < 18 && dx < best) {
|
|
best = dx;
|
|
nodeIdx = i;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (nodeIdx >= 0) {
|
|
bands.splice(nodeIdx, 1);
|
|
bands.forEach((b, i) => {
|
|
b.id = i;
|
|
});
|
|
draggedNode = null;
|
|
hoveredNode = null;
|
|
} else {
|
|
if (bands.length >= 32) return;
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
const padLeft = 40,
|
|
padRight = 10,
|
|
padTop = 10,
|
|
padBottom = 30;
|
|
const w = rect.width - padLeft - padRight;
|
|
const h = rect.height - padTop - padBottom;
|
|
const dbCenter = isParam ? 0 : 75;
|
|
const dbHalf = isParam ? graphDbHalfParametric : graphDbHalfAutoEQ;
|
|
const dbMin = dbCenter - dbHalf;
|
|
const dbMax = dbCenter + dbHalf;
|
|
const freq = Math.max(20, Math.min(20000, Math.round(xToFreq(coords.x - padLeft, w))));
|
|
const gain = Math.max(
|
|
-30,
|
|
Math.min(30, Math.round((yToDb(coords.y - padTop, h, dbMin, dbMax) - dbCenter) * 10) / 10)
|
|
);
|
|
bands.push({ id: bands.length, type: 'peaking', freq, gain, q: 1.0, enabled: true });
|
|
}
|
|
|
|
setActiveBands(bands);
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
renderBandControls(bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
|
|
autoeqCanvas.addEventListener(
|
|
'wheel',
|
|
(e) => {
|
|
const coords = getCanvasCoords(e);
|
|
const padLeft = 40;
|
|
|
|
// Scroll on Y axis area (left edge) = dB zoom
|
|
if (coords.x <= padLeft + 10) {
|
|
e.preventDefault();
|
|
const zoomStep = e.deltaY > 0 ? 2 : -2; // scroll down = zoom out (wider range), scroll up = zoom in
|
|
if (currentMode === 'parametric') {
|
|
graphDbHalfParametric = Math.max(5, Math.min(60, graphDbHalfParametric + zoomStep));
|
|
} else {
|
|
graphDbHalfAutoEQ = Math.max(5, Math.min(60, graphDbHalfAutoEQ + zoomStep));
|
|
}
|
|
drawAutoEQGraph();
|
|
return;
|
|
}
|
|
|
|
// Scroll on a node = Q adjust
|
|
const wBands = getActiveBands();
|
|
if (hoveredNode >= 0 && wBands && wBands[hoveredNode]) {
|
|
e.preventDefault();
|
|
const band = wBands[hoveredNode];
|
|
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
|
band.q = Math.max(0.1, Math.min(10, (band.q || 1) + delta));
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(wBands);
|
|
drawAutoEQGraph();
|
|
renderBandControls(wBands);
|
|
}
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
// Touch support
|
|
let touchNodeIdx = -1;
|
|
autoeqCanvas.addEventListener(
|
|
'touchstart',
|
|
(e) => {
|
|
const touch = e.touches[0];
|
|
const coords = {
|
|
x: touch.clientX - autoeqCanvas.getBoundingClientRect().left,
|
|
y: touch.clientY - autoeqCanvas.getBoundingClientRect().top,
|
|
};
|
|
touchNodeIdx = findClosestNode(coords.x, coords.y, 25);
|
|
if (touchNodeIdx >= 0) {
|
|
draggedNode = touchNodeIdx;
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
autoeqCanvas.addEventListener(
|
|
'touchmove',
|
|
(e) => {
|
|
const tBands = getActiveBands();
|
|
if (draggedNode !== null && tBands) {
|
|
const touch = e.touches[0];
|
|
const rect = autoeqCanvas.getBoundingClientRect();
|
|
const coords = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
|
|
const padLeft = 40,
|
|
padRight = 10,
|
|
padTop = 10,
|
|
padBottom = 30;
|
|
const w = rect.width - padLeft - padRight;
|
|
const h = rect.height - padTop - padBottom;
|
|
|
|
const freq = xToFreq(coords.x - padLeft, w);
|
|
tBands[draggedNode].freq = Math.max(20, Math.min(20000, freq));
|
|
|
|
if (currentMode === 'parametric') {
|
|
const newGain = yToDb(coords.y - padTop, h, -graphDbHalfParametric, graphDbHalfParametric);
|
|
tBands[draggedNode].gain = Math.max(-30, Math.min(30, Math.round(newGain * 10) / 10));
|
|
}
|
|
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(tBands);
|
|
if (!graphAnimFrame) {
|
|
graphAnimFrame = requestAnimationFrame(() => {
|
|
drawAutoEQGraph();
|
|
renderBandControls(tBands);
|
|
graphAnimFrame = null;
|
|
});
|
|
}
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
autoeqCanvas.addEventListener('touchend', () => {
|
|
draggedNode = null;
|
|
touchNodeIdx = -1;
|
|
});
|
|
|
|
// Resize observer for graph
|
|
if (autoeqGraphWrapper) {
|
|
const ro = new ResizeObserver(() => {
|
|
drawAutoEQGraph();
|
|
});
|
|
ro.observe(autoeqGraphWrapper);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Per-Band Parametric EQ Controls
|
|
// ========================================
|
|
const renderBandControls = (bands) => {
|
|
if (!autoeqBandsList) return;
|
|
autoeqBandsList.innerHTML = '';
|
|
if (!bands || bands.length === 0) return;
|
|
|
|
bands.forEach((band, i) => {
|
|
const control = document.createElement('div');
|
|
control.className = 'autoeq-band-control';
|
|
control.dataset.band = i;
|
|
const currentType = band.type || 'peaking';
|
|
control.innerHTML = `
|
|
<div class="autoeq-band-header">
|
|
<span class="autoeq-band-number">${i + 1}</span>
|
|
<select class="autoeq-type-select">
|
|
<option value="peaking"${currentType === 'peaking' ? ' selected' : ''}>PK</option>
|
|
<option value="lowshelf"${currentType === 'lowshelf' ? ' selected' : ''}>LSF</option>
|
|
<option value="highshelf"${currentType === 'highshelf' ? ' selected' : ''}>HSF</option>
|
|
</select>
|
|
<div class="autoeq-band-param">
|
|
<span class="autoeq-band-param-label">Freq</span>
|
|
<span class="autoeq-band-value autoeq-freq-val">${formatFreq(band.freq)} Hz</span>
|
|
</div>
|
|
<div class="autoeq-band-param">
|
|
<span class="autoeq-band-param-label">Gain</span>
|
|
<span class="autoeq-band-value autoeq-gain-val">${band.gain > 0 ? '+' : ''}${band.gain.toFixed(1)} dB</span>
|
|
</div>
|
|
<div class="autoeq-band-param">
|
|
<span class="autoeq-band-param-label">Q</span>
|
|
<span class="autoeq-band-value autoeq-q-val">${band.q.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="autoeq-band-sliders">
|
|
<input type="range" class="autoeq-band-slider autoeq-freq-slider" min="20" max="20000" step="1" value="${Math.round(band.freq)}" />
|
|
<input type="range" class="autoeq-band-slider autoeq-gain-slider" min="-30" max="30" step="0.1" value="${band.gain.toFixed(1)}" />
|
|
<input type="range" class="autoeq-band-slider autoeq-q-slider" min="0.1" max="10" step="0.01" value="${band.q.toFixed(2)}" />
|
|
</div>
|
|
`;
|
|
autoeqBandsList.appendChild(control);
|
|
|
|
// Attach slider event listeners
|
|
const freqSlider = control.querySelector('.autoeq-freq-slider');
|
|
const gainSlider = control.querySelector('.autoeq-gain-slider');
|
|
const qSlider = control.querySelector('.autoeq-q-slider');
|
|
const freqVal = control.querySelector('.autoeq-freq-val');
|
|
const gainVal = control.querySelector('.autoeq-gain-val');
|
|
const qVal = control.querySelector('.autoeq-q-val');
|
|
|
|
freqSlider.addEventListener('input', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands || !bands[i]) return;
|
|
bands[i].freq = parseFloat(freqSlider.value);
|
|
freqVal.textContent = `${formatFreq(bands[i].freq)} Hz`;
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
|
|
gainSlider.addEventListener('input', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands || !bands[i]) return;
|
|
bands[i].gain = parseFloat(gainSlider.value);
|
|
gainVal.textContent = `${bands[i].gain > 0 ? '+' : ''}${bands[i].gain.toFixed(1)} dB`;
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
|
|
qSlider.addEventListener('input', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands || !bands[i]) return;
|
|
bands[i].q = parseFloat(qSlider.value);
|
|
qVal.textContent = bands[i].q.toFixed(2);
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
|
|
const typeSelect = control.querySelector('.autoeq-type-select');
|
|
typeSelect.addEventListener('change', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands || !bands[i]) return;
|
|
bands[i].type = typeSelect.value;
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
});
|
|
};
|
|
|
|
// ========================================
|
|
// EQ Toggle + Container Visibility
|
|
// ========================================
|
|
/**
|
|
* Ensure parametric bands exist - creates default 10 log-spaced bands if none
|
|
*/
|
|
const ensureParametricBands = () => {
|
|
if (!parametricBands || parametricBands.length === 0) {
|
|
const defaultBands = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const freq = 20 * Math.pow(20000 / 20, i / 9);
|
|
defaultBands.push({ id: i, type: 'peaking', freq: Math.round(freq), gain: 0, q: 1.0, enabled: true });
|
|
}
|
|
parametricBands = defaultBands;
|
|
applyBandsToAudio(parametricBands);
|
|
}
|
|
};
|
|
|
|
const updateEQContainerVisibility = (enabled) => {
|
|
if (eqContainer) {
|
|
eqContainer.style.display = enabled ? 'flex' : 'none';
|
|
if (enabled) {
|
|
// Ensure bands exist when EQ is enabled (fixes parametric mode without AutoEQ)
|
|
if (currentMode === 'parametric') {
|
|
ensureParametricBands();
|
|
applyBandsToAudio(parametricBands);
|
|
renderBandControls(parametricBands);
|
|
}
|
|
requestAnimationFrame(drawAutoEQGraph);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ========================================
|
|
// Collapsible Sections
|
|
// ========================================
|
|
// Saved Profiles collapse
|
|
if (autoeqSavedCollapse) {
|
|
const savedGrid = document.getElementById('autoeq-saved-grid');
|
|
autoeqSavedCollapse.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
autoeqSavedCollapse.classList.toggle('collapsed');
|
|
if (savedGrid)
|
|
savedGrid.style.display = autoeqSavedCollapse.classList.contains('collapsed') ? 'none' : 'flex';
|
|
});
|
|
}
|
|
|
|
// Parametric EQ Filters collapse
|
|
if (autoeqFiltersToggle) {
|
|
autoeqFiltersToggle.addEventListener('click', () => {
|
|
if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.toggle('collapsed');
|
|
if (autoeqFiltersContent)
|
|
autoeqFiltersContent.style.display = autoeqFiltersContent.style.display === 'none' ? 'flex' : 'none';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Set Status Message
|
|
// ========================================
|
|
const setAutoEQStatus = (msg, type = '') => {
|
|
if (!autoeqStatus) return;
|
|
autoeqStatus.textContent = msg;
|
|
autoeqStatus.className = 'autoeq-status' + (type ? ' ' + type : '');
|
|
};
|
|
|
|
// ========================================
|
|
// Downsample curve for profile storage
|
|
// ========================================
|
|
const downsampleCurve = (data, maxPoints = 80) => {
|
|
if (!data || data.length <= maxPoints) return data ? [...data] : [];
|
|
const result = [];
|
|
const step = data.length / maxPoints;
|
|
for (let i = 0; i < maxPoints; i++) {
|
|
result.push({ ...data[Math.floor(i * step)] });
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ========================================
|
|
// Mini-Graph Renderer for Profile Cards
|
|
// ========================================
|
|
const drawMiniGraph = (canvas, measurementData, targetData, correctedData) => {
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (rect.width === 0) {
|
|
// Canvas not laid out yet — retry when it becomes visible
|
|
const obs = new IntersectionObserver((entries, observer) => {
|
|
if (entries[0].isIntersecting) {
|
|
observer.disconnect();
|
|
drawMiniGraph(canvas, measurementData, targetData, correctedData);
|
|
}
|
|
});
|
|
obs.observe(canvas);
|
|
return;
|
|
}
|
|
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = (rect.height || 60) * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
const w = rect.width;
|
|
const h = rect.height || 60;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const drawMiniFill = (data, colors) => {
|
|
if (!data || data.length < 2) return;
|
|
const allGains = data.map((p) => p.gain);
|
|
const dMin = Math.min(...allGains) - 2;
|
|
const dMax = Math.max(...allGains) + 2;
|
|
const dRange = dMax - dMin || 1;
|
|
|
|
const gradient = ctx.createLinearGradient(0, 0, w, 0);
|
|
colors.forEach((c, i) => gradient.addColorStop(i / (colors.length - 1), c));
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, h);
|
|
for (let i = 0; i < data.length; i++) {
|
|
const x = freqToX(data[i].freq, w);
|
|
const y = h - ((data[i].gain - dMin) / dRange) * h * 0.8 - h * 0.1;
|
|
if (i === 0) ctx.lineTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.lineTo(w, h);
|
|
ctx.closePath();
|
|
ctx.fillStyle = gradient;
|
|
ctx.globalAlpha = 0.4;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Draw line
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = gradient;
|
|
ctx.lineWidth = 1.5;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const x = freqToX(data[i].freq, w);
|
|
const y = h - ((data[i].gain - dMin) / dRange) * h * 0.8 - h * 0.1;
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
};
|
|
|
|
if (measurementData) drawMiniFill(measurementData, ['#3b82f6', '#06b6d4', '#8b5cf6']);
|
|
if (targetData) drawMiniFill(targetData, ['#f472b6', '#a855f7', '#6366f1']);
|
|
if (correctedData) drawMiniFill(correctedData, ['#22c55e', '#06b6d4', '#3b82f6']);
|
|
};
|
|
|
|
const BAND_PREVIEW_COLORS = [
|
|
'#f472b6',
|
|
'#fb923c',
|
|
'#facc15',
|
|
'#4ade80',
|
|
'#22d3ee',
|
|
'#818cf8',
|
|
'#c084fc',
|
|
'#f87171',
|
|
'#34d399',
|
|
'#60a5fa',
|
|
];
|
|
|
|
const drawBandsPreview = (canvas, bands, sampleRate) => {
|
|
if (!canvas || !bands || bands.length === 0) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (rect.width === 0) {
|
|
const obs = new IntersectionObserver((entries, observer) => {
|
|
if (entries[0].isIntersecting) {
|
|
observer.disconnect();
|
|
drawBandsPreview(canvas, bands, sampleRate);
|
|
}
|
|
});
|
|
obs.observe(canvas);
|
|
return;
|
|
}
|
|
const sr = sampleRate || 48000;
|
|
const ph = rect.height || 100;
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = ph * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
const pw = rect.width;
|
|
ctx.clearRect(0, 0, pw, ph);
|
|
const mid = ph / 2;
|
|
const dbRange = 12; // -12dB to +12dB
|
|
|
|
// Draw each band as a filled blob
|
|
bands.forEach((band, bi) => {
|
|
if (!band.enabled || Math.abs(band.gain) < 0.1) return;
|
|
const color = BAND_PREVIEW_COLORS[bi % BAND_PREVIEW_COLORS.length];
|
|
const pts = [];
|
|
for (let f = 20; f <= 20000; f *= 1.04) {
|
|
const resp = calculateBiquadResponse(f, band, sr);
|
|
pts.push({
|
|
x: freqToX(f, pw),
|
|
y: mid - (Math.max(-dbRange, Math.min(dbRange, resp)) / dbRange) * mid * 0.9,
|
|
});
|
|
}
|
|
if (!pts.length) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, mid);
|
|
pts.forEach((p) => ctx.lineTo(p.x, p.y));
|
|
ctx.lineTo(pts[pts.length - 1].x, mid);
|
|
ctx.closePath();
|
|
const grad = ctx.createLinearGradient(0, 0, pw, 0);
|
|
grad.addColorStop(0, color + '18');
|
|
grad.addColorStop(0.5, color + '55');
|
|
grad.addColorStop(1, color + '18');
|
|
ctx.fillStyle = grad;
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
});
|
|
|
|
// Combined curve on top
|
|
ctx.beginPath();
|
|
let first = true;
|
|
for (let f = 20; f <= 20000; f *= 1.04) {
|
|
let total = 0;
|
|
for (const b of bands) {
|
|
if (b.enabled) total += calculateBiquadResponse(f, b, sr);
|
|
}
|
|
const x = freqToX(f, pw);
|
|
const y = mid - (Math.max(-dbRange, Math.min(dbRange, total)) / dbRange) * mid * 0.9;
|
|
first ? (ctx.moveTo(x, y), (first = false)) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
};
|
|
|
|
// ========================================
|
|
// Saved Profiles Rendering
|
|
// ========================================
|
|
const renderSavedProfiles = () => {
|
|
if (!autoeqSavedGrid) return;
|
|
const profiles = equalizerSettings.getAutoEQProfiles();
|
|
const activeId = equalizerSettings.getActiveAutoEQProfile();
|
|
const keys = Object.keys(profiles);
|
|
|
|
if (autoeqSavedCount) autoeqSavedCount.textContent = keys.length;
|
|
autoeqSavedGrid.innerHTML = '';
|
|
|
|
if (keys.length === 0) return;
|
|
|
|
keys.forEach((id) => {
|
|
const profile = profiles[id];
|
|
const card = document.createElement('div');
|
|
card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : '');
|
|
card.dataset.profileId = id;
|
|
|
|
const preview = document.createElement('canvas');
|
|
preview.className = 'autoeq-profile-preview';
|
|
card.appendChild(preview);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'autoeq-profile-info';
|
|
info.innerHTML = `
|
|
<span class="autoeq-profile-active-icon">✓</span>
|
|
<span class="autoeq-profile-name">${profile.name || 'Unnamed'}</span>
|
|
<span class="autoeq-profile-meta">${profile.bandCount || '?'} bands · ${profile.targetLabel || ''}</span>
|
|
`;
|
|
card.appendChild(info);
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'autoeq-profile-delete';
|
|
delBtn.innerHTML = '🗑';
|
|
delBtn.title = 'Delete profile';
|
|
delBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
equalizerSettings.deleteAutoEQProfile(id);
|
|
renderSavedProfiles();
|
|
});
|
|
card.appendChild(delBtn);
|
|
|
|
// Click to load profile
|
|
card.addEventListener('click', () => {
|
|
loadAutoEQProfile(id);
|
|
});
|
|
|
|
autoeqSavedGrid.appendChild(card);
|
|
|
|
// Draw mini preview using filter bands
|
|
requestAnimationFrame(() => {
|
|
drawBandsPreview(preview, profile.bands, profile.sampleRate);
|
|
});
|
|
});
|
|
};
|
|
|
|
// ========================================
|
|
// Profile Save/Load
|
|
// ========================================
|
|
const saveAutoEQProfile = (name) => {
|
|
if (!autoeqCurrentBands || !autoeqSelectedMeasurement) return;
|
|
|
|
const targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018';
|
|
const targetEntry = TARGETS.find((t) => t.id === targetId);
|
|
|
|
const profile = {
|
|
id: 'autoeq_' + Date.now(),
|
|
name: name || (autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom'),
|
|
headphoneName: autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom',
|
|
headphoneType: autoeqSelectedEntry ? autoeqSelectedEntry.type : 'over-ear',
|
|
targetId,
|
|
targetLabel: targetEntry ? targetEntry.label : targetId,
|
|
bandCount:
|
|
(autoeqBandCount && autoeqBandCount.value ? parseInt(autoeqBandCount.value, 10) : null) ||
|
|
autoeqCurrentBands.length ||
|
|
10,
|
|
maxFreq: autoeqMaxFreq ? parseInt(autoeqMaxFreq.value, 10) : 16000,
|
|
sampleRate: autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000,
|
|
bands: autoeqCurrentBands.map((b) => ({ ...b })),
|
|
gains: audioContextManager.getGains ? audioContextManager.getGains() : [],
|
|
preamp: equalizerSettings.getPreamp(),
|
|
measurementData: downsampleCurve(autoeqSelectedMeasurement),
|
|
targetData: downsampleCurve(targetEntry?.data),
|
|
correctedData: downsampleCurve(autoeqCorrectedCurve),
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
const id = equalizerSettings.saveAutoEQProfile(profile);
|
|
equalizerSettings.setActiveAutoEQProfile(id);
|
|
renderSavedProfiles();
|
|
setAutoEQStatus(`Profile "${name}" saved`, 'success');
|
|
};
|
|
|
|
const loadAutoEQProfile = (profileId) => {
|
|
const profiles = equalizerSettings.getAutoEQProfiles();
|
|
const profile = profiles[profileId];
|
|
if (!profile) return;
|
|
|
|
autoeqCurrentBands = profile.bands.map((b) => ({ ...b }));
|
|
autoeqCorrectedCurve = profile.correctedData ? [...profile.correctedData] : null;
|
|
autoeqSelectedMeasurement = profile.measurementData ? [...profile.measurementData] : null;
|
|
autoeqSelectedEntry = { name: profile.headphoneName, type: profile.headphoneType };
|
|
|
|
// Update headphone select dropdown
|
|
if (autoeqHeadphoneSelect) {
|
|
let opt = autoeqHeadphoneSelect.querySelector(`option[value="${profile.headphoneName}"]`);
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = profile.headphoneName;
|
|
opt.textContent = profile.headphoneName.replace(/\s*\([^)]*\)\s*$/, '');
|
|
autoeqHeadphoneSelect.appendChild(opt);
|
|
}
|
|
autoeqHeadphoneSelect.value = profile.headphoneName;
|
|
}
|
|
|
|
// Update UI selects
|
|
if (autoeqTargetSelect) autoeqTargetSelect.value = profile.targetId || 'harman_oe_2018';
|
|
setAutoeqBandCount(profile.bandCount, profile.bands);
|
|
if (autoeqMaxFreq) autoeqMaxFreq.value = profile.maxFreq || 16000;
|
|
if (autoeqSampleRate) autoeqSampleRate.value = profile.sampleRate || 48000;
|
|
|
|
// Apply to audio
|
|
applyBandsToAudio(autoeqCurrentBands);
|
|
|
|
equalizerSettings.setActiveAutoEQProfile(profileId);
|
|
renderSavedProfiles();
|
|
renderBandControls(autoeqCurrentBands);
|
|
drawAutoEQGraph();
|
|
setAutoEQStatus(`Loaded "${profile.name}"`, 'success');
|
|
};
|
|
|
|
// Save button
|
|
if (autoeqSaveBtn) {
|
|
autoeqSaveBtn.addEventListener('click', () => {
|
|
const name = autoeqProfileNameInput ? autoeqProfileNameInput.value.trim() : '';
|
|
if (!name) {
|
|
setAutoEQStatus('Enter a profile name', 'error');
|
|
return;
|
|
}
|
|
saveAutoEQProfile(name);
|
|
if (autoeqProfileNameInput) autoeqProfileNameInput.value = '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
|
|
// ========================================
|
|
// Database Browser
|
|
// ========================================
|
|
/**
|
|
* Load a headphone measurement entry
|
|
*/
|
|
const loadHeadphoneEntry = async (entry) => {
|
|
setAutoEQStatus('Loading measurement...', '');
|
|
try {
|
|
const data = await fetchHeadphoneData(entry);
|
|
autoeqSelectedMeasurement = data;
|
|
autoeqSelectedEntry = entry;
|
|
|
|
if (autoeqHeadphoneSelect) {
|
|
let opt = autoeqHeadphoneSelect.querySelector(`option[value="${entry.name}"]`);
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = entry.name;
|
|
opt.textContent = entry.name;
|
|
autoeqHeadphoneSelect.appendChild(opt);
|
|
}
|
|
autoeqHeadphoneSelect.value = entry.name;
|
|
}
|
|
|
|
if (autoeqTargetSelect && entry.type === 'in-ear') {
|
|
autoeqTargetSelect.value = 'harman_ie_2019';
|
|
}
|
|
|
|
if (autoeqRunBtn) autoeqRunBtn.disabled = false;
|
|
drawAutoEQGraph();
|
|
setAutoEQStatus(`Loaded ${data.length} points for ${entry.name}`, 'success');
|
|
|
|
// Persist for reload
|
|
equalizerSettings.setLastHeadphone(entry, data);
|
|
} catch (err) {
|
|
setAutoEQStatus('Failed: ' + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Render database list with expandable headphone groups
|
|
*/
|
|
const renderDatabaseResults = (entries, append = false) => {
|
|
if (!autoeqDatabaseList) return;
|
|
if (!append) autoeqDatabaseList.innerHTML = '';
|
|
|
|
if (entries.length === 0 && !append) {
|
|
autoeqDatabaseList.innerHTML =
|
|
'<div style="padding: 1rem; text-align: center; color: var(--muted-foreground); font-size: 0.8rem;">No results found</div>';
|
|
return;
|
|
}
|
|
|
|
// Group by base model name (strip source suffix like "(crinacle)")
|
|
const modelMap = new Map();
|
|
entries.forEach((entry) => {
|
|
const baseName = entry.name.replace(/\s*\([^)]*\)\s*$/, '').trim() || entry.name;
|
|
if (!modelMap.has(baseName)) {
|
|
modelMap.set(baseName, []);
|
|
}
|
|
modelMap.get(baseName).push(entry);
|
|
});
|
|
|
|
modelMap.forEach((variants, name) => {
|
|
const wrapper = document.createElement('div');
|
|
const rawFirstChar = name[0]?.toUpperCase() || '#';
|
|
const firstLetter = /^[A-Z]$/.test(rawFirstChar) ? rawFirstChar : '#';
|
|
wrapper.dataset.letter = firstLetter;
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'autoeq-db-item';
|
|
item.dataset.name = name;
|
|
|
|
item.innerHTML = `
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>
|
|
<div class="autoeq-db-item-info">
|
|
<span class="autoeq-db-item-name">${name}</span>
|
|
<span class="autoeq-db-item-meta">${variants.length} profile${variants.length > 1 ? 's' : ''}</span>
|
|
</div>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="autoeq-db-item-chevron"><path d="m9 18 6-6-6-6"/></svg>
|
|
`;
|
|
|
|
wrapper.appendChild(item);
|
|
|
|
// Sub-list for multiple profiles
|
|
if (variants.length > 1) {
|
|
const subList = document.createElement('div');
|
|
subList.className = 'autoeq-db-sub-list';
|
|
|
|
variants.forEach((entry) => {
|
|
const subItem = document.createElement('div');
|
|
subItem.className = 'autoeq-db-sub-item';
|
|
// Extract source from parentheses
|
|
const sourceMatch = entry.name.match(/\(([^)]+)\)\s*$/);
|
|
const source = sourceMatch ? sourceMatch[1] : entry.type;
|
|
subItem.innerHTML = `<span>${entry.name}</span><span class="sub-source">${source}</span>`;
|
|
subItem.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
loadHeadphoneEntry(entry);
|
|
});
|
|
subList.appendChild(subItem);
|
|
});
|
|
|
|
wrapper.appendChild(subList);
|
|
|
|
item.addEventListener('click', () => {
|
|
item.classList.toggle('expanded');
|
|
subList.classList.toggle('visible');
|
|
});
|
|
} else {
|
|
// Single profile - load directly
|
|
item.addEventListener('click', () => loadHeadphoneEntry(variants[0]));
|
|
}
|
|
|
|
autoeqDatabaseList.appendChild(wrapper);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Render the A-Z alphabet index
|
|
*/
|
|
const renderAlphaIndex = () => {
|
|
const alphaContainer = document.getElementById('autoeq-alpha-index');
|
|
if (!alphaContainer) return;
|
|
alphaContainer.innerHTML = '';
|
|
|
|
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('');
|
|
letters.forEach((letter) => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = letter;
|
|
btn.addEventListener('click', () => {
|
|
// Find the index of the first entry starting with this letter
|
|
const targetIdx = _dbFilteredEntries.findIndex((e) => {
|
|
const first = e.name[0].toUpperCase();
|
|
return letter === '#' ? !/[A-Z]/.test(first) : first === letter;
|
|
});
|
|
|
|
if (targetIdx < 0) return; // No entries for this letter
|
|
|
|
// Render all entries up to and past the target so the DOM element exists
|
|
while (_dbRenderedCount <= targetIdx + DB_BATCH_SIZE && _dbRenderedCount < _dbFilteredEntries.length) {
|
|
renderNextDatabaseBatch();
|
|
}
|
|
|
|
// Now find and scroll to the element
|
|
requestAnimationFrame(() => {
|
|
const target = autoeqDatabaseList?.querySelector(`[data-letter="${letter}"]`);
|
|
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
});
|
|
});
|
|
alphaContainer.appendChild(btn);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Load and display the full headphone database
|
|
*/
|
|
// Lazy-loading state for database list
|
|
let _dbFilteredEntries = [];
|
|
let _dbRenderedCount = 0;
|
|
const DB_BATCH_SIZE = 80;
|
|
|
|
const renderNextDatabaseBatch = () => {
|
|
if (_dbRenderedCount >= _dbFilteredEntries.length) return;
|
|
const end = Math.min(_dbRenderedCount + DB_BATCH_SIZE, _dbFilteredEntries.length);
|
|
const batch = _dbFilteredEntries.slice(_dbRenderedCount, end);
|
|
renderDatabaseResults(batch, true); // append mode
|
|
_dbRenderedCount = end;
|
|
};
|
|
|
|
const resetDatabaseList = (entries) => {
|
|
_dbFilteredEntries = entries;
|
|
_dbRenderedCount = 0;
|
|
if (autoeqDatabaseList) autoeqDatabaseList.innerHTML = '';
|
|
renderNextDatabaseBatch();
|
|
};
|
|
|
|
// Infinite scroll on database list
|
|
if (autoeqDatabaseList) {
|
|
autoeqDatabaseList.addEventListener('scroll', () => {
|
|
const el = autoeqDatabaseList;
|
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 60) {
|
|
renderNextDatabaseBatch();
|
|
}
|
|
});
|
|
}
|
|
|
|
const loadFullDatabase = async () => {
|
|
if (_autoeqIndex.length === 0) {
|
|
setAutoEQStatus('Loading headphone database...', '');
|
|
try {
|
|
_autoeqIndex = await fetchAutoEqIndex();
|
|
setAutoEQStatus(`Loaded ${_autoeqIndex.length} headphones`, 'success');
|
|
} catch {
|
|
setAutoEQStatus('Failed to load database', 'error');
|
|
return;
|
|
}
|
|
}
|
|
if (autoeqDatabaseCount) autoeqDatabaseCount.textContent = `${_autoeqIndex.length} models`;
|
|
resetDatabaseList(_autoeqIndex);
|
|
renderAlphaIndex();
|
|
};
|
|
|
|
// Search input with debounce
|
|
{
|
|
const searchEl = document.getElementById('autoeq-headphone-search');
|
|
|
|
if (searchEl && !searchEl._autoeqBound) {
|
|
searchEl._autoeqBound = true;
|
|
let timer = null;
|
|
|
|
const doSearch = async () => {
|
|
const query = searchEl.value.trim();
|
|
if (!query) {
|
|
resetDatabaseList(_autoeqIndex);
|
|
return;
|
|
}
|
|
|
|
if (_autoeqIndex.length === 0) await loadFullDatabase();
|
|
|
|
const results = searchHeadphones(query, _autoeqIndex, 'all', 500);
|
|
resetDatabaseList(results);
|
|
};
|
|
|
|
searchEl.addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(doSearch, 300);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// AutoEQ Run
|
|
// ========================================
|
|
if (autoeqRunBtn) {
|
|
autoeqRunBtn.addEventListener('click', () => {
|
|
if (!autoeqSelectedMeasurement) return;
|
|
|
|
setAutoEQStatus('Running AutoEQ...', '');
|
|
autoeqRunBtn.disabled = true;
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
const targetId = autoeqTargetSelect ? autoeqTargetSelect.value : 'harman_oe_2018';
|
|
const targetEntry = TARGETS.find((t) => t.id === targetId);
|
|
if (!targetEntry || !targetEntry.data || targetEntry.data.length === 0) {
|
|
setAutoEQStatus('Invalid target curve', 'error');
|
|
autoeqRunBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
const bandCount = autoeqBandCount ? parseInt(autoeqBandCount.value, 10) : 10;
|
|
const maxFreq = autoeqMaxFreq ? parseInt(autoeqMaxFreq.value, 10) : 16000;
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
|
|
const bands = runAutoEqAlgorithm(
|
|
autoeqSelectedMeasurement,
|
|
targetEntry.data,
|
|
bandCount,
|
|
maxFreq,
|
|
20,
|
|
5.0,
|
|
sampleRate
|
|
);
|
|
|
|
if (!bands || bands.length === 0) {
|
|
setAutoEQStatus('No correction needed', 'success');
|
|
autoeqRunBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
autoeqCurrentBands = bands;
|
|
computeCorrectedCurve();
|
|
applyBandsToAudio(autoeqCurrentBands);
|
|
drawAutoEQGraph();
|
|
renderBandControls(autoeqCurrentBands);
|
|
|
|
const headphoneName = autoeqSelectedEntry ? autoeqSelectedEntry.name : 'Custom';
|
|
setAutoEQStatus(`Applied ${bands.length} bands for ${headphoneName}`, 'success');
|
|
autoeqRunBtn.disabled = false;
|
|
} catch (err) {
|
|
console.error('[AutoEQ] Algorithm failed:', err);
|
|
setAutoEQStatus('Error: ' + err.message, 'error');
|
|
autoeqRunBtn.disabled = false;
|
|
}
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Import Measurement File
|
|
// ========================================
|
|
if (autoeqImportBtn && autoeqImportFile) {
|
|
autoeqImportBtn.addEventListener('click', () => {
|
|
autoeqImportFile.click();
|
|
});
|
|
|
|
autoeqImportFile.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const data = parseRawData(event.target.result);
|
|
if (data.length === 0) {
|
|
setAutoEQStatus('Invalid measurement file', 'error');
|
|
return;
|
|
}
|
|
autoeqSelectedMeasurement = data;
|
|
autoeqSelectedEntry = { name: file.name.replace(/\.(txt|csv)$/i, ''), type: 'over-ear' };
|
|
if (autoeqRunBtn) autoeqRunBtn.disabled = false;
|
|
drawAutoEQGraph();
|
|
setAutoEQStatus(`Imported ${data.length} points from ${file.name}`, 'success');
|
|
} catch {
|
|
setAutoEQStatus('Failed to parse file', 'error');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
e.target.value = '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Import Target Button
|
|
// ========================================
|
|
const autoeqImportTargetBtn = document.getElementById('autoeq-import-target-btn');
|
|
const autoeqImportTargetFile = document.getElementById('autoeq-import-target-file');
|
|
|
|
if (autoeqImportTargetBtn && autoeqImportTargetFile) {
|
|
autoeqImportTargetBtn.addEventListener('click', () => autoeqImportTargetFile.click());
|
|
|
|
autoeqImportTargetFile.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const data = parseRawData(event.target.result);
|
|
if (data.length === 0) {
|
|
setAutoEQStatus('Invalid target file', 'error');
|
|
return;
|
|
}
|
|
|
|
const customId = 'custom_target';
|
|
const customLabel = file.name.replace(/\.(txt|csv)$/i, '');
|
|
|
|
// Inject or update in TARGETS array
|
|
const existing = TARGETS.findIndex((t) => t.id === customId);
|
|
if (existing > -1) {
|
|
TARGETS[existing] = { id: customId, label: customLabel, data };
|
|
} else {
|
|
TARGETS.push({ id: customId, label: customLabel, data });
|
|
}
|
|
|
|
// Add/update option in select
|
|
if (autoeqTargetSelect) {
|
|
let opt = autoeqTargetSelect.querySelector('option[value="custom_target"]');
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = customId;
|
|
autoeqTargetSelect.appendChild(opt);
|
|
}
|
|
opt.textContent = customLabel;
|
|
autoeqTargetSelect.value = customId;
|
|
}
|
|
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
setAutoEQStatus(`Target "${customLabel}" imported`, 'success');
|
|
} catch {
|
|
setAutoEQStatus('Failed to parse target file', 'error');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
e.target.value = '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Download/Export Button
|
|
// ========================================
|
|
if (autoeqDownloadBtn) {
|
|
autoeqDownloadBtn.addEventListener('click', () => {
|
|
if (!autoeqCurrentBands || autoeqCurrentBands.length === 0) {
|
|
setAutoEQStatus('No EQ to export', 'error');
|
|
return;
|
|
}
|
|
// Build EqualizerAPO / Peace format
|
|
let lines = [`Preamp: ${currentPreamp} dB`];
|
|
autoeqCurrentBands.forEach((band, i) => {
|
|
if (!band.enabled) return;
|
|
const type = band.type === 'peaking' ? 'PK' : band.type === 'lowshelf' ? 'LSC' : 'HSC';
|
|
lines.push(
|
|
`Filter ${i + 1}: ON ${type} Fc ${Math.round(band.freq)} Hz Gain ${band.gain.toFixed(1)} dB Q ${band.q.toFixed(2)}`
|
|
);
|
|
});
|
|
const exportText = lines.join('\n');
|
|
const blob = new Blob([exportText], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `autoeq-${autoeqSelectedEntry?.name || 'custom'}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
setAutoEQStatus('Exported', 'success');
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Auto Preamp Compensation Toggle
|
|
// ========================================
|
|
if (autoPreampToggle) {
|
|
autoPreampToggle.addEventListener('change', () => {
|
|
autoPreampEnabled = autoPreampToggle.checked;
|
|
if (autoPreampEnabled) {
|
|
// Recalculate and apply auto preamp immediately
|
|
const bands = getActiveBands();
|
|
if (bands && bands.length > 0) {
|
|
const maxGain = Math.max(0, ...bands.filter((b) => b.enabled).map((b) => b.gain));
|
|
const autoPreamp = maxGain > 0 ? -Math.round(maxGain * 10) / 10 : 0;
|
|
currentPreamp = autoPreamp;
|
|
equalizerSettings.setPreamp(autoPreamp);
|
|
if (audioContextManager.setPreamp) audioContextManager.setPreamp(autoPreamp);
|
|
if (eqPreampSlider) eqPreampSlider.value = autoPreamp;
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = `${autoPreamp} dB`;
|
|
}
|
|
} else {
|
|
// Reset preamp to 0 dB
|
|
currentPreamp = 0;
|
|
equalizerSettings.setPreamp(0);
|
|
if (audioContextManager.setPreamp) audioContextManager.setPreamp(0);
|
|
if (eqPreampSlider) eqPreampSlider.value = 0;
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = '0 dB';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Preamp Slider
|
|
// ========================================
|
|
if (eqPreampSlider) {
|
|
eqPreampSlider.value = currentPreamp;
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = `${currentPreamp} dB`;
|
|
|
|
eqPreampSlider.addEventListener('input', () => {
|
|
// Manual preamp adjustment disables auto compensation
|
|
if (autoPreampEnabled) {
|
|
autoPreampEnabled = false;
|
|
if (autoPreampToggle) autoPreampToggle.checked = false;
|
|
}
|
|
const val = parseFloat(eqPreampSlider.value);
|
|
currentPreamp = val;
|
|
equalizerSettings.setPreamp(val);
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = `${val} dB`;
|
|
if (audioContextManager.setPreamp) audioContextManager.setPreamp(val);
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Speaker EQ State
|
|
// ========================================
|
|
const SPEAKER_CONFIGS = {
|
|
'2.0': ['FL', 'FR'],
|
|
5.1: ['FL', 'FR', 'C', 'LFE', 'SL', 'SR'],
|
|
7.1: ['FL', 'FR', 'C', 'LFE', 'SL', 'SR', 'SBL', 'SBR'],
|
|
};
|
|
const SPEAKER_CHANNEL_LABELS = {
|
|
FL: 'Front L',
|
|
FR: 'Front R',
|
|
C: 'Center',
|
|
LFE: 'Sub',
|
|
SL: 'Surr L',
|
|
SR: 'Surr R',
|
|
SBL: 'Back L',
|
|
SBR: 'Back R',
|
|
};
|
|
let speakerConfig = '2.0';
|
|
let speakerActiveChannel = 'FL';
|
|
const speakerChannels = {};
|
|
// Initialize all channels
|
|
Object.keys(SPEAKER_CHANNEL_LABELS).forEach((id) => {
|
|
speakerChannels[id] = {
|
|
measurement: null,
|
|
targetId: 'harman_room',
|
|
bands: Array.from({ length: 10 }, (_, i) => ({
|
|
id: i,
|
|
type: 'peaking',
|
|
freq: Math.round(100 * Math.pow(2, i)),
|
|
gain: 0,
|
|
q: 1.41,
|
|
enabled: true,
|
|
})),
|
|
preamp: 0,
|
|
};
|
|
});
|
|
|
|
// ========================================
|
|
// Mode Toggle: AutoEQ vs Parametric EQ vs Speaker EQ
|
|
// ========================================
|
|
const modeButtons = document.querySelectorAll('.autoeq-mode-btn');
|
|
const EQ_MODE_KEY = 'eq-active-mode';
|
|
let currentMode = 'autoeq';
|
|
|
|
const speakerSection = document.getElementById('speaker-eq-section');
|
|
|
|
const setEQMode = (mode) => {
|
|
currentMode = mode;
|
|
localStorage.setItem(EQ_MODE_KEY, mode);
|
|
modeButtons.forEach((b) => b.classList.toggle('active', b.dataset.mode === mode));
|
|
|
|
const graphSection = document.querySelector('.autoeq-graph-section');
|
|
const controlsSection = document.querySelector('.autoeq-controls-section');
|
|
const savedSection = document.getElementById('autoeq-saved-section');
|
|
const databaseSection = document.getElementById('autoeq-database-section');
|
|
const filtersSection = document.getElementById('autoeq-filters-section');
|
|
const filtersContent = document.getElementById('autoeq-filters-content');
|
|
const presetRow = document.getElementById('autoeq-preset-row');
|
|
const parametricProfiles = document.getElementById('autoeq-parametric-profiles');
|
|
const speakerSavedSection = document.getElementById('speaker-saved-section');
|
|
|
|
// Reset interactive state on switch
|
|
draggedNode = null;
|
|
hoveredNode = null;
|
|
|
|
// Graph always visible in all modes
|
|
if (graphSection) graphSection.style.display = '';
|
|
// Only show shared AutoEq button in AutoEQ mode
|
|
if (autoeqRunBtn) autoeqRunBtn.style.display = mode === 'autoeq' ? '' : 'none';
|
|
|
|
// Hide all mode-specific sections first
|
|
if (controlsSection) controlsSection.style.display = 'none';
|
|
if (savedSection) savedSection.style.display = 'none';
|
|
if (databaseSection) databaseSection.style.display = 'none';
|
|
if (filtersSection) filtersSection.style.display = 'none';
|
|
if (presetRow) presetRow.style.display = 'none';
|
|
if (parametricProfiles) parametricProfiles.style.display = 'none';
|
|
if (speakerSection) speakerSection.style.display = 'none';
|
|
if (speakerSavedSection) speakerSavedSection.style.display = 'none';
|
|
|
|
if (mode === 'autoeq') {
|
|
if (controlsSection) controlsSection.style.display = '';
|
|
if (savedSection) savedSection.style.display = '';
|
|
if (databaseSection) databaseSection.style.display = '';
|
|
if (filtersSection) filtersSection.style.display = '';
|
|
|
|
if (autoeqCurrentBands && autoeqCurrentBands.length > 0) {
|
|
applyBandsToAudio(autoeqCurrentBands);
|
|
renderBandControls(autoeqCurrentBands);
|
|
}
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
} else if (mode === 'parametric') {
|
|
if (filtersSection) filtersSection.style.display = '';
|
|
if (filtersContent) filtersContent.style.display = 'flex';
|
|
if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.remove('collapsed');
|
|
if (presetRow) presetRow.style.display = '';
|
|
if (parametricProfiles) parametricProfiles.style.display = '';
|
|
|
|
if (!parametricBands || parametricBands.length === 0) {
|
|
const defaultBands = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const freq = 20 * Math.pow(20000 / 20, i / 9);
|
|
defaultBands.push({
|
|
id: i,
|
|
type: 'peaking',
|
|
freq: Math.round(freq),
|
|
gain: 0,
|
|
q: 1.0,
|
|
enabled: true,
|
|
});
|
|
}
|
|
parametricBands = defaultBands;
|
|
}
|
|
applyBandsToAudio(parametricBands);
|
|
renderBandControls(parametricBands);
|
|
renderParametricProfiles();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
} else if (mode === 'speaker') {
|
|
if (speakerSection) speakerSection.style.display = '';
|
|
if (speakerSavedSection) speakerSavedSection.style.display = '';
|
|
if (filtersSection) filtersSection.style.display = '';
|
|
if (filtersContent) filtersContent.style.display = 'flex';
|
|
if (autoeqFiltersCollapse) autoeqFiltersCollapse.classList.remove('collapsed');
|
|
|
|
// Apply active speaker channel bands
|
|
const ch = speakerChannels[speakerActiveChannel];
|
|
if (ch && ch.bands.length > 0) {
|
|
applyBandsToAudio(ch.bands);
|
|
renderBandControls(ch.bands);
|
|
}
|
|
renderSpeakerChannelTabs();
|
|
renderSpeakerProfiles();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
}
|
|
|
|
// Update tutorial tab if visible
|
|
const hp = document.getElementById('eq-howto-panel');
|
|
if (hp && hp.style.display !== 'none') {
|
|
const tabs = {
|
|
autoeq: document.getElementById('eq-howto-autoeq'),
|
|
parametric: document.getElementById('eq-howto-parametric'),
|
|
speaker: document.getElementById('eq-howto-speaker'),
|
|
};
|
|
Object.values(tabs).forEach((t) => {
|
|
if (t) t.style.display = 'none';
|
|
});
|
|
if (tabs[mode]) tabs[mode].style.display = '';
|
|
}
|
|
};
|
|
|
|
modeButtons.forEach((btn) => {
|
|
btn.addEventListener('click', () => setEQMode(btn.dataset.mode));
|
|
});
|
|
|
|
// ========================================
|
|
// How-To Tutorial Panel
|
|
// ========================================
|
|
const howtoBtn = document.getElementById('eq-howto-btn');
|
|
const howtoPanel = document.getElementById('eq-howto-panel');
|
|
const howtoClose = document.getElementById('eq-howto-close');
|
|
const howtoTabs = {
|
|
autoeq: document.getElementById('eq-howto-autoeq'),
|
|
parametric: document.getElementById('eq-howto-parametric'),
|
|
speaker: document.getElementById('eq-howto-speaker'),
|
|
};
|
|
|
|
const updateHowtoTab = () => {
|
|
Object.values(howtoTabs).forEach((t) => {
|
|
if (t) t.style.display = 'none';
|
|
});
|
|
const active = howtoTabs[currentMode];
|
|
if (active) active.style.display = '';
|
|
};
|
|
|
|
if (howtoBtn && howtoPanel) {
|
|
howtoBtn.addEventListener('click', () => {
|
|
const visible = howtoPanel.style.display !== 'none';
|
|
howtoPanel.style.display = visible ? 'none' : '';
|
|
if (!visible) updateHowtoTab();
|
|
});
|
|
}
|
|
if (howtoClose && howtoPanel) {
|
|
howtoClose.addEventListener('click', () => {
|
|
howtoPanel.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Redraw graph when target/settings change
|
|
// ========================================
|
|
if (autoeqTargetSelect) {
|
|
autoeqTargetSelect.addEventListener('change', () => {
|
|
if (autoeqCurrentBands && autoeqSelectedMeasurement) {
|
|
computeCorrectedCurve();
|
|
}
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
if (autoeqBandCount) {
|
|
autoeqBandCount.addEventListener('change', () => drawAutoEQGraph());
|
|
}
|
|
if (autoeqMaxFreq) {
|
|
autoeqMaxFreq.addEventListener('change', () => drawAutoEQGraph());
|
|
}
|
|
if (autoeqSampleRate) {
|
|
autoeqSampleRate.addEventListener('change', () => {
|
|
if (autoeqCurrentBands && autoeqSelectedMeasurement) {
|
|
computeCorrectedCurve();
|
|
}
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Parametric EQ Preset Selector
|
|
// ========================================
|
|
const parametricPresetSelect = document.getElementById('parametric-preset-select');
|
|
if (parametricPresetSelect) {
|
|
parametricPresetSelect.addEventListener('change', () => {
|
|
const presetKey = parametricPresetSelect.value;
|
|
if (!presetKey) return; // "Custom" selected
|
|
|
|
ensureParametricBands();
|
|
const bandCount = parametricBands.length;
|
|
const presets = getPresetsForBandCount(bandCount);
|
|
const preset = presets[presetKey];
|
|
if (!preset) return;
|
|
|
|
parametricBands.forEach((band, i) => {
|
|
band.gain = preset.gains[i] || 0;
|
|
});
|
|
|
|
applyBandsToAudio(parametricBands);
|
|
renderBandControls(parametricBands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Parametric EQ Profile Save/Load/Render
|
|
// ========================================
|
|
const PARAMETRIC_PROFILES_KEY = 'parametric-eq-profiles';
|
|
const PARAMETRIC_ACTIVE_KEY = 'parametric-eq-active-profile';
|
|
|
|
const getParametricProfiles = () => {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(PARAMETRIC_PROFILES_KEY)) || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const renderParametricProfiles = () => {
|
|
const grid = document.getElementById('parametric-saved-grid');
|
|
const countEl = document.getElementById('parametric-saved-count');
|
|
if (!grid) return;
|
|
|
|
const profiles = getParametricProfiles();
|
|
const activeId = localStorage.getItem(PARAMETRIC_ACTIVE_KEY);
|
|
const keys = Object.keys(profiles);
|
|
if (countEl) countEl.textContent = keys.length;
|
|
grid.innerHTML = '';
|
|
|
|
keys.forEach((id) => {
|
|
const profile = profiles[id];
|
|
const card = document.createElement('div');
|
|
card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : '');
|
|
card.dataset.profileId = id;
|
|
|
|
const preview = document.createElement('canvas');
|
|
preview.className = 'autoeq-profile-preview';
|
|
preview.style.height = '80px';
|
|
card.appendChild(preview);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'autoeq-profile-info';
|
|
info.innerHTML = `
|
|
<span class="autoeq-profile-active-icon">✓</span>
|
|
<span class="autoeq-profile-name">${profile.name || 'Unnamed'}</span>
|
|
<span class="autoeq-profile-meta">${profile.bandCount || '?'} bands</span>
|
|
`;
|
|
card.appendChild(info);
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'autoeq-profile-delete';
|
|
delBtn.innerHTML = '🗑';
|
|
delBtn.title = 'Delete profile';
|
|
delBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const all = getParametricProfiles();
|
|
delete all[id];
|
|
localStorage.setItem(PARAMETRIC_PROFILES_KEY, JSON.stringify(all));
|
|
if (localStorage.getItem(PARAMETRIC_ACTIVE_KEY) === id) localStorage.removeItem(PARAMETRIC_ACTIVE_KEY);
|
|
renderParametricProfiles();
|
|
});
|
|
card.appendChild(delBtn);
|
|
|
|
card.addEventListener('click', () => {
|
|
parametricBands = profile.bands.map((b) => ({ ...b }));
|
|
applyBandsToAudio(parametricBands);
|
|
renderBandControls(parametricBands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
localStorage.setItem(PARAMETRIC_ACTIVE_KEY, id);
|
|
if (parametricPresetSelect) parametricPresetSelect.value = '';
|
|
renderParametricProfiles();
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
|
|
// Draw mini graph
|
|
requestAnimationFrame(() => {
|
|
drawBandsPreview(preview, profile.bands);
|
|
});
|
|
});
|
|
};
|
|
|
|
// Save parametric profile
|
|
const parametricSaveBtn = document.getElementById('parametric-save-btn');
|
|
const parametricProfileName = document.getElementById('parametric-profile-name');
|
|
if (parametricSaveBtn) {
|
|
parametricSaveBtn.addEventListener('click', () => {
|
|
if (!parametricBands || parametricBands.length === 0) return;
|
|
const name = parametricProfileName ? parametricProfileName.value.trim() : '';
|
|
if (!name) return;
|
|
|
|
const profiles = getParametricProfiles();
|
|
const id = 'peq_' + Date.now();
|
|
profiles[id] = {
|
|
name,
|
|
bands: parametricBands.map((b) => ({ ...b })),
|
|
bandCount: parametricBands.length,
|
|
preamp: equalizerSettings.getPreamp(),
|
|
createdAt: Date.now(),
|
|
};
|
|
localStorage.setItem(PARAMETRIC_PROFILES_KEY, JSON.stringify(profiles));
|
|
localStorage.setItem(PARAMETRIC_ACTIVE_KEY, id);
|
|
if (parametricProfileName) parametricProfileName.value = '';
|
|
renderParametricProfiles();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Parametric EQ Import/Export
|
|
// ========================================
|
|
const parametricExportBtn = document.getElementById('parametric-export-btn');
|
|
const parametricImportBtn = document.getElementById('parametric-import-btn');
|
|
const parametricImportFile = document.getElementById('parametric-import-file');
|
|
|
|
if (parametricExportBtn) {
|
|
parametricExportBtn.addEventListener('click', () => {
|
|
if (!parametricBands || parametricBands.length === 0) return;
|
|
const preamp = equalizerSettings.getPreamp();
|
|
const lines = [`Preamp: ${preamp.toFixed(1)} dB`];
|
|
parametricBands.forEach((band, i) => {
|
|
const ft = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK';
|
|
lines.push(
|
|
`Filter ${i + 1}: ON ${ft} Fc ${Math.round(band.freq)} Hz Gain ${band.gain.toFixed(1)} dB Q ${band.q.toFixed(2)}`
|
|
);
|
|
});
|
|
const text = lines.join('\n');
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'parametric-eq.txt';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
}
|
|
|
|
if (parametricImportBtn && parametricImportFile) {
|
|
parametricImportBtn.addEventListener('click', () => parametricImportFile.click());
|
|
parametricImportFile.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const text = event.target.result;
|
|
const bands = [];
|
|
let preamp = 0;
|
|
const lines = text.split('\n');
|
|
for (const line of lines) {
|
|
const preampMatch = line.match(/Preamp:\s*([-\d.]+)\s*dB/i);
|
|
if (preampMatch) {
|
|
preamp = parseFloat(preampMatch[1]);
|
|
continue;
|
|
}
|
|
const filterMatch = line.match(
|
|
/Filter\s+\d+:\s*ON\s+(\w+)\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB\s+Q\s+([\d.]+)/i
|
|
);
|
|
if (filterMatch) {
|
|
const typeMap = {
|
|
PK: 'peaking',
|
|
LS: 'lowshelf',
|
|
LSC: 'lowshelf',
|
|
LSF: 'lowshelf',
|
|
HS: 'highshelf',
|
|
HSC: 'highshelf',
|
|
HSF: 'highshelf',
|
|
};
|
|
bands.push({
|
|
id: bands.length,
|
|
type: typeMap[filterMatch[1].toUpperCase()] || 'peaking',
|
|
freq: parseFloat(filterMatch[2]),
|
|
gain: parseFloat(filterMatch[3]),
|
|
q: parseFloat(filterMatch[4]),
|
|
enabled: true,
|
|
});
|
|
}
|
|
}
|
|
if (bands.length === 0) return;
|
|
parametricBands = bands;
|
|
applyBandsToAudio(parametricBands);
|
|
equalizerSettings.setPreamp(preamp);
|
|
if (eqPreampSlider) eqPreampSlider.value = preamp;
|
|
if (autoeqPreampValue) autoeqPreampValue.textContent = `${preamp} dB`;
|
|
renderBandControls(parametricBands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
if (parametricPresetSelect) parametricPresetSelect.value = '';
|
|
} catch (err) {
|
|
console.error('[PEQ Import] Failed:', err);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
e.target.value = '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Speaker EQ Logic
|
|
// ========================================
|
|
const speakerConfigSelect = document.getElementById('speaker-config-select');
|
|
const speakerChannelTabsEl = document.getElementById('speaker-channel-tabs');
|
|
const speakerMeasStatus = document.getElementById('speaker-measurement-status');
|
|
const speakerImportMeasBtn = document.getElementById('speaker-import-measurement-btn');
|
|
const speakerImportMeasFile = document.getElementById('speaker-import-measurement-file');
|
|
const speakerClearMeasBtn = document.getElementById('speaker-clear-measurement-btn');
|
|
const speakerTargetSelect = document.getElementById('speaker-target-select');
|
|
const speakerImportTargetBtn = document.getElementById('speaker-import-target-btn');
|
|
const speakerImportTargetFile = document.getElementById('speaker-import-target-file');
|
|
const speakerBandCountSelect = document.getElementById('speaker-band-count');
|
|
const speakerBassCutoff = document.getElementById('speaker-bass-cutoff');
|
|
const speakerBassCutoffValue = document.getElementById('speaker-bass-cutoff-value');
|
|
const speakerRoomLimit = document.getElementById('speaker-room-limit');
|
|
const speakerRoomLimitValue = document.getElementById('speaker-room-limit-value');
|
|
const speakerAutoEqBtn = document.getElementById('speaker-autoeq-btn');
|
|
const speakerEqStatus = document.getElementById('speaker-eq-status');
|
|
const speakerExportBtn = document.getElementById('speaker-export-btn');
|
|
|
|
const getSpeakerChannel = () => speakerChannels[speakerActiveChannel];
|
|
|
|
const renderSpeakerChannelTabs = () => {
|
|
if (!speakerChannelTabsEl) return;
|
|
const ids = SPEAKER_CONFIGS[speakerConfig];
|
|
speakerChannelTabsEl.innerHTML = '';
|
|
ids.forEach((id) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'speaker-channel-tab' + (id === speakerActiveChannel ? ' active' : '');
|
|
btn.textContent = id;
|
|
btn.title = SPEAKER_CHANNEL_LABELS[id];
|
|
if (speakerChannels[id].measurement) btn.classList.add('has-data');
|
|
btn.addEventListener('click', () => {
|
|
speakerActiveChannel = id;
|
|
renderSpeakerChannelTabs();
|
|
updateSpeakerUI();
|
|
// Apply this channel's bands to audio + graph
|
|
const ch = getSpeakerChannel();
|
|
applyBandsToAudio(ch.bands);
|
|
renderBandControls(ch.bands);
|
|
drawAutoEQGraph();
|
|
});
|
|
speakerChannelTabsEl.appendChild(btn);
|
|
});
|
|
};
|
|
|
|
const updateSpeakerUI = () => {
|
|
const ch = getSpeakerChannel();
|
|
// Measurement status
|
|
if (speakerMeasStatus) {
|
|
speakerMeasStatus.textContent = ch.measurement ? `${ch.measurement.length} pts` : 'No measurement';
|
|
speakerMeasStatus.classList.toggle('loaded', !!ch.measurement);
|
|
}
|
|
if (speakerClearMeasBtn) speakerClearMeasBtn.style.display = ch.measurement ? '' : 'none';
|
|
if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = !ch.measurement;
|
|
// Target
|
|
if (speakerTargetSelect) speakerTargetSelect.value = ch.targetId;
|
|
// Preamp
|
|
};
|
|
|
|
// Config change
|
|
if (speakerConfigSelect) {
|
|
speakerConfigSelect.addEventListener('change', () => {
|
|
speakerConfig = speakerConfigSelect.value;
|
|
const ids = SPEAKER_CONFIGS[speakerConfig];
|
|
if (!ids.includes(speakerActiveChannel)) speakerActiveChannel = ids[0];
|
|
renderSpeakerChannelTabs();
|
|
updateSpeakerUI();
|
|
});
|
|
}
|
|
|
|
// Import measurement
|
|
if (speakerImportMeasBtn && speakerImportMeasFile) {
|
|
speakerImportMeasBtn.addEventListener('click', () => speakerImportMeasFile.click());
|
|
speakerImportMeasFile.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const data = parseRawData(ev.target.result);
|
|
if (data.length > 0) {
|
|
getSpeakerChannel().measurement = data;
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
drawAutoEQGraph();
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
e.target.value = '';
|
|
});
|
|
}
|
|
|
|
// Clear measurement
|
|
if (speakerClearMeasBtn) {
|
|
speakerClearMeasBtn.addEventListener('click', () => {
|
|
getSpeakerChannel().measurement = null;
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
// Pink noise room measurement
|
|
const speakerMeasureBtn = document.getElementById('speaker-measure-btn');
|
|
if (speakerMeasureBtn) {
|
|
speakerMeasureBtn.addEventListener('click', async () => {
|
|
speakerMeasureBtn.disabled = true;
|
|
if (speakerMeasStatus) {
|
|
speakerMeasStatus.textContent = 'Requesting mic...';
|
|
speakerMeasStatus.classList.remove('loaded');
|
|
}
|
|
|
|
let measCtx, stream;
|
|
try {
|
|
// 1. Get mic with processing disabled
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
|
|
});
|
|
|
|
measCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 });
|
|
const sr = measCtx.sampleRate;
|
|
const duration = 5;
|
|
|
|
// 2. Generate pink noise buffer (Voss algorithm approximation)
|
|
const bufLen = sr * duration;
|
|
const buffer = measCtx.createBuffer(1, bufLen, sr);
|
|
const data = buffer.getChannelData(0);
|
|
// Paul Kellet's refined pink noise filter coefficients
|
|
let b0 = 0,
|
|
b1 = 0,
|
|
b2 = 0,
|
|
b3 = 0,
|
|
b4 = 0,
|
|
b5 = 0,
|
|
b6 = 0;
|
|
for (let i = 0; i < bufLen; i++) {
|
|
const white = Math.random() * 2 - 1;
|
|
b0 = 0.99886 * b0 + white * 0.0555179;
|
|
b1 = 0.99332 * b1 + white * 0.0750759;
|
|
b2 = 0.969 * b2 + white * 0.153852;
|
|
b3 = 0.8665 * b3 + white * 0.3104856;
|
|
b4 = 0.55 * b4 + white * 0.5329522;
|
|
b5 = -0.7616 * b5 - white * 0.016898;
|
|
let pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
|
b6 = white * 0.115926;
|
|
// Fade in/out envelope (100ms)
|
|
let env = 1;
|
|
const t = i / sr;
|
|
if (t < 0.1) env = t / 0.1;
|
|
else if (t > duration - 0.1) env = (duration - t) / 0.1;
|
|
data[i] = pink * 0.04 * env; // low amplitude
|
|
}
|
|
|
|
// 3. Play pink noise
|
|
const noiseSource = measCtx.createBufferSource();
|
|
noiseSource.buffer = buffer;
|
|
noiseSource.connect(measCtx.destination);
|
|
|
|
// 4. Setup mic analyser
|
|
const micSource = measCtx.createMediaStreamSource(stream);
|
|
const analyser = measCtx.createAnalyser();
|
|
analyser.fftSize = 8192;
|
|
analyser.smoothingTimeConstant = 0.3;
|
|
micSource.connect(analyser);
|
|
|
|
const freqBinCount = analyser.frequencyBinCount;
|
|
const binHz = sr / analyser.fftSize;
|
|
const fftData = new Float32Array(freqBinCount);
|
|
const accumulator = new Float64Array(freqBinCount);
|
|
let frameCount = 0;
|
|
|
|
// 5. Start playback + capture loop
|
|
noiseSource.start();
|
|
const startTime = measCtx.currentTime;
|
|
|
|
await new Promise((resolve) => {
|
|
const tick = () => {
|
|
const elapsed = measCtx.currentTime - startTime;
|
|
if (elapsed >= duration) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Update progress
|
|
const pct = Math.round((elapsed / duration) * 100);
|
|
if (speakerMeasStatus) speakerMeasStatus.textContent = `Measuring... ${pct}%`;
|
|
|
|
// Skip first 0.3s (let noise settle)
|
|
if (elapsed > 0.3) {
|
|
analyser.getFloatFrequencyData(fftData);
|
|
for (let j = 0; j < freqBinCount; j++) {
|
|
const val = fftData[j];
|
|
if (val !== -Infinity) accumulator[j] += val;
|
|
}
|
|
frameCount++;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
|
|
noiseSource.stop();
|
|
|
|
// 6. Post-process: average bins → log-spaced points
|
|
if (frameCount === 0) throw new Error('No frames captured');
|
|
for (let j = 0; j < freqBinCount; j++) accumulator[j] /= frameCount;
|
|
|
|
const points = [];
|
|
const ptsPerOctave = 24;
|
|
let freq = 20;
|
|
while (freq <= 20000) {
|
|
const binIdx = Math.round(freq / binHz);
|
|
if (binIdx >= 0 && binIdx < freqBinCount) {
|
|
// Average a few bins around target for smoothing
|
|
const lo = Math.max(0, binIdx - 2);
|
|
const hi = Math.min(freqBinCount - 1, binIdx + 2);
|
|
let sum = 0,
|
|
cnt = 0;
|
|
for (let k = lo; k <= hi; k++) {
|
|
sum += accumulator[k];
|
|
cnt++;
|
|
}
|
|
points.push({ freq, gain: sum / cnt });
|
|
}
|
|
freq *= Math.pow(2, 1 / ptsPerOctave);
|
|
}
|
|
|
|
// Normalize: midrange (500-2000 Hz) average → 75 dB
|
|
const midPts = points.filter((p) => p.freq >= 500 && p.freq <= 2000);
|
|
const midAvg = midPts.length > 0 ? midPts.reduce((s, p) => s + p.gain, 0) / midPts.length : 0;
|
|
const offset = 75 - midAvg;
|
|
const normalized = points.map((p) => ({ freq: p.freq, gain: p.gain + offset }));
|
|
|
|
// 7. Store result
|
|
getSpeakerChannel().measurement = normalized;
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
if (speakerMeasStatus) speakerMeasStatus.textContent = `${normalized.length} pts (measured)`;
|
|
} catch (err) {
|
|
console.error('[Speaker Measure]', err);
|
|
if (speakerMeasStatus)
|
|
speakerMeasStatus.textContent = err.name === 'NotAllowedError' ? 'Mic denied' : 'Measure failed';
|
|
} finally {
|
|
// Cleanup
|
|
if (stream) stream.getTracks().forEach((t) => t.stop());
|
|
if (measCtx && measCtx.state !== 'closed') measCtx.close().catch(() => {});
|
|
speakerMeasureBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Measure All — plays pink noise once, assigns averaged measurement to all active channels
|
|
const speakerMeasureAllBtn = document.getElementById('speaker-measure-all-btn');
|
|
if (speakerMeasureAllBtn) {
|
|
speakerMeasureAllBtn.addEventListener('click', async () => {
|
|
speakerMeasureAllBtn.disabled = true;
|
|
if (speakerMeasureBtn) speakerMeasureBtn.disabled = true;
|
|
if (speakerMeasStatus) {
|
|
speakerMeasStatus.textContent = 'Requesting mic...';
|
|
speakerMeasStatus.classList.remove('loaded');
|
|
}
|
|
|
|
let measCtx, stream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
|
|
});
|
|
|
|
measCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 });
|
|
const sr = measCtx.sampleRate;
|
|
const duration = 5;
|
|
|
|
// Generate pink noise buffer
|
|
const bufLen = sr * duration;
|
|
const buffer = measCtx.createBuffer(1, bufLen, sr);
|
|
const d = buffer.getChannelData(0);
|
|
let b0 = 0,
|
|
b1 = 0,
|
|
b2 = 0,
|
|
b3 = 0,
|
|
b4 = 0,
|
|
b5 = 0,
|
|
b6 = 0;
|
|
for (let i = 0; i < bufLen; i++) {
|
|
const white = Math.random() * 2 - 1;
|
|
b0 = 0.99886 * b0 + white * 0.0555179;
|
|
b1 = 0.99332 * b1 + white * 0.0750759;
|
|
b2 = 0.969 * b2 + white * 0.153852;
|
|
b3 = 0.8665 * b3 + white * 0.3104856;
|
|
b4 = 0.55 * b4 + white * 0.5329522;
|
|
b5 = -0.7616 * b5 - white * 0.016898;
|
|
let pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
|
b6 = white * 0.115926;
|
|
let env = 1;
|
|
const t = i / sr;
|
|
if (t < 0.1) env = t / 0.1;
|
|
else if (t > duration - 0.1) env = (duration - t) / 0.1;
|
|
d[i] = pink * 0.04 * env;
|
|
}
|
|
|
|
const noiseSource = measCtx.createBufferSource();
|
|
noiseSource.buffer = buffer;
|
|
noiseSource.connect(measCtx.destination);
|
|
|
|
const micSource = measCtx.createMediaStreamSource(stream);
|
|
const analyser = measCtx.createAnalyser();
|
|
analyser.fftSize = 8192;
|
|
analyser.smoothingTimeConstant = 0.3;
|
|
micSource.connect(analyser);
|
|
|
|
const freqBinCount = analyser.frequencyBinCount;
|
|
const binHz = sr / analyser.fftSize;
|
|
const fftData = new Float32Array(freqBinCount);
|
|
const accumulator = new Float64Array(freqBinCount);
|
|
let frameCount = 0;
|
|
|
|
noiseSource.start();
|
|
const startTime = measCtx.currentTime;
|
|
|
|
await new Promise((resolve) => {
|
|
const tick = () => {
|
|
const elapsed = measCtx.currentTime - startTime;
|
|
if (elapsed >= duration) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const pct = Math.round((elapsed / duration) * 100);
|
|
if (speakerMeasStatus) speakerMeasStatus.textContent = `Measuring all... ${pct}%`;
|
|
if (elapsed > 0.3) {
|
|
analyser.getFloatFrequencyData(fftData);
|
|
for (let j = 0; j < freqBinCount; j++) {
|
|
const val = fftData[j];
|
|
if (val !== -Infinity) accumulator[j] += val;
|
|
}
|
|
frameCount++;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
|
|
noiseSource.stop();
|
|
|
|
if (frameCount === 0) throw new Error('No frames captured');
|
|
for (let j = 0; j < freqBinCount; j++) accumulator[j] /= frameCount;
|
|
|
|
const points = [];
|
|
const ptsPerOctave = 24;
|
|
let freq = 20;
|
|
while (freq <= 20000) {
|
|
const binIdx = Math.round(freq / binHz);
|
|
if (binIdx >= 0 && binIdx < freqBinCount) {
|
|
const lo = Math.max(0, binIdx - 2);
|
|
const hi = Math.min(freqBinCount - 1, binIdx + 2);
|
|
let sum = 0,
|
|
cnt = 0;
|
|
for (let k = lo; k <= hi; k++) {
|
|
sum += accumulator[k];
|
|
cnt++;
|
|
}
|
|
points.push({ freq, gain: sum / cnt });
|
|
}
|
|
freq *= Math.pow(2, 1 / ptsPerOctave);
|
|
}
|
|
|
|
const midPts = points.filter((p) => p.freq >= 500 && p.freq <= 2000);
|
|
const midAvg = midPts.length > 0 ? midPts.reduce((s, p) => s + p.gain, 0) / midPts.length : 0;
|
|
const offset = 75 - midAvg;
|
|
const normalized = points.map((p) => ({ freq: p.freq, gain: p.gain + offset }));
|
|
|
|
// Assign to ALL active channels
|
|
const activeIds = SPEAKER_CONFIGS[speakerConfig];
|
|
activeIds.forEach((id) => {
|
|
speakerChannels[id].measurement = normalized.map((p) => ({ ...p }));
|
|
});
|
|
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
if (speakerMeasStatus)
|
|
speakerMeasStatus.textContent = `${normalized.length} pts → ${activeIds.length} channels`;
|
|
} catch (err) {
|
|
console.error('[Speaker Measure All]', err);
|
|
if (speakerMeasStatus)
|
|
speakerMeasStatus.textContent = err.name === 'NotAllowedError' ? 'Mic denied' : 'Measure failed';
|
|
} finally {
|
|
if (stream) stream.getTracks().forEach((t) => t.stop());
|
|
if (measCtx && measCtx.state !== 'closed') measCtx.close().catch(() => {});
|
|
speakerMeasureAllBtn.disabled = false;
|
|
if (speakerMeasureBtn) speakerMeasureBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// AutoEQ All — runs AutoEQ on every active channel that has a measurement
|
|
const speakerAutoEqAllBtn = document.getElementById('speaker-autoeq-all-btn');
|
|
if (speakerAutoEqAllBtn) {
|
|
speakerAutoEqAllBtn.addEventListener('click', () => {
|
|
const activeIds = SPEAKER_CONFIGS[speakerConfig];
|
|
const measuredIds = activeIds.filter((id) => speakerChannels[id].measurement);
|
|
if (measuredIds.length === 0) return;
|
|
|
|
speakerAutoEqAllBtn.disabled = true;
|
|
if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = true;
|
|
if (speakerEqStatus) speakerEqStatus.textContent = 'Running all...';
|
|
|
|
setTimeout(() => {
|
|
const bandCount = speakerBandCountSelect ? parseInt(speakerBandCountSelect.value, 10) : 10;
|
|
const bassCut = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40;
|
|
const roomLim = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500;
|
|
|
|
measuredIds.forEach((id) => {
|
|
const ch = speakerChannels[id];
|
|
const targetEntry = SPEAKER_TARGETS.find((t) => t.id === ch.targetId);
|
|
const targetData = targetEntry?.data || [];
|
|
|
|
const bands = runAutoEqAlgorithm(ch.measurement, targetData, bandCount, roomLim, bassCut, 3.0);
|
|
|
|
let maxGain = 0;
|
|
for (let f = 20; f <= 20000; f *= 1.1) {
|
|
let total = 0;
|
|
bands.forEach((b) => {
|
|
if (b.enabled) total += calculateBiquadResponse(f, b);
|
|
});
|
|
if (total > maxGain) maxGain = total;
|
|
}
|
|
ch.bands = bands;
|
|
ch.preamp = maxGain > 0 ? parseFloat((-maxGain - 0.1).toFixed(1)) : 0;
|
|
});
|
|
|
|
// Refresh active channel UI
|
|
const ch = getSpeakerChannel();
|
|
applyBandsToAudio(ch.bands);
|
|
renderBandControls(ch.bands);
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
|
|
speakerAutoEqAllBtn.disabled = false;
|
|
if (speakerAutoEqBtn) speakerAutoEqBtn.disabled = !ch.measurement;
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `${measuredIds.length} channels optimized`;
|
|
setTimeout(() => {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = '';
|
|
}, 3000);
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
// Target change
|
|
if (speakerTargetSelect) {
|
|
speakerTargetSelect.addEventListener('change', () => {
|
|
getSpeakerChannel().targetId = speakerTargetSelect.value;
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
// Import custom speaker target
|
|
if (speakerImportTargetBtn && speakerImportTargetFile) {
|
|
speakerImportTargetBtn.addEventListener('click', () => speakerImportTargetFile.click());
|
|
speakerImportTargetFile.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const data = parseRawData(ev.target.result);
|
|
if (data.length === 0) return;
|
|
const customId = 'custom_speaker_target';
|
|
const label = file.name.replace(/\.(txt|csv)$/i, '');
|
|
const existing = SPEAKER_TARGETS.findIndex((t) => t.id === customId);
|
|
if (existing > -1) SPEAKER_TARGETS[existing] = { id: customId, label, data };
|
|
else SPEAKER_TARGETS.push({ id: customId, label, data });
|
|
let opt = speakerTargetSelect.querySelector('option[value="custom_speaker_target"]');
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = customId;
|
|
speakerTargetSelect.appendChild(opt);
|
|
}
|
|
opt.textContent = label;
|
|
speakerTargetSelect.value = customId;
|
|
getSpeakerChannel().targetId = customId;
|
|
drawAutoEQGraph();
|
|
};
|
|
reader.readAsText(file);
|
|
e.target.value = '';
|
|
});
|
|
}
|
|
|
|
// Slider labels
|
|
if (speakerBassCutoff) {
|
|
speakerBassCutoff.addEventListener('input', () => {
|
|
if (speakerBassCutoffValue) speakerBassCutoffValue.textContent = `${speakerBassCutoff.value} Hz`;
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
if (speakerRoomLimit) {
|
|
speakerRoomLimit.addEventListener('input', () => {
|
|
if (speakerRoomLimitValue) speakerRoomLimitValue.textContent = `${speakerRoomLimit.value} Hz`;
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
// AutoEQ per channel
|
|
if (speakerAutoEqBtn) {
|
|
speakerAutoEqBtn.addEventListener('click', () => {
|
|
const ch = getSpeakerChannel();
|
|
if (!ch.measurement) return;
|
|
speakerAutoEqBtn.disabled = true;
|
|
if (speakerEqStatus) speakerEqStatus.textContent = 'Running...';
|
|
|
|
setTimeout(() => {
|
|
const targetEntry = SPEAKER_TARGETS.find((t) => t.id === ch.targetId);
|
|
const targetData = targetEntry?.data || [];
|
|
const bandCount = speakerBandCountSelect ? parseInt(speakerBandCountSelect.value, 10) : 10;
|
|
const bassCut = speakerBassCutoff ? parseInt(speakerBassCutoff.value, 10) : 40;
|
|
const roomLim = speakerRoomLimit ? parseInt(speakerRoomLimit.value, 10) : 500;
|
|
|
|
const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000;
|
|
const bands = runAutoEqAlgorithm(
|
|
ch.measurement,
|
|
targetData,
|
|
bandCount,
|
|
roomLim,
|
|
bassCut,
|
|
3.0,
|
|
sampleRate
|
|
);
|
|
|
|
// Auto preamp
|
|
let maxGain = 0;
|
|
for (let f = 20; f <= 20000; f *= 1.1) {
|
|
let total = 0;
|
|
bands.forEach((b) => {
|
|
if (b.enabled) total += calculateBiquadResponse(f, b, sampleRate);
|
|
});
|
|
if (total > maxGain) maxGain = total;
|
|
}
|
|
const autoPreamp = maxGain > 0 ? parseFloat((-maxGain - 0.1).toFixed(1)) : 0;
|
|
|
|
ch.bands = bands;
|
|
ch.preamp = autoPreamp;
|
|
|
|
applyBandsToAudio(bands);
|
|
renderBandControls(bands);
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
|
|
speakerAutoEqBtn.disabled = false;
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `${speakerActiveChannel} optimized`;
|
|
setTimeout(() => {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = '';
|
|
}, 3000);
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
// Export all channels as JSON
|
|
if (speakerExportBtn) {
|
|
speakerExportBtn.addEventListener('click', () => {
|
|
const activeIds = SPEAKER_CONFIGS[speakerConfig];
|
|
const data = {
|
|
config: speakerConfig,
|
|
channels: activeIds.map((id) => {
|
|
const ch = speakerChannels[id];
|
|
return {
|
|
id,
|
|
label: SPEAKER_CHANNEL_LABELS[id],
|
|
preamp: ch.preamp,
|
|
filters: ch.bands
|
|
.filter((b) => b.enabled)
|
|
.map((b) => ({
|
|
type: b.type,
|
|
freq: b.freq,
|
|
gain: b.gain,
|
|
q: b.q,
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `SpeakerEQ_${speakerConfig}_${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
}
|
|
|
|
// Import EQ settings from JSON
|
|
const speakerImportBtn = document.getElementById('speaker-import-btn');
|
|
const speakerImportFile = document.getElementById('speaker-import-file');
|
|
if (speakerImportBtn && speakerImportFile) {
|
|
speakerImportBtn.addEventListener('click', () => speakerImportFile.click());
|
|
speakerImportFile.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
try {
|
|
const text = await file.text();
|
|
const data = JSON.parse(text);
|
|
if (!data.config || !Array.isArray(data.channels)) {
|
|
throw new Error('Invalid JSON format');
|
|
}
|
|
// Change config if different
|
|
if (data.config !== speakerConfig) {
|
|
speakerConfig = data.config;
|
|
if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig;
|
|
}
|
|
// Load channels
|
|
data.channels.forEach((ch) => {
|
|
if (speakerChannels[ch.id]) {
|
|
speakerChannels[ch.id].preamp = ch.preamp || 0;
|
|
speakerChannels[ch.id].bands = ch.filters.map((f) => ({
|
|
enabled: true,
|
|
type: f.type,
|
|
freq: f.freq,
|
|
gain: f.gain,
|
|
q: f.q,
|
|
}));
|
|
}
|
|
});
|
|
// Update UI
|
|
speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0];
|
|
renderSpeakerChannelTabs();
|
|
setEQMode('speaker');
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `Loaded: ${data.channels.length} channels`;
|
|
setTimeout(() => {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = '';
|
|
}, 2000);
|
|
} catch (err) {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `Error: ${err.message}`;
|
|
}
|
|
speakerImportFile.value = '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Speaker Saved Profiles
|
|
// ========================================
|
|
const SPEAKER_PROFILES_IDB_KEY = 'speaker-eq-profiles';
|
|
const SPEAKER_ACTIVE_PROFILE_KEY = 'speaker-eq-active-profile';
|
|
let _speakerProfilesCache = null; // in-memory cache backed by IndexedDB
|
|
|
|
const getSpeakerProfiles = () => _speakerProfilesCache || {};
|
|
|
|
const loadSpeakerProfilesFromDB = async () => {
|
|
try {
|
|
// Migrate from localStorage if present
|
|
const lsData = localStorage.getItem('speaker-eq-profiles');
|
|
if (lsData) {
|
|
const parsed = JSON.parse(lsData);
|
|
if (parsed && Object.keys(parsed).length > 0) {
|
|
await db.saveSetting(SPEAKER_PROFILES_IDB_KEY, parsed);
|
|
}
|
|
localStorage.removeItem('speaker-eq-profiles');
|
|
}
|
|
} catch {
|
|
/* ignore migration errors */
|
|
}
|
|
try {
|
|
_speakerProfilesCache = (await db.getSetting(SPEAKER_PROFILES_IDB_KEY)) || {};
|
|
} catch {
|
|
_speakerProfilesCache = {};
|
|
}
|
|
};
|
|
|
|
const saveSpeakerProfiles = async (profiles) => {
|
|
_speakerProfilesCache = profiles;
|
|
await db.saveSetting(SPEAKER_PROFILES_IDB_KEY, profiles);
|
|
};
|
|
|
|
await loadSpeakerProfilesFromDB();
|
|
|
|
const renderSpeakerProfiles = () => {
|
|
const grid = document.getElementById('speaker-saved-grid');
|
|
const countEl = document.getElementById('speaker-saved-count');
|
|
if (!grid) return;
|
|
|
|
const profiles = getSpeakerProfiles();
|
|
const activeId = localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY);
|
|
const keys = Object.keys(profiles);
|
|
if (countEl) countEl.textContent = keys.length;
|
|
grid.innerHTML = '';
|
|
|
|
if (keys.length === 0) return;
|
|
|
|
keys.forEach((id) => {
|
|
const profile = profiles[id];
|
|
const card = document.createElement('div');
|
|
card.className = 'autoeq-profile-card' + (id === activeId ? ' active' : '');
|
|
|
|
const preview = document.createElement('canvas');
|
|
preview.className = 'autoeq-profile-preview';
|
|
preview.style.height = '80px';
|
|
card.appendChild(preview);
|
|
|
|
const channelCount = profile.channels ? profile.channels.length : 0;
|
|
const info = document.createElement('div');
|
|
info.className = 'autoeq-profile-info';
|
|
info.innerHTML = `
|
|
<span class="autoeq-profile-active-icon">✓</span>
|
|
<span class="autoeq-profile-name">${profile.name || 'Unnamed'}</span>
|
|
<span class="autoeq-profile-meta">${profile.config} · ${channelCount} ch</span>
|
|
`;
|
|
card.appendChild(info);
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.className = 'autoeq-profile-delete';
|
|
delBtn.innerHTML = '🗑';
|
|
delBtn.title = 'Delete profile';
|
|
delBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const all = getSpeakerProfiles();
|
|
delete all[id];
|
|
await saveSpeakerProfiles(all);
|
|
if (localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY) === id)
|
|
localStorage.removeItem(SPEAKER_ACTIVE_PROFILE_KEY);
|
|
renderSpeakerProfiles();
|
|
});
|
|
card.appendChild(delBtn);
|
|
|
|
// Click to load
|
|
card.addEventListener('click', () => {
|
|
loadSpeakerProfile(id);
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
|
|
// Draw mini preview from first channel's measurement
|
|
requestAnimationFrame(() => {
|
|
const firstCh = profile.channels?.[0];
|
|
if (firstCh && firstCh.measurementPreview) {
|
|
const targetEntry = SPEAKER_TARGETS.find((t) => t.id === (firstCh.targetId || 'harman_room'));
|
|
drawMiniGraph(
|
|
preview,
|
|
firstCh.measurementPreview,
|
|
targetEntry?.data ? downsampleCurve(targetEntry.data) : null,
|
|
firstCh.correctedPreview || null
|
|
);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const loadSpeakerProfile = (profileId) => {
|
|
const profiles = getSpeakerProfiles();
|
|
const profile = profiles[profileId];
|
|
if (!profile) return;
|
|
|
|
// Switch config if different
|
|
if (profile.config && profile.config !== speakerConfig) {
|
|
speakerConfig = profile.config;
|
|
if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig;
|
|
}
|
|
|
|
// Load all channels
|
|
if (profile.channels) {
|
|
profile.channels.forEach((saved) => {
|
|
if (speakerChannels[saved.id]) {
|
|
speakerChannels[saved.id].measurement = saved.measurement || null;
|
|
speakerChannels[saved.id].targetId = saved.targetId || 'harman_room';
|
|
speakerChannels[saved.id].preamp = saved.preamp || 0;
|
|
speakerChannels[saved.id].bands = saved.bands
|
|
? saved.bands.map((b) => ({ ...b }))
|
|
: speakerChannels[saved.id].bands;
|
|
}
|
|
});
|
|
}
|
|
|
|
speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0];
|
|
const ch = getSpeakerChannel();
|
|
applyBandsToAudio(ch.bands);
|
|
renderBandControls(ch.bands);
|
|
updateSpeakerUI();
|
|
renderSpeakerChannelTabs();
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
|
|
localStorage.setItem(SPEAKER_ACTIVE_PROFILE_KEY, profileId);
|
|
renderSpeakerProfiles();
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `Loaded "${profile.name}"`;
|
|
setTimeout(() => {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = '';
|
|
}, 2000);
|
|
};
|
|
|
|
// Save button
|
|
const speakerSaveBtn = document.getElementById('speaker-save-btn');
|
|
const speakerProfileNameInput = document.getElementById('speaker-profile-name');
|
|
if (speakerSaveBtn) {
|
|
speakerSaveBtn.addEventListener('click', async () => {
|
|
try {
|
|
const name = speakerProfileNameInput?.value.trim() || `Speaker ${speakerConfig}`;
|
|
const activeIds = SPEAKER_CONFIGS[speakerConfig];
|
|
const profiles = getSpeakerProfiles();
|
|
const id = 'spk_' + Date.now();
|
|
|
|
profiles[id] = {
|
|
name,
|
|
config: speakerConfig,
|
|
channels: activeIds.map((chId) => {
|
|
const ch = speakerChannels[chId];
|
|
return {
|
|
id: chId,
|
|
targetId: ch.targetId,
|
|
preamp: ch.preamp,
|
|
bands: ch.bands.map((b) => ({ ...b })),
|
|
measurement: ch.measurement
|
|
? ch.measurement.map((p) => ({ freq: p.freq, gain: parseFloat(p.gain.toFixed(1)) }))
|
|
: null,
|
|
measurementPreview: ch.measurement ? downsampleCurve(ch.measurement) : null,
|
|
correctedPreview:
|
|
autoeqCorrectedCurve && chId === speakerActiveChannel
|
|
? downsampleCurve(autoeqCorrectedCurve)
|
|
: null,
|
|
};
|
|
}),
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
await saveSpeakerProfiles(profiles);
|
|
localStorage.setItem(SPEAKER_ACTIVE_PROFILE_KEY, id);
|
|
if (speakerProfileNameInput) speakerProfileNameInput.value = '';
|
|
renderSpeakerProfiles();
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `Saved "${name}"`;
|
|
setTimeout(() => {
|
|
if (speakerEqStatus) speakerEqStatus.textContent = '';
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('[Speaker Save]', err);
|
|
if (speakerEqStatus) speakerEqStatus.textContent = `Save failed: ${err.message}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Collapse toggle for speaker saved section
|
|
const speakerSavedCollapse = document.getElementById('speaker-saved-collapse');
|
|
const speakerSavedGrid = document.getElementById('speaker-saved-grid');
|
|
if (speakerSavedCollapse && speakerSavedGrid) {
|
|
speakerSavedCollapse.addEventListener('click', () => {
|
|
speakerSavedCollapse.classList.toggle('collapsed');
|
|
speakerSavedGrid.style.display = speakerSavedCollapse.classList.contains('collapsed') ? 'none' : '';
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// Add/Remove/Reset Band Buttons
|
|
// ========================================
|
|
const addBandBtn = document.getElementById('autoeq-add-band-btn');
|
|
const removeBandBtn = document.getElementById('autoeq-remove-band-btn');
|
|
const resetBandsBtn = document.getElementById('autoeq-reset-bands-btn');
|
|
|
|
if (addBandBtn) {
|
|
addBandBtn.addEventListener('click', () => {
|
|
let bands = getActiveBands();
|
|
if (!bands) {
|
|
bands = [];
|
|
setActiveBands(bands);
|
|
}
|
|
if (bands.length >= 32) return;
|
|
bands.push({ id: bands.length, type: 'peaking', freq: 1000, gain: 0, q: 1.0, enabled: true });
|
|
applyBandsToAudio(bands);
|
|
renderBandControls(bands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
if (removeBandBtn) {
|
|
removeBandBtn.addEventListener('click', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands || bands.length <= 1) return;
|
|
bands.pop();
|
|
applyBandsToAudio(bands);
|
|
renderBandControls(bands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
if (resetBandsBtn) {
|
|
resetBandsBtn.addEventListener('click', () => {
|
|
const bands = getActiveBands();
|
|
if (!bands) return;
|
|
bands.forEach((b) => {
|
|
b.gain = 0;
|
|
});
|
|
applyBandsToAudio(bands);
|
|
renderBandControls(bands);
|
|
computeCorrectedCurve();
|
|
drawAutoEQGraph();
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// EQ Toggle (enable/disable)
|
|
// ========================================
|
|
if (eqToggle) {
|
|
eqToggle.checked = equalizerSettings.isEnabled();
|
|
updateEQContainerVisibility(eqToggle.checked);
|
|
|
|
eqToggle.addEventListener('change', (e) => {
|
|
const enabled = e.target.checked;
|
|
equalizerSettings.setEnabled(enabled);
|
|
updateEQContainerVisibility(enabled);
|
|
|
|
audioContextManager.toggleEQ(enabled);
|
|
});
|
|
}
|
|
|
|
// Initial render of saved profiles
|
|
renderSavedProfiles();
|
|
|
|
// Hide parametric-only elements on startup (default mode is autoeq)
|
|
const initPresetRow = document.getElementById('autoeq-preset-row');
|
|
const initParaProfiles = document.getElementById('autoeq-parametric-profiles');
|
|
if (initPresetRow) initPresetRow.style.display = 'none';
|
|
if (initParaProfiles) initParaProfiles.style.display = 'none';
|
|
|
|
// Auto-load headphone database
|
|
loadFullDatabase();
|
|
|
|
// Auto-load default popular headphone if no saved profile is active
|
|
const activeProfileId = equalizerSettings.getActiveAutoEQProfile();
|
|
if (!activeProfileId) {
|
|
// Try restoring last selected headphone (persisted measurement + entry)
|
|
const lastHp = equalizerSettings.getLastHeadphone();
|
|
if (lastHp) {
|
|
autoeqSelectedMeasurement = lastHp.measurementData;
|
|
autoeqSelectedEntry = lastHp.entry;
|
|
if (autoeqHeadphoneSelect) {
|
|
let opt = autoeqHeadphoneSelect.querySelector(`option[value="${lastHp.entry.name}"]`);
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = lastHp.entry.name;
|
|
opt.textContent = lastHp.entry.name.replace(/\s*\([^)]*\)\s*$/, '');
|
|
autoeqHeadphoneSelect.appendChild(opt);
|
|
}
|
|
autoeqHeadphoneSelect.value = lastHp.entry.name;
|
|
}
|
|
if (autoeqRunBtn) autoeqRunBtn.disabled = false;
|
|
requestAnimationFrame(drawAutoEQGraph);
|
|
} else if (POPULAR_HEADPHONES.length > 0) {
|
|
loadHeadphoneEntry(POPULAR_HEADPHONES[0]);
|
|
}
|
|
}
|
|
|
|
// Initial draw of graph (if EQ is enabled)
|
|
if (equalizerSettings.isEnabled()) {
|
|
requestAnimationFrame(drawAutoEQGraph);
|
|
}
|
|
|
|
// Load active profile on startup
|
|
if (activeProfileId) {
|
|
const profiles = equalizerSettings.getAutoEQProfiles();
|
|
if (profiles[activeProfileId]) {
|
|
// Restore state silently
|
|
const profile = profiles[activeProfileId];
|
|
autoeqCurrentBands = profile.bands?.map((b) => ({ ...b })) || null;
|
|
autoeqCorrectedCurve = profile.correctedData ? [...profile.correctedData] : null;
|
|
autoeqSelectedMeasurement = profile.measurementData ? [...profile.measurementData] : null;
|
|
autoeqSelectedEntry = { name: profile.headphoneName, type: profile.headphoneType };
|
|
// Restore headphone select dropdown
|
|
if (autoeqHeadphoneSelect) {
|
|
let opt = autoeqHeadphoneSelect.querySelector(`option[value="${profile.headphoneName}"]`);
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = profile.headphoneName;
|
|
opt.textContent = profile.headphoneName.replace(/\s*\([^)]*\)\s*$/, '');
|
|
autoeqHeadphoneSelect.appendChild(opt);
|
|
}
|
|
autoeqHeadphoneSelect.value = profile.headphoneName;
|
|
}
|
|
if (autoeqTargetSelect) autoeqTargetSelect.value = profile.targetId || 'harman_oe_2018';
|
|
setAutoeqBandCount(profile.bandCount, profile.bands);
|
|
if (autoeqMaxFreq) autoeqMaxFreq.value = profile.maxFreq || 16000;
|
|
if (autoeqSampleRate) autoeqSampleRate.value = profile.sampleRate || 48000;
|
|
if (autoeqRunBtn) autoeqRunBtn.disabled = false;
|
|
if (autoeqCurrentBands) renderBandControls(autoeqCurrentBands);
|
|
requestAnimationFrame(drawAutoEQGraph);
|
|
}
|
|
}
|
|
|
|
// Restore parametric EQ active profile on startup
|
|
const activeParametricId = localStorage.getItem(PARAMETRIC_ACTIVE_KEY);
|
|
if (activeParametricId) {
|
|
const parametricProfiles = getParametricProfiles();
|
|
const paraProfile = parametricProfiles[activeParametricId];
|
|
if (paraProfile && paraProfile.bands) {
|
|
parametricBands = paraProfile.bands.map((b) => ({ ...b }));
|
|
}
|
|
}
|
|
|
|
// Restore speaker EQ active profile on startup
|
|
const activeSpeakerId = localStorage.getItem(SPEAKER_ACTIVE_PROFILE_KEY);
|
|
if (activeSpeakerId) {
|
|
const speakerProfiles = getSpeakerProfiles();
|
|
const spkProfile = speakerProfiles[activeSpeakerId];
|
|
if (spkProfile) {
|
|
if (spkProfile.config) {
|
|
speakerConfig = spkProfile.config;
|
|
if (speakerConfigSelect) speakerConfigSelect.value = speakerConfig;
|
|
}
|
|
if (spkProfile.channels) {
|
|
spkProfile.channels.forEach((saved) => {
|
|
if (speakerChannels[saved.id]) {
|
|
speakerChannels[saved.id].measurement = saved.measurement || null;
|
|
speakerChannels[saved.id].targetId = saved.targetId || 'harman_room';
|
|
speakerChannels[saved.id].preamp = saved.preamp || 0;
|
|
speakerChannels[saved.id].bands = saved.bands
|
|
? saved.bands.map((b) => ({ ...b }))
|
|
: speakerChannels[saved.id].bands;
|
|
}
|
|
});
|
|
}
|
|
speakerActiveChannel = SPEAKER_CONFIGS[speakerConfig][0];
|
|
}
|
|
}
|
|
|
|
// Restore EQ mode on startup
|
|
const savedEQMode = localStorage.getItem(EQ_MODE_KEY);
|
|
if (savedEQMode && ['autoeq', 'parametric', 'speaker'].includes(savedEQMode)) {
|
|
setEQMode(savedEQMode);
|
|
}
|
|
|
|
// Now Playing Mode
|
|
const nowPlayingMode = document.getElementById('now-playing-mode');
|
|
if (nowPlayingMode) {
|
|
nowPlayingMode.value = nowPlayingSettings.getMode();
|
|
nowPlayingMode.addEventListener('change', (e) => {
|
|
nowPlayingSettings.setMode(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Fullscreen Cover Click Action
|
|
const fullscreenCoverClickAction = document.getElementById('fullscreen-cover-click-action');
|
|
if (fullscreenCoverClickAction) {
|
|
fullscreenCoverClickAction.value = fullscreenCoverClickSettings.getAction();
|
|
fullscreenCoverClickAction.addEventListener('change', (e) => {
|
|
fullscreenCoverClickSettings.setAction(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Close Modals on Navigation Toggle
|
|
const closeModalsOnNavigationToggle = document.getElementById('close-modals-on-navigation-toggle');
|
|
if (closeModalsOnNavigationToggle) {
|
|
closeModalsOnNavigationToggle.checked = modalSettings.shouldCloseOnNavigation();
|
|
closeModalsOnNavigationToggle.addEventListener('change', (e) => {
|
|
modalSettings.setCloseOnNavigation(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Intercept Back to Close Modals Toggle
|
|
const interceptBackToCloseToggle = document.getElementById('intercept-back-to-close-modals-toggle');
|
|
if (interceptBackToCloseToggle) {
|
|
interceptBackToCloseToggle.checked = modalSettings.shouldInterceptBackToClose();
|
|
interceptBackToCloseToggle.addEventListener('change', (e) => {
|
|
modalSettings.setInterceptBackToClose(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Compact Artist Toggle
|
|
const compactArtistToggle = document.getElementById('compact-artist-toggle');
|
|
if (compactArtistToggle) {
|
|
compactArtistToggle.checked = cardSettings.isCompactArtist();
|
|
compactArtistToggle.addEventListener('change', (e) => {
|
|
cardSettings.setCompactArtist(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Compact Album Toggle
|
|
const compactAlbumToggle = document.getElementById('compact-album-toggle');
|
|
if (compactAlbumToggle) {
|
|
compactAlbumToggle.checked = cardSettings.isCompactAlbum();
|
|
compactAlbumToggle.addEventListener('change', (e) => {
|
|
cardSettings.setCompactAlbum(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Download Lyrics Toggle
|
|
const downloadLyricsToggle = document.getElementById('download-lyrics-toggle');
|
|
if (downloadLyricsToggle) {
|
|
downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
|
|
downloadLyricsToggle.addEventListener('change', (e) => {
|
|
lyricsSettings.setDownloadLyrics(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Romaji Lyrics Toggle
|
|
const romajiLyricsToggle = document.getElementById('romaji-lyrics-toggle');
|
|
if (romajiLyricsToggle) {
|
|
romajiLyricsToggle.checked = localStorage.getItem('lyricsRomajiMode') === 'true';
|
|
romajiLyricsToggle.addEventListener('change', (e) => {
|
|
localStorage.setItem('lyricsRomajiMode', e.target.checked ? 'true' : 'false');
|
|
});
|
|
}
|
|
|
|
// Album Background Toggle
|
|
const albumBackgroundToggle = document.getElementById('album-background-toggle');
|
|
if (albumBackgroundToggle) {
|
|
albumBackgroundToggle.checked = backgroundSettings.isEnabled();
|
|
albumBackgroundToggle.addEventListener('change', (e) => {
|
|
backgroundSettings.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Dynamic Color Toggle
|
|
const dynamicColorToggle = document.getElementById('dynamic-color-toggle');
|
|
if (dynamicColorToggle) {
|
|
dynamicColorToggle.checked = dynamicColorSettings.isEnabled();
|
|
dynamicColorToggle.addEventListener('change', (e) => {
|
|
dynamicColorSettings.setEnabled(e.target.checked);
|
|
if (!e.target.checked) {
|
|
// Reset colors immediately when disabled
|
|
window.dispatchEvent(new CustomEvent('reset-dynamic-color'));
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 } }));
|
|
});
|
|
}
|
|
|
|
// Visualizer Sensitivity
|
|
const visualizerSensitivitySlider = document.getElementById('visualizer-sensitivity-slider');
|
|
const visualizerSensitivityValue = document.getElementById('visualizer-sensitivity-value');
|
|
if (visualizerSensitivitySlider && visualizerSensitivityValue) {
|
|
const currentSensitivity = visualizerSettings.getSensitivity();
|
|
visualizerSensitivitySlider.value = currentSensitivity;
|
|
visualizerSensitivityValue.textContent = `${(currentSensitivity * 100).toFixed(0)}%`;
|
|
|
|
visualizerSensitivitySlider.addEventListener('input', (e) => {
|
|
const newSensitivity = parseFloat(e.target.value);
|
|
visualizerSettings.setSensitivity(newSensitivity);
|
|
visualizerSensitivityValue.textContent = `${(newSensitivity * 100).toFixed(0)}%`;
|
|
});
|
|
}
|
|
|
|
const visualizerDimmingSlider = document.getElementById('visualizer-dimming-slider');
|
|
const visualizerDimmingValue = document.getElementById('visualizer-dimming-value');
|
|
if (visualizerDimmingSlider && visualizerDimmingValue) {
|
|
const currentDimming = visualizerSettings.getDimAmount();
|
|
visualizerDimmingSlider.value = currentDimming;
|
|
visualizerDimmingValue.textContent = `${(currentDimming * 100).toFixed(0)}%`;
|
|
|
|
visualizerDimmingSlider.addEventListener('input', (e) => {
|
|
const newDimming = parseFloat(e.target.value);
|
|
visualizerSettings.setDimAmount(newDimming);
|
|
visualizerDimmingValue.textContent = `${(newDimming * 100).toFixed(0)}%`;
|
|
window.dispatchEvent(new CustomEvent('visualizer-dim-change', { detail: { dimAmount: newDimming } }));
|
|
});
|
|
}
|
|
|
|
// Visualizer Smart Intensity
|
|
const smartIntensityToggle = document.getElementById('smart-intensity-toggle');
|
|
if (smartIntensityToggle) {
|
|
const isSmart = visualizerSettings.isSmartIntensityEnabled();
|
|
smartIntensityToggle.checked = isSmart;
|
|
|
|
const updateSliderState = (enabled) => {
|
|
if (visualizerSensitivitySlider) {
|
|
visualizerSensitivitySlider.disabled = enabled;
|
|
visualizerSensitivitySlider.parentElement.style.opacity = enabled ? '0.5' : '1';
|
|
visualizerSensitivitySlider.parentElement.style.pointerEvents = enabled ? 'none' : 'auto';
|
|
}
|
|
};
|
|
updateSliderState(isSmart);
|
|
|
|
smartIntensityToggle.addEventListener('change', (e) => {
|
|
visualizerSettings.setSmartIntensity(e.target.checked);
|
|
updateSliderState(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Visualizer Enabled Toggle
|
|
const visualizerEnabledToggle = document.getElementById('visualizer-enabled-toggle');
|
|
const visualizerModeSetting = document.getElementById('visualizer-mode-setting');
|
|
const visualizerSmartIntensitySetting = document.getElementById('visualizer-smart-intensity-setting');
|
|
const visualizerSensitivitySetting = document.getElementById('visualizer-sensitivity-setting');
|
|
const visualizerPresetSetting = document.getElementById('visualizer-preset-setting');
|
|
const visualizerPresetSelect = document.getElementById('visualizer-preset-select');
|
|
|
|
// Butterchurn Settings Elements
|
|
const butterchurnCycleSetting = document.getElementById('butterchurn-cycle-setting');
|
|
const butterchurnDurationSetting = document.getElementById('butterchurn-duration-setting');
|
|
const butterchurnRandomizeSetting = document.getElementById('butterchurn-randomize-setting');
|
|
const butterchurnSpecificPresetSetting = document.getElementById('butterchurn-specific-preset-setting');
|
|
const butterchurnSpecificPresetSelect = document.getElementById('butterchurn-specific-preset-select');
|
|
const butterchurnCycleToggle = document.getElementById('butterchurn-cycle-toggle');
|
|
const butterchurnDurationInput = document.getElementById('butterchurn-duration-input');
|
|
const butterchurnRandomizeToggle = document.getElementById('butterchurn-randomize-toggle');
|
|
|
|
const updateButterchurnSettingsVisibility = async () => {
|
|
const isEnabled = visualizerEnabledToggle ? visualizerEnabledToggle.checked : false;
|
|
const isButterchurn = visualizerPresetSelect ? visualizerPresetSelect.value === 'butterchurn' : false;
|
|
const show = isEnabled && isButterchurn;
|
|
|
|
if (butterchurnCycleSetting) butterchurnCycleSetting.style.display = show ? 'flex' : 'none';
|
|
if (butterchurnSpecificPresetSetting) butterchurnSpecificPresetSetting.style.display = show ? 'flex' : 'none';
|
|
|
|
// Cycle duration and randomize only show if cycle is enabled
|
|
const isCycleEnabled = butterchurnCycleToggle ? butterchurnCycleToggle.checked : false;
|
|
const showSubSettings = show && isCycleEnabled;
|
|
|
|
if (butterchurnDurationSetting) butterchurnDurationSetting.style.display = showSubSettings ? 'flex' : 'none';
|
|
if (butterchurnRandomizeSetting) butterchurnRandomizeSetting.style.display = showSubSettings ? 'flex' : 'none';
|
|
|
|
// Populate preset list using module-level cache (works even before visualizer initializes)
|
|
const { keys: presetNames } = await getButterchurnPresets();
|
|
const select = butterchurnSpecificPresetSelect;
|
|
|
|
if (select && presetNames.length > 0) {
|
|
const currentNames = Array.from(select.options).map((opt) => opt.value);
|
|
// Check if dropdown only has "Loading..." or needs full update
|
|
const hasOnlyLoadingOption = currentNames.length === 1 && currentNames[0] === '';
|
|
const needsUpdate =
|
|
hasOnlyLoadingOption ||
|
|
currentNames.length !== presetNames.length ||
|
|
!presetNames.every((name) => currentNames.includes(name));
|
|
|
|
if (needsUpdate) {
|
|
// Save current selection
|
|
const currentSelection = select.value;
|
|
|
|
// Clear and rebuild dropdown
|
|
select.innerHTML = '';
|
|
presetNames.forEach((name) => {
|
|
const option = document.createElement('option');
|
|
option.value = name;
|
|
option.textContent = name;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Restore selection if it still exists
|
|
if (presetNames.includes(currentSelection)) {
|
|
select.value = currentSelection;
|
|
} else {
|
|
select.selectedIndex = 0;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateVisualizerSettingsVisibility = async (enabled) => {
|
|
const display = enabled ? 'flex' : 'none';
|
|
if (visualizerModeSetting) visualizerModeSetting.style.display = display;
|
|
if (visualizerSmartIntensitySetting) visualizerSmartIntensitySetting.style.display = display;
|
|
if (visualizerSensitivitySetting) visualizerSensitivitySetting.style.display = display;
|
|
if (visualizerPresetSetting) visualizerPresetSetting.style.display = display;
|
|
|
|
// Also update Butterchurn specific visibility
|
|
await updateButterchurnSettingsVisibility();
|
|
};
|
|
|
|
// Initialize preset select value early so visibility logic works correctly on load
|
|
if (visualizerPresetSelect) {
|
|
visualizerPresetSelect.value = visualizerSettings.getPreset();
|
|
}
|
|
|
|
if (visualizerEnabledToggle) {
|
|
visualizerEnabledToggle.checked = visualizerSettings.isEnabled();
|
|
|
|
await updateVisualizerSettingsVisibility(visualizerEnabledToggle.checked);
|
|
|
|
visualizerEnabledToggle.addEventListener('change', async (e) => {
|
|
visualizerSettings.setEnabled(e.target.checked);
|
|
await updateVisualizerSettingsVisibility(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Visualizer Preset Select
|
|
if (visualizerPresetSelect) {
|
|
// value set above
|
|
visualizerPresetSelect.addEventListener('change', async (e) => {
|
|
const val = e.target.value;
|
|
visualizerSettings.setPreset(val);
|
|
if (ui && ui.visualizer) {
|
|
ui.visualizer.setPreset(val);
|
|
}
|
|
await updateButterchurnSettingsVisibility();
|
|
|
|
//Since changing the preset breaks the visualizer, a location.reload() is added to make sure that it works
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
if (butterchurnCycleToggle) {
|
|
butterchurnCycleToggle.checked = visualizerSettings.isButterchurnCycleEnabled();
|
|
butterchurnCycleToggle.addEventListener('change', async (e) => {
|
|
visualizerSettings.setButterchurnCycleEnabled(e.target.checked);
|
|
await updateButterchurnSettingsVisibility();
|
|
});
|
|
}
|
|
|
|
if (butterchurnDurationInput) {
|
|
butterchurnDurationInput.value = visualizerSettings.getButterchurnCycleDuration();
|
|
butterchurnDurationInput.addEventListener('change', (e) => {
|
|
let val = parseInt(e.target.value, 10);
|
|
if (isNaN(val) || val < 5) val = 5;
|
|
if (val > 300) val = 300;
|
|
e.target.value = val;
|
|
visualizerSettings.setButterchurnCycleDuration(val);
|
|
});
|
|
}
|
|
|
|
if (butterchurnRandomizeToggle) {
|
|
butterchurnRandomizeToggle.checked = visualizerSettings.isButterchurnRandomizeEnabled();
|
|
butterchurnRandomizeToggle.addEventListener('change', (e) => {
|
|
visualizerSettings.setButterchurnRandomizeEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
if (butterchurnSpecificPresetSelect) {
|
|
butterchurnSpecificPresetSelect.addEventListener('change', (e) => {
|
|
// Try to load via visualizer if active, otherwise just store the selection
|
|
if (ui && ui.visualizer && ui.visualizer.presets['butterchurn']) {
|
|
ui.visualizer.presets['butterchurn'].loadPreset(e.target.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Refresh settings when presets are loaded asynchronously
|
|
window.addEventListener('butterchurn-presets-loaded', async () => {
|
|
console.log('[Settings] Butterchurn presets loaded event received');
|
|
await updateButterchurnSettingsVisibility();
|
|
});
|
|
|
|
// Check if presets already cached and update immediately
|
|
const { keys: cachedKeys } = await getButterchurnPresets();
|
|
if (cachedKeys.length > 0) {
|
|
console.log('[Settings] Presets already cached, updating dropdown immediately');
|
|
await updateButterchurnSettingsVisibility();
|
|
}
|
|
|
|
// Watch for appearance tab becoming active and refresh presets
|
|
const appearanceTabContent = document.getElementById('settings-tab-appearance');
|
|
if (appearanceTabContent) {
|
|
const observer = new MutationObserver(async (mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
|
if (appearanceTabContent.classList.contains('active')) {
|
|
console.log('[Settings] Appearance tab became active, refreshing presets');
|
|
await updateButterchurnSettingsVisibility();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
observer.observe(appearanceTabContent, { attributes: true });
|
|
}
|
|
|
|
// Watch for downloads tab becoming active and update setting visibility
|
|
const downloadsTabContent = document.getElementById('settings-tab-downloads');
|
|
if (downloadsTabContent) {
|
|
const observer = new MutationObserver(async (mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
|
if (downloadsTabContent.classList.contains('active')) {
|
|
console.log('[Settings] Downloads tab became active, updating setting visibility');
|
|
updateForceZipBlobVisibility();
|
|
await updateFolderMethodVisibility();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
observer.observe(downloadsTabContent, { attributes: true });
|
|
}
|
|
|
|
// Visualizer Mode Select
|
|
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
|
|
if (visualizerModeSelect) {
|
|
visualizerModeSelect.value = visualizerSettings.getMode();
|
|
visualizerModeSelect.addEventListener('change', (e) => {
|
|
visualizerSettings.setMode(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Home Page Section Toggles
|
|
const showRecommendedSongsToggle = document.getElementById('show-recommended-songs-toggle');
|
|
if (showRecommendedSongsToggle) {
|
|
showRecommendedSongsToggle.checked = homePageSettings.shouldShowRecommendedSongs();
|
|
showRecommendedSongsToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShowRecommendedSongs(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const showRecommendedAlbumsToggle = document.getElementById('show-recommended-albums-toggle');
|
|
if (showRecommendedAlbumsToggle) {
|
|
showRecommendedAlbumsToggle.checked = homePageSettings.shouldShowRecommendedAlbums();
|
|
showRecommendedAlbumsToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShowRecommendedAlbums(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const showRecommendedArtistsToggle = document.getElementById('show-recommended-artists-toggle');
|
|
if (showRecommendedArtistsToggle) {
|
|
showRecommendedArtistsToggle.checked = homePageSettings.shouldShowRecommendedArtists();
|
|
showRecommendedArtistsToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShowRecommendedArtists(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const showJumpBackInToggle = document.getElementById('show-jump-back-in-toggle');
|
|
if (showJumpBackInToggle) {
|
|
showJumpBackInToggle.checked = homePageSettings.shouldShowJumpBackIn();
|
|
showJumpBackInToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShowJumpBackIn(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const showEditorsPicksToggle = document.getElementById('show-editors-picks-toggle');
|
|
if (showEditorsPicksToggle) {
|
|
showEditorsPicksToggle.checked = homePageSettings.shouldShowEditorsPicks();
|
|
showEditorsPicksToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShowEditorsPicks(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const shuffleEditorsPicksToggle = document.getElementById('shuffle-editors-picks-toggle');
|
|
if (shuffleEditorsPicksToggle) {
|
|
shuffleEditorsPicksToggle.checked = homePageSettings.shouldShuffleEditorsPicks();
|
|
shuffleEditorsPicksToggle.addEventListener('change', (e) => {
|
|
homePageSettings.setShuffleEditorsPicks(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const editorsPicksSourceSelect = document.getElementById('editors-picks-source-select');
|
|
if (editorsPicksSourceSelect) {
|
|
async function populateEditorsPicksSource() {
|
|
try {
|
|
const response = await fetch('/editors-picks-old/index.json');
|
|
if (response.ok) {
|
|
const oldPicks = await response.json();
|
|
oldPicks.forEach((pick) => {
|
|
const option = document.createElement('option');
|
|
option.value = pick.file;
|
|
option.textContent = pick.label;
|
|
editorsPicksSourceSelect.appendChild(option);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load editors-picks-old index:', e);
|
|
}
|
|
const currentSource = homePageSettings.getEditorsPicksSource();
|
|
editorsPicksSourceSelect.value = currentSource;
|
|
}
|
|
populateEditorsPicksSource();
|
|
|
|
editorsPicksSourceSelect.addEventListener('change', (e) => {
|
|
homePageSettings.setEditorsPicksSource(e.target.value);
|
|
window.dispatchEvent(new CustomEvent('refresh-home-editors-picks'));
|
|
});
|
|
}
|
|
|
|
// Sidebar Section Toggles
|
|
const sidebarShowHomeToggle = document.getElementById('sidebar-show-home-toggle');
|
|
if (sidebarShowHomeToggle) {
|
|
sidebarShowHomeToggle.checked = sidebarSectionSettings.shouldShowHome();
|
|
sidebarShowHomeToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowHome(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowLibraryToggle = document.getElementById('sidebar-show-library-toggle');
|
|
if (sidebarShowLibraryToggle) {
|
|
sidebarShowLibraryToggle.checked = sidebarSectionSettings.shouldShowLibrary();
|
|
sidebarShowLibraryToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowLibrary(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowRecentToggle = document.getElementById('sidebar-show-recent-toggle');
|
|
if (sidebarShowRecentToggle) {
|
|
sidebarShowRecentToggle.checked = sidebarSectionSettings.shouldShowRecent();
|
|
sidebarShowRecentToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowRecent(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowUnreleasedToggle = document.getElementById('sidebar-show-unreleased-toggle');
|
|
if (sidebarShowUnreleasedToggle) {
|
|
sidebarShowUnreleasedToggle.checked = sidebarSectionSettings.shouldShowUnreleased();
|
|
sidebarShowUnreleasedToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowUnreleased(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowDonateToggle = document.getElementById('sidebar-show-donate-toggle');
|
|
if (sidebarShowDonateToggle) {
|
|
sidebarShowDonateToggle.checked = sidebarSectionSettings.shouldShowDonate();
|
|
sidebarShowDonateToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowDonate(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowSettingsToggle = document.getElementById('sidebar-show-settings-toggle');
|
|
if (sidebarShowSettingsToggle) {
|
|
sidebarShowSettingsToggle.checked = true;
|
|
sidebarShowSettingsToggle.disabled = true;
|
|
sidebarSectionSettings.setShowSettings(true);
|
|
}
|
|
|
|
const sidebarShowAboutToggle = document.getElementById('sidebar-show-about-bottom-toggle');
|
|
if (sidebarShowAboutToggle) {
|
|
sidebarShowAboutToggle.checked = sidebarSectionSettings.shouldShowAbout();
|
|
sidebarShowAboutToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowAbout(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowDiscordToggle = document.getElementById('sidebar-show-discordbtn-toggle');
|
|
if (sidebarShowDiscordToggle) {
|
|
sidebarShowDiscordToggle.checked = sidebarSectionSettings.shouldShowDiscord();
|
|
sidebarShowDiscordToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowDiscord(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
const sidebarShowGithubToggle = document.getElementById('sidebar-show-githubbtn-toggle');
|
|
if (sidebarShowGithubToggle) {
|
|
sidebarShowGithubToggle.checked = sidebarSectionSettings.shouldShowGithub();
|
|
sidebarShowGithubToggle.addEventListener('change', (e) => {
|
|
sidebarSectionSettings.setShowGithub(e.target.checked);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
});
|
|
}
|
|
|
|
// Apply sidebar visibility on initialization
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
|
|
const sidebarSettingsGroup = sidebarShowHomeToggle?.closest('.settings-group');
|
|
if (sidebarSettingsGroup) {
|
|
const toggleIdFromSidebarId = (sidebarId) =>
|
|
sidebarId ? sidebarId.replace('sidebar-nav-', 'sidebar-show-') + '-toggle' : '';
|
|
|
|
const sidebarOrderConfig = sidebarSectionSettings.DEFAULT_ORDER.map((sidebarId) => ({
|
|
sidebarId,
|
|
toggleId: toggleIdFromSidebarId(sidebarId),
|
|
}));
|
|
|
|
sidebarOrderConfig.forEach(({ toggleId, sidebarId }) => {
|
|
const toggle = document.getElementById(toggleId);
|
|
const item = toggle?.closest('.setting-item');
|
|
if (!item) return;
|
|
item.dataset.sidebarId = sidebarId;
|
|
item.classList.add('sidebar-setting-item');
|
|
item.draggable = true;
|
|
});
|
|
|
|
const mainContainer = sidebarSettingsGroup.querySelector('.sidebar-settings-main');
|
|
const bottomContainer = sidebarSettingsGroup.querySelector('.sidebar-settings-bottom');
|
|
|
|
const getSidebarItems = () => [
|
|
...(mainContainer?.querySelectorAll('.sidebar-setting-item[data-sidebar-id]') ?? []),
|
|
...(bottomContainer?.querySelectorAll('.sidebar-setting-item[data-sidebar-id]') ?? []),
|
|
];
|
|
|
|
const applySidebarSettingsOrder = () => {
|
|
const order = sidebarSectionSettings.getOrder();
|
|
const bottomIds = sidebarSectionSettings.getBottomNavIds();
|
|
const mainOrder = order.filter((id) => !bottomIds.includes(id));
|
|
const bottomOrder = order.filter((id) => bottomIds.includes(id));
|
|
const allItems = getSidebarItems();
|
|
const itemMap = new Map(allItems.map((item) => [item.dataset.sidebarId, item]));
|
|
|
|
mainOrder.forEach((id) => {
|
|
const item = itemMap.get(id);
|
|
if (item && mainContainer) mainContainer.appendChild(item);
|
|
});
|
|
bottomOrder.forEach((id) => {
|
|
const item = itemMap.get(id);
|
|
if (item && bottomContainer) bottomContainer.appendChild(item);
|
|
});
|
|
};
|
|
|
|
applySidebarSettingsOrder();
|
|
|
|
let draggedItem = null;
|
|
|
|
const saveSidebarOrder = () => {
|
|
const order = getSidebarItems().map((item) => item.dataset.sidebarId);
|
|
sidebarSectionSettings.setOrder(order);
|
|
sidebarSectionSettings.applySidebarVisibility();
|
|
};
|
|
|
|
const handleDragStart = (e) => {
|
|
const item = e.target.closest('.sidebar-setting-item');
|
|
if (!item) return;
|
|
draggedItem = item;
|
|
draggedItem.classList.add('dragging');
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', item.dataset.sidebarId || '');
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
if (!draggedItem) return;
|
|
draggedItem.classList.remove('dragging');
|
|
draggedItem = null;
|
|
saveSidebarOrder();
|
|
};
|
|
|
|
const getDragAfterElement = (elements, y) => {
|
|
const draggableElements = elements.filter((el) => el !== draggedItem);
|
|
return draggableElements.reduce(
|
|
(closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset, element: child };
|
|
}
|
|
return closest;
|
|
},
|
|
{ offset: Number.NEGATIVE_INFINITY }
|
|
).element;
|
|
};
|
|
|
|
const handleDragOver = (e) => {
|
|
e.preventDefault();
|
|
if (!draggedItem) return;
|
|
const container = draggedItem.parentElement;
|
|
if (container !== mainContainer && container !== bottomContainer) return;
|
|
const sectionItems = Array.from(container.querySelectorAll('.sidebar-setting-item[data-sidebar-id]'));
|
|
const afterElement = getDragAfterElement(sectionItems, e.clientY);
|
|
if (afterElement === draggedItem) return;
|
|
if (afterElement) {
|
|
container.insertBefore(draggedItem, afterElement);
|
|
} else {
|
|
container.appendChild(draggedItem);
|
|
}
|
|
};
|
|
|
|
sidebarSettingsGroup.addEventListener('dragstart', handleDragStart);
|
|
sidebarSettingsGroup.addEventListener('dragend', handleDragEnd);
|
|
sidebarSettingsGroup.addEventListener('dragover', handleDragOver);
|
|
sidebarSettingsGroup.addEventListener('drop', (e) => e.preventDefault());
|
|
}
|
|
|
|
// Filename template setting
|
|
const filenameTemplate = document.getElementById('filename-template');
|
|
if (filenameTemplate) {
|
|
filenameTemplate.value = modernSettings.filenameTemplate;
|
|
filenameTemplate.addEventListener('change', (e) => {
|
|
modernSettings.filenameTemplate = String(e.target.value);
|
|
});
|
|
}
|
|
|
|
// ZIP folder template
|
|
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
|
if (zipFolderTemplate) {
|
|
zipFolderTemplate.value = modernSettings.folderTemplate;
|
|
zipFolderTemplate.addEventListener('change', (e) => {
|
|
modernSettings.folderTemplate = String(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Playlist file generation settings
|
|
const generateM3UToggle = document.getElementById('generate-m3u-toggle');
|
|
if (generateM3UToggle) {
|
|
generateM3UToggle.checked = playlistSettings.shouldGenerateM3U();
|
|
generateM3UToggle.addEventListener('change', (e) => {
|
|
playlistSettings.setGenerateM3U(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const generateM3U8Toggle = document.getElementById('generate-m3u8-toggle');
|
|
if (generateM3U8Toggle) {
|
|
generateM3U8Toggle.checked = playlistSettings.shouldGenerateM3U8();
|
|
generateM3U8Toggle.addEventListener('change', (e) => {
|
|
playlistSettings.setGenerateM3U8(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const generateCUEtoggle = document.getElementById('generate-cue-toggle');
|
|
if (generateCUEtoggle) {
|
|
generateCUEtoggle.checked = playlistSettings.shouldGenerateCUE();
|
|
generateCUEtoggle.addEventListener('change', (e) => {
|
|
playlistSettings.setGenerateCUE(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const generateNFOtoggle = document.getElementById('generate-nfo-toggle');
|
|
if (generateNFOtoggle) {
|
|
generateNFOtoggle.checked = playlistSettings.shouldGenerateNFO();
|
|
generateNFOtoggle.addEventListener('change', (e) => {
|
|
playlistSettings.setGenerateNFO(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const generateJSONtoggle = document.getElementById('generate-json-toggle');
|
|
if (generateJSONtoggle) {
|
|
generateJSONtoggle.checked = playlistSettings.shouldGenerateJSON();
|
|
generateJSONtoggle.addEventListener('change', (e) => {
|
|
playlistSettings.setGenerateJSON(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const relativePathsToggle = document.getElementById('relative-paths-toggle');
|
|
if (relativePathsToggle) {
|
|
relativePathsToggle.checked = playlistSettings.shouldUseRelativePaths();
|
|
relativePathsToggle.addEventListener('change', (e) => {
|
|
playlistSettings.setUseRelativePaths(e.target.checked);
|
|
});
|
|
}
|
|
|
|
const separateDiscsZipToggle = document.getElementById('separate-discs-zip-toggle');
|
|
if (separateDiscsZipToggle) {
|
|
separateDiscsZipToggle.checked = playlistSettings.shouldSeparateDiscsInZip();
|
|
separateDiscsZipToggle.addEventListener('change', (e) => {
|
|
playlistSettings.setSeparateDiscsInZip(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// API settings
|
|
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('refresh-speed-test-btn');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Testing...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
await api.settings.refreshInstances();
|
|
ui.renderApiSettings();
|
|
btn.textContent = 'Done!';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error('Failed to refresh speed tests:', error);
|
|
btn.textContent = 'Error';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}, 1500);
|
|
}
|
|
});
|
|
|
|
document.getElementById('api-instance-list')?.addEventListener('click', async (e) => {
|
|
const button = e.target.closest('button');
|
|
if (!button) return;
|
|
|
|
const li = button.closest('li');
|
|
const type = button.dataset.type || li?.dataset.type || 'api';
|
|
|
|
if (button.classList.contains('add-instance')) {
|
|
const url = prompt(`Enter custom ${type.toUpperCase()} instance URL (e.g. https://my-instance.com):`);
|
|
if (url && url.trim()) {
|
|
let formattedUrl = url.trim();
|
|
if (!formattedUrl.startsWith('http')) {
|
|
formattedUrl = 'https://' + formattedUrl;
|
|
}
|
|
api.settings.addUserInstance(type, formattedUrl);
|
|
ui.renderApiSettings();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (button.classList.contains('delete-instance')) {
|
|
const url = li.dataset.url;
|
|
if (url && confirm(`Delete custom instance ${url}?`)) {
|
|
api.settings.removeUserInstance(type, url);
|
|
ui.renderApiSettings();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const index = parseInt(li?.dataset.index, 10);
|
|
if (isNaN(index)) return;
|
|
|
|
const instances = await api.settings.getInstances(type);
|
|
|
|
if (button.classList.contains('move-up') && index > 0) {
|
|
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
|
|
} else if (button.classList.contains('move-down') && index < instances.length - 1) {
|
|
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
|
|
}
|
|
|
|
api.settings.saveInstances(instances, type);
|
|
ui.renderApiSettings();
|
|
});
|
|
|
|
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('clear-cache-btn');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Clearing...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
await api.clearCache();
|
|
btn.textContent = 'Cleared!';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
if (window.location.hash.includes('settings')) {
|
|
ui.renderApiSettings();
|
|
}
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error('Failed to clear cache:', error);
|
|
btn.textContent = 'Error';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}, 1500);
|
|
}
|
|
});
|
|
|
|
document.getElementById('auth-clear-cloud-btn')?.addEventListener('click', async () => {
|
|
if (confirm('Are you sure you want to delete ALL your data from the cloud? This cannot be undone.')) {
|
|
try {
|
|
await syncManager.clearCloudData();
|
|
alert('Cloud data cleared successfully.');
|
|
authManager.signOut();
|
|
} catch (error) {
|
|
console.error('Failed to clear cloud data:', error);
|
|
alert('Failed to clear cloud data: ' + error.message);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Backup & Restore
|
|
document.getElementById('export-library-btn')?.addEventListener('click', async () => {
|
|
const data = await db.exportData();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
type: 'application/json',
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `monochrome-library-${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
const importInput = document.getElementById('import-library-input');
|
|
document.getElementById('import-library-btn')?.addEventListener('click', () => {
|
|
importInput.click();
|
|
});
|
|
|
|
importInput?.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
try {
|
|
const data = JSON.parse(event.target.result);
|
|
await db.importData(data);
|
|
alert('Library imported successfully!');
|
|
window.location.reload(); // Simple way to refresh all state
|
|
} catch (err) {
|
|
console.error('Import failed:', err);
|
|
alert('Failed to import library. Please check the file format.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
// Export All Settings
|
|
document.getElementById('export-settings-btn')?.addEventListener('click', () => {
|
|
const settingsToExport = {};
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith('monochrome-')) {
|
|
try {
|
|
settingsToExport[key] = JSON.parse(localStorage.getItem(key));
|
|
} catch {
|
|
settingsToExport[key] = localStorage.getItem(key);
|
|
}
|
|
}
|
|
}
|
|
const blob = new Blob([JSON.stringify(settingsToExport, null, 2)], {
|
|
type: 'application/json',
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `monochrome-settings-${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// Import All Settings
|
|
const settingsImportInput = document.getElementById('import-settings-input');
|
|
document.getElementById('import-settings-btn')?.addEventListener('click', () => {
|
|
settingsImportInput.click();
|
|
});
|
|
|
|
settingsImportInput?.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
try {
|
|
const settingsToImport = JSON.parse(event.target.result);
|
|
for (const [key, value] of Object.entries(settingsToImport)) {
|
|
if (key.startsWith('monochrome-')) {
|
|
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
}
|
|
}
|
|
alert('Settings imported successfully! Please reload the app.');
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error('Import failed:', err);
|
|
alert('Failed to import settings. Please check the file format.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
const customDbBtn = document.getElementById('custom-db-btn');
|
|
const customDbModal = document.getElementById('custom-db-modal');
|
|
const customPbUrlInput = document.getElementById('custom-pb-url');
|
|
const customAppwriteEndpointInput = document.getElementById('custom-appwrite-endpoint');
|
|
const customAppwriteProjectInput = document.getElementById('custom-appwrite-project');
|
|
const customDbSaveBtn = document.getElementById('custom-db-save');
|
|
const customDbResetBtn = document.getElementById('custom-db-reset');
|
|
const customDbCancelBtn = document.getElementById('custom-db-cancel');
|
|
|
|
if (customDbBtn && customDbModal) {
|
|
const appwriteFromEnv = !!(window.__APPWRITE_ENDPOINT__ || window.__APPWRITE_PROJECT_ID__);
|
|
const pbFromEnv = !!window.__POCKETBASE_URL__;
|
|
|
|
// Hide entire setting if both are server-configured
|
|
if (appwriteFromEnv && pbFromEnv) {
|
|
const settingItem = customDbBtn.closest('.setting-item');
|
|
if (settingItem) settingItem.style.display = 'none';
|
|
}
|
|
|
|
// Hide individual fields in the modal
|
|
if (pbFromEnv && customPbUrlInput) customPbUrlInput.closest('div[style]').style.display = 'none';
|
|
if (appwriteFromEnv) {
|
|
if (customAppwriteEndpointInput) customAppwriteEndpointInput.closest('div[style]').style.display = 'none';
|
|
if (customAppwriteProjectInput) customAppwriteProjectInput.closest('div[style]').style.display = 'none';
|
|
}
|
|
|
|
customDbBtn.addEventListener('click', () => {
|
|
const pbUrl = localStorage.getItem('monochrome-pocketbase-url') || '';
|
|
const appwriteEndpoint = localStorage.getItem('monochrome-appwrite-endpoint') || '';
|
|
const appwriteProject = localStorage.getItem('monochrome-appwrite-project') || '';
|
|
|
|
if (!pbFromEnv && customPbUrlInput) customPbUrlInput.value = pbUrl;
|
|
if (!appwriteFromEnv) {
|
|
if (customAppwriteEndpointInput) customAppwriteEndpointInput.value = appwriteEndpoint;
|
|
if (customAppwriteProjectInput) customAppwriteProjectInput.value = appwriteProject;
|
|
}
|
|
|
|
customDbModal.classList.add('active');
|
|
});
|
|
|
|
const closeCustomDbModal = () => {
|
|
customDbModal.classList.remove('active');
|
|
};
|
|
|
|
customDbCancelBtn.addEventListener('click', closeCustomDbModal);
|
|
customDbModal.querySelector('.modal-overlay').addEventListener('click', closeCustomDbModal);
|
|
|
|
customDbSaveBtn.addEventListener('click', () => {
|
|
if (!pbFromEnv && customPbUrlInput) {
|
|
const pbUrl = customPbUrlInput.value.trim();
|
|
if (pbUrl) {
|
|
localStorage.setItem('monochrome-pocketbase-url', pbUrl);
|
|
} else {
|
|
localStorage.removeItem('monochrome-pocketbase-url');
|
|
}
|
|
}
|
|
|
|
if (!appwriteFromEnv) {
|
|
const endpoint = customAppwriteEndpointInput?.value.trim();
|
|
const project = customAppwriteProjectInput?.value.trim();
|
|
|
|
if (endpoint) {
|
|
localStorage.setItem('monochrome-appwrite-endpoint', endpoint);
|
|
} else {
|
|
localStorage.removeItem('monochrome-appwrite-endpoint');
|
|
}
|
|
|
|
if (project) {
|
|
localStorage.setItem('monochrome-appwrite-project', project);
|
|
} else {
|
|
localStorage.removeItem('monochrome-appwrite-project');
|
|
}
|
|
}
|
|
|
|
alert('Settings saved. Reloading...');
|
|
window.location.reload();
|
|
});
|
|
|
|
customDbResetBtn.addEventListener('click', () => {
|
|
if (confirm('Reset custom database settings to default?')) {
|
|
localStorage.removeItem('monochrome-pocketbase-url');
|
|
localStorage.removeItem('monochrome-appwrite-endpoint');
|
|
localStorage.removeItem('monochrome-appwrite-project');
|
|
alert('Settings reset. Reloading...');
|
|
window.location.reload();
|
|
}
|
|
});
|
|
}
|
|
|
|
// PWA Auto-Update Toggle
|
|
const pwaAutoUpdateToggle = document.getElementById('pwa-auto-update-toggle');
|
|
if (pwaAutoUpdateToggle) {
|
|
pwaAutoUpdateToggle.checked = pwaUpdateSettings.isAutoUpdateEnabled();
|
|
pwaAutoUpdateToggle.addEventListener('change', (e) => {
|
|
pwaUpdateSettings.setAutoUpdateEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Analytics Toggle
|
|
const analyticsToggle = document.getElementById('analytics-toggle');
|
|
if (analyticsToggle) {
|
|
analyticsToggle.checked = analyticsSettings.isEnabled();
|
|
analyticsToggle.addEventListener('change', (e) => {
|
|
analyticsSettings.setEnabled(e.target.checked);
|
|
});
|
|
}
|
|
|
|
// Reset Local Data Button
|
|
const resetLocalDataBtn = document.getElementById('reset-local-data-btn');
|
|
if (resetLocalDataBtn) {
|
|
resetLocalDataBtn.addEventListener('click', async () => {
|
|
if (
|
|
confirm(
|
|
'WARNING: This will clear all local data including settings, cache, and library.\n\nAre you sure you want to continue?\n\n(Cloud-synced data will not be affected)'
|
|
)
|
|
) {
|
|
try {
|
|
// Clear all localStorage
|
|
const keysToPreserve = [];
|
|
// Optionally preserve certain keys if needed
|
|
|
|
// Get all keys
|
|
const allKeys = Object.keys(localStorage);
|
|
|
|
// Clear each key except preserved ones
|
|
allKeys.forEach((key) => {
|
|
if (!keysToPreserve.includes(key)) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
});
|
|
|
|
// Clear IndexedDB - try to clear individual stores, fallback to deleting database
|
|
try {
|
|
const stores = [
|
|
'favorites_tracks',
|
|
'favorites_videos',
|
|
'favorites_albums',
|
|
'favorites_artists',
|
|
'favorites_playlists',
|
|
'favorites_mixes',
|
|
'history_tracks',
|
|
'user_playlists',
|
|
'user_folders',
|
|
'settings',
|
|
'pinned_items',
|
|
];
|
|
|
|
for (const storeName of stores) {
|
|
try {
|
|
await db.performTransaction(storeName, 'readwrite', (store) => store.clear());
|
|
} catch {
|
|
// Store might not exist, continue
|
|
}
|
|
}
|
|
} catch (dbError) {
|
|
console.log('Could not clear IndexedDB stores:', dbError);
|
|
// Try to delete the entire database as fallback
|
|
try {
|
|
const deleteRequest = indexedDB.deleteDatabase('MonochromeDB');
|
|
await new Promise((resolve, reject) => {
|
|
deleteRequest.onsuccess = resolve;
|
|
deleteRequest.onerror = reject;
|
|
});
|
|
} catch (deleteError) {
|
|
console.log('Could not delete IndexedDB:', deleteError);
|
|
}
|
|
}
|
|
|
|
alert('All local data has been cleared. The app will now reload.');
|
|
window.location.reload();
|
|
} catch (error) {
|
|
console.error('Failed to reset local data:', error);
|
|
alert('Failed to reset local data: ' + error.message);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Font Settings
|
|
initializeFontSettings();
|
|
|
|
// Settings Search functionality
|
|
setupSettingsSearch();
|
|
|
|
// Blocked Content Management
|
|
initializeBlockedContentManager();
|
|
}
|
|
|
|
function initializeFontSettings() {
|
|
const fontTypeSelect = document.getElementById('font-type-select');
|
|
const fontPresetSection = document.getElementById('font-preset-section');
|
|
const fontGoogleSection = document.getElementById('font-google-section');
|
|
const fontUrlSection = document.getElementById('font-url-section');
|
|
const fontUploadSection = document.getElementById('font-upload-section');
|
|
const fontPresetSelect = document.getElementById('font-preset-select');
|
|
const fontGoogleInput = document.getElementById('font-google-input');
|
|
const fontGoogleApply = document.getElementById('font-google-apply');
|
|
const fontUrlInput = document.getElementById('font-url-input');
|
|
const fontUrlName = document.getElementById('font-url-name');
|
|
const fontUrlApply = document.getElementById('font-url-apply');
|
|
const fontUploadInput = document.getElementById('font-upload-input');
|
|
const uploadedFontsList = document.getElementById('uploaded-fonts-list');
|
|
|
|
if (!fontTypeSelect) return;
|
|
|
|
// Load current font config
|
|
const config = fontSettings.getConfig();
|
|
|
|
// Show correct section based on type
|
|
function showFontSection(type) {
|
|
fontPresetSection.style.display = type === 'preset' ? 'block' : 'none';
|
|
fontGoogleSection.style.display = type === 'google' ? 'flex' : 'none';
|
|
fontUrlSection.style.display = type === 'url' ? 'flex' : 'none';
|
|
fontUploadSection.style.display = type === 'upload' ? 'block' : 'none';
|
|
}
|
|
|
|
// Initialize UI state
|
|
fontTypeSelect.value = config.type;
|
|
showFontSection(config.type);
|
|
|
|
if (config.type === 'preset') {
|
|
fontPresetSelect.value = config.family;
|
|
} else if (config.type === 'google') {
|
|
fontGoogleInput.value = config.family || '';
|
|
} else if (config.type === 'url') {
|
|
fontUrlInput.value = config.url || '';
|
|
fontUrlName.value = config.family || '';
|
|
}
|
|
|
|
// Type selector change
|
|
fontTypeSelect.addEventListener('change', (e) => {
|
|
showFontSection(e.target.value);
|
|
});
|
|
|
|
// Preset font change
|
|
fontPresetSelect.addEventListener('change', (e) => {
|
|
const value = e.target.value;
|
|
if (value === 'System UI') {
|
|
fontSettings.loadPresetFont(
|
|
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue'",
|
|
'sans-serif'
|
|
);
|
|
} else if (value === 'monospace') {
|
|
fontSettings.loadPresetFont('monospace', 'monospace');
|
|
} else if (value === 'Apple Music') {
|
|
fontSettings.loadAppleMusicFont();
|
|
} else {
|
|
fontSettings.loadPresetFont(value, 'sans-serif');
|
|
}
|
|
});
|
|
|
|
// Google Fonts apply
|
|
fontGoogleApply.addEventListener('click', () => {
|
|
const input = fontGoogleInput.value.trim();
|
|
if (!input) return;
|
|
|
|
let fontName = input;
|
|
|
|
// Check if it's a Google Fonts URL
|
|
try {
|
|
const urlObj = new URL(input);
|
|
if (urlObj.hostname === 'fonts.google.com') {
|
|
const parsed = fontSettings.parseGoogleFontsUrl(input);
|
|
if (parsed) {
|
|
fontName = parsed;
|
|
}
|
|
}
|
|
} catch {
|
|
// Not a URL, treat as font name
|
|
}
|
|
|
|
fontSettings.loadGoogleFont(fontName);
|
|
});
|
|
|
|
// URL font apply
|
|
fontUrlApply.addEventListener('click', () => {
|
|
const url = fontUrlInput.value.trim();
|
|
const name = fontUrlName.value.trim();
|
|
if (!url) return;
|
|
|
|
fontSettings.loadFontFromUrl(url, name || 'CustomFont');
|
|
});
|
|
|
|
// File upload
|
|
fontUploadInput.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const font = await fontSettings.saveUploadedFont(file);
|
|
await fontSettings.loadUploadedFont(font.id);
|
|
renderUploadedFontsList();
|
|
fontUploadInput.value = '';
|
|
} catch (err) {
|
|
console.error('Failed to upload font:', err);
|
|
alert('Failed to upload font');
|
|
}
|
|
});
|
|
|
|
// Render uploaded fonts list
|
|
function renderUploadedFontsList() {
|
|
const fonts = fontSettings.getUploadedFontList();
|
|
uploadedFontsList.innerHTML = '';
|
|
|
|
fonts.forEach((font) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'uploaded-font-item';
|
|
item.innerHTML = `
|
|
<span class="font-name">${font.name}</span>
|
|
<div class="font-actions">
|
|
<button class="btn-icon" data-id="${font.id}" data-action="use">Use</button>
|
|
<button class="btn-icon btn-delete" data-id="${font.id}" data-action="delete">Delete</button>
|
|
</div>
|
|
`;
|
|
uploadedFontsList.appendChild(item);
|
|
});
|
|
|
|
// Add event listeners for buttons
|
|
uploadedFontsList.querySelectorAll('.btn-icon').forEach((btn) => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const fontId = e.target.dataset.id;
|
|
const action = e.target.dataset.action;
|
|
|
|
if (action === 'use') {
|
|
await fontSettings.loadUploadedFont(fontId);
|
|
fontTypeSelect.value = 'upload';
|
|
showFontSection('upload');
|
|
} else if (action === 'delete') {
|
|
if (confirm('Delete this font?')) {
|
|
fontSettings.deleteUploadedFont(fontId);
|
|
renderUploadedFontsList();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderUploadedFontsList();
|
|
|
|
// Font Size Controls
|
|
const fontSizeSlider = document.getElementById('font-size-slider');
|
|
const fontSizeInput = document.getElementById('font-size-input');
|
|
const fontSizeReset = document.getElementById('font-size-reset');
|
|
|
|
// Helper function to update both controls
|
|
const updateFontSizeControls = (size) => {
|
|
const validSize = Math.max(50, Math.min(200, parseInt(size, 10) || 100));
|
|
if (fontSizeSlider) fontSizeSlider.value = validSize;
|
|
if (fontSizeInput) fontSizeInput.value = validSize;
|
|
return validSize;
|
|
};
|
|
|
|
// Initialize with saved value
|
|
const savedSize = fontSettings.getFontSize();
|
|
updateFontSizeControls(savedSize);
|
|
|
|
// Slider change handler
|
|
if (fontSizeSlider) {
|
|
fontSizeSlider.addEventListener('input', () => {
|
|
const size = parseInt(fontSizeSlider.value, 10);
|
|
if (fontSizeInput) fontSizeInput.value = size;
|
|
fontSettings.setFontSize(size);
|
|
});
|
|
}
|
|
|
|
// Number input change handler
|
|
if (fontSizeInput) {
|
|
fontSizeInput.addEventListener('change', () => {
|
|
let size = parseInt(fontSizeInput.value, 10);
|
|
// Clamp to valid range
|
|
size = Math.max(50, Math.min(200, size || 100));
|
|
updateFontSizeControls(size);
|
|
fontSettings.setFontSize(size);
|
|
});
|
|
|
|
// Also update on input for real-time feedback
|
|
fontSizeInput.addEventListener('input', () => {
|
|
let size = parseInt(fontSizeInput.value, 10);
|
|
if (!isNaN(size) && size >= 50 && size <= 200) {
|
|
if (fontSizeSlider) fontSizeSlider.value = size;
|
|
fontSettings.setFontSize(size);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (fontSizeReset) {
|
|
fontSizeReset.addEventListener('click', () => {
|
|
const defaultSize = fontSettings.resetFontSize();
|
|
updateFontSizeControls(defaultSize);
|
|
});
|
|
}
|
|
}
|
|
|
|
function setupSettingsSearch() {
|
|
const searchInput = document.getElementById('settings-search-input');
|
|
if (!searchInput) return;
|
|
|
|
// Setup clear button
|
|
const clearBtn = searchInput.parentElement.querySelector('.search-clear-btn');
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
searchInput.dispatchEvent(new Event('input'));
|
|
searchInput.focus();
|
|
});
|
|
}
|
|
|
|
// Show/hide clear button based on input
|
|
const updateClearButton = () => {
|
|
if (clearBtn) {
|
|
clearBtn.style.display = searchInput.value ? 'flex' : 'none';
|
|
}
|
|
};
|
|
|
|
searchInput.addEventListener('input', () => {
|
|
updateClearButton();
|
|
filterSettings(searchInput.value.toLowerCase().trim());
|
|
});
|
|
|
|
searchInput.addEventListener('focus', updateClearButton);
|
|
}
|
|
|
|
function filterSettings(query) {
|
|
const settingsPage = document.getElementById('page-settings');
|
|
if (!settingsPage) return;
|
|
|
|
const allTabContents = settingsPage.querySelectorAll('.settings-tab-content');
|
|
const allTabs = settingsPage.querySelectorAll('.settings-tab');
|
|
|
|
if (!query) {
|
|
// Reset: show saved active tab
|
|
allTabContents.forEach((content) => {
|
|
content.classList.remove('active');
|
|
});
|
|
allTabs.forEach((tab) => {
|
|
tab.classList.remove('active');
|
|
});
|
|
|
|
// Restore saved tab as active
|
|
const savedTabName = settingsUiState.getActiveTab();
|
|
const savedTab = document.querySelector(`.settings-tab[data-tab="${savedTabName}"]`);
|
|
const savedContent = document.getElementById(`settings-tab-${savedTabName}`);
|
|
if (savedTab && savedContent) {
|
|
savedTab.classList.add('active');
|
|
savedContent.classList.add('active');
|
|
} else if (allTabs[0] && allTabContents[0]) {
|
|
// Fallback to first tab if saved tab not found
|
|
allTabs[0].classList.add('active');
|
|
allTabContents[0].classList.add('active');
|
|
}
|
|
|
|
// Show all settings groups and items
|
|
const allGroups = settingsPage.querySelectorAll('.settings-group');
|
|
const allItems = settingsPage.querySelectorAll('.setting-item');
|
|
allGroups.forEach((group) => (group.style.display = ''));
|
|
allItems.forEach((item) => (item.style.display = ''));
|
|
return;
|
|
}
|
|
|
|
// When searching, show all tabs' content
|
|
allTabContents.forEach((content) => {
|
|
content.classList.add('active');
|
|
});
|
|
allTabs.forEach((tab) => {
|
|
tab.classList.remove('active');
|
|
});
|
|
|
|
// Search through all settings
|
|
const allGroups = settingsPage.querySelectorAll('.settings-group');
|
|
|
|
allGroups.forEach((group) => {
|
|
const items = group.querySelectorAll('.setting-item');
|
|
let hasMatch = false;
|
|
|
|
items.forEach((item) => {
|
|
const label = item.querySelector('.label');
|
|
const description = item.querySelector('.description');
|
|
|
|
const labelText = label?.textContent?.toLowerCase() || '';
|
|
const descriptionText = description?.textContent?.toLowerCase() || '';
|
|
|
|
const matches = labelText.includes(query) || descriptionText.includes(query);
|
|
|
|
if (matches) {
|
|
item.style.display = '';
|
|
hasMatch = true;
|
|
} else {
|
|
item.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Show/hide group based on whether it has any visible items
|
|
group.style.display = hasMatch ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function initializeBlockedContentManager() {
|
|
const manageBtn = document.getElementById('manage-blocked-btn');
|
|
const clearAllBtn = document.getElementById('clear-all-blocked-btn');
|
|
const blockedListContainer = document.getElementById('blocked-content-list');
|
|
const blockedArtistsList = document.getElementById('blocked-artists-list');
|
|
const blockedAlbumsList = document.getElementById('blocked-albums-list');
|
|
const blockedTracksList = document.getElementById('blocked-tracks-list');
|
|
const blockedArtistsSection = document.getElementById('blocked-artists-section');
|
|
const blockedAlbumsSection = document.getElementById('blocked-albums-section');
|
|
const blockedTracksSection = document.getElementById('blocked-tracks-section');
|
|
const blockedEmptyMessage = document.getElementById('blocked-empty-message');
|
|
|
|
if (!manageBtn || !blockedListContainer) return;
|
|
|
|
function renderBlockedLists() {
|
|
const artists = contentBlockingSettings.getBlockedArtists();
|
|
const albums = contentBlockingSettings.getBlockedAlbums();
|
|
const tracks = contentBlockingSettings.getBlockedTracks();
|
|
const totalCount = artists.length + albums.length + tracks.length;
|
|
|
|
// Update manage button text
|
|
manageBtn.textContent = totalCount > 0 ? `Manage (${totalCount})` : 'Manage';
|
|
|
|
// Show/hide clear all button
|
|
if (clearAllBtn) {
|
|
clearAllBtn.style.display = totalCount > 0 ? 'inline-block' : 'none';
|
|
}
|
|
|
|
// Show/hide sections
|
|
blockedArtistsSection.style.display = artists.length > 0 ? 'block' : 'none';
|
|
blockedAlbumsSection.style.display = albums.length > 0 ? 'block' : 'none';
|
|
blockedTracksSection.style.display = tracks.length > 0 ? 'block' : 'none';
|
|
blockedEmptyMessage.style.display = totalCount === 0 ? 'block' : 'none';
|
|
|
|
// Render artists
|
|
if (blockedArtistsList) {
|
|
blockedArtistsList.innerHTML = artists
|
|
.map(
|
|
(artist) => `
|
|
<li data-id="${artist.id}" data-type="artist">
|
|
<div class="item-info">
|
|
<div class="item-name">${escapeHtml(artist.name)}</div>
|
|
<div class="item-meta">${new Date(artist.blockedAt).toLocaleDateString()}</div>
|
|
</div>
|
|
<button class="unblock-btn" data-id="${artist.id}" data-type="artist">Unblock</button>
|
|
</li>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
// Render albums
|
|
if (blockedAlbumsList) {
|
|
blockedAlbumsList.innerHTML = albums
|
|
.map(
|
|
(album) => `
|
|
<li data-id="${album.id}" data-type="album">
|
|
<div class="item-info">
|
|
<div class="item-name">${escapeHtml(album.title)}</div>
|
|
<div class="item-meta">${escapeHtml(album.artist || 'Unknown Artist')} • ${new Date(album.blockedAt).toLocaleDateString()}</div>
|
|
</div>
|
|
<button class="unblock-btn" data-id="${album.id}" data-type="album">Unblock</button>
|
|
</li>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
// Render tracks
|
|
if (blockedTracksList) {
|
|
blockedTracksList.innerHTML = tracks
|
|
.map(
|
|
(track) => `
|
|
<li data-id="${track.id}" data-type="track">
|
|
<div class="item-info">
|
|
<div class="item-name">${escapeHtml(track.title)}</div>
|
|
<div class="item-meta">${escapeHtml(track.artist || 'Unknown Artist')} • ${new Date(track.blockedAt).toLocaleDateString()}</div>
|
|
</div>
|
|
<button class="unblock-btn" data-id="${track.id}" data-type="track">Unblock</button>
|
|
</li>
|
|
`
|
|
)
|
|
.join('');
|
|
}
|
|
|
|
// Add unblock button handlers
|
|
blockedListContainer.querySelectorAll('.unblock-btn').forEach((btn) => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.id;
|
|
const type = btn.dataset.type;
|
|
|
|
if (type === 'artist') {
|
|
contentBlockingSettings.unblockArtist(id);
|
|
} else if (type === 'album') {
|
|
contentBlockingSettings.unblockAlbum(id);
|
|
} else if (type === 'track') {
|
|
contentBlockingSettings.unblockTrack(id);
|
|
}
|
|
|
|
renderBlockedLists();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Toggle blocked list visibility
|
|
manageBtn.addEventListener('click', () => {
|
|
const isVisible = blockedListContainer.style.display !== 'none';
|
|
blockedListContainer.style.display = isVisible ? 'none' : 'block';
|
|
if (!isVisible) {
|
|
renderBlockedLists();
|
|
}
|
|
});
|
|
|
|
// Clear all blocked content
|
|
if (clearAllBtn) {
|
|
clearAllBtn.addEventListener('click', () => {
|
|
if (confirm('Are you sure you want to unblock all artists, albums, and tracks?')) {
|
|
contentBlockingSettings.clearAllBlocked();
|
|
renderBlockedLists();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initial render
|
|
renderBlockedLists();
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|