Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
5eba6aa224
17 changed files with 1402 additions and 145 deletions
|
|
@ -22,7 +22,9 @@ def recv_packet(s):
|
||||||
op, length = struct.unpack('<II', header)
|
op, length = struct.unpack('<II', header)
|
||||||
payload = s.recv(length)
|
payload = s.recv(length)
|
||||||
return json.loads(payload.decode('utf-8'))
|
return json.loads(payload.decode('utf-8'))
|
||||||
except: return None
|
except Exception:
|
||||||
|
# Ignore errors and return None
|
||||||
|
return None
|
||||||
|
|
||||||
def set_activity(ds, pid, details, state, img=None, start=None, end=None, large_text=None, small_img=None, small_txt=None):
|
def set_activity(ds, pid, details, state, img=None, start=None, end=None, large_text=None, small_img=None, small_txt=None):
|
||||||
global LAST_STATUS
|
global LAST_STATUS
|
||||||
|
|
@ -61,7 +63,9 @@ def main():
|
||||||
line = sys.stdin.readline()
|
line = sys.stdin.readline()
|
||||||
if not line: return
|
if not line: return
|
||||||
config = json.loads(line)
|
config = json.loads(line)
|
||||||
except: return
|
except Exception:
|
||||||
|
# Ignore errors and exit
|
||||||
|
return
|
||||||
|
|
||||||
ppid = os.getppid()
|
ppid = os.getppid()
|
||||||
|
|
||||||
|
|
@ -71,7 +75,9 @@ def main():
|
||||||
try:
|
try:
|
||||||
ds = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
ds = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
ds.connect(ipc_path)
|
ds.connect(ipc_path)
|
||||||
except: return
|
except Exception:
|
||||||
|
# Ignore connection errors and exit
|
||||||
|
return
|
||||||
|
|
||||||
# 3. Handshake
|
# 3. Handshake
|
||||||
send_packet(ds, 0, {"v": 1, "client_id": CLIENT_ID})
|
send_packet(ds, 0, {"v": 1, "client_id": CLIENT_ID})
|
||||||
|
|
@ -85,7 +91,9 @@ def main():
|
||||||
ws.settimeout(1.0)
|
ws.settimeout(1.0)
|
||||||
try:
|
try:
|
||||||
ws.connect(('127.0.0.1', int(config['nlPort'])))
|
ws.connect(('127.0.0.1', int(config['nlPort'])))
|
||||||
except: return
|
except Exception:
|
||||||
|
# Ignore connection errors and exit
|
||||||
|
return
|
||||||
|
|
||||||
key = base64.b64encode(os.urandom(16)).decode()
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
handshake = (
|
handshake = (
|
||||||
|
|
@ -133,8 +141,12 @@ def main():
|
||||||
set_activity(ds, ppid, "Idling", "Monochrome")
|
set_activity(ds, ppid, "Idling", "Monochrome")
|
||||||
elif msg['event'] == 'windowClose':
|
elif msg['event'] == 'windowClose':
|
||||||
break
|
break
|
||||||
except socket.timeout: continue
|
except socket.timeout:
|
||||||
except: continue
|
# Timeout is expected, continue polling
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
# Ignore other errors and continue
|
||||||
|
continue
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
try:
|
try:
|
||||||
|
|
@ -145,7 +157,9 @@ def main():
|
||||||
})
|
})
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
ds.close()
|
ds.close()
|
||||||
except: pass
|
except Exception:
|
||||||
|
# Ignore cleanup errors
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
78
index.html
78
index.html
|
|
@ -3244,14 +3244,27 @@
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Close Queue on Navigation</span>
|
<span class="label">Close Modals on Navigation</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Close the queue panel when navigating back or to a new page (useful for
|
>Close open modals and panels (like lyrics, queue) when navigating back or
|
||||||
mobile)</span
|
to a new page</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="queue-close-on-navigation-toggle" />
|
<input type="checkbox" id="close-modals-on-navigation-toggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Intercept Back to Close Modals</span>
|
||||||
|
<span class="description"
|
||||||
|
>When pressing back, close open modals/panels first without navigating.
|
||||||
|
Press back again to actually go back.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="intercept-back-to-close-modals-toggle" />
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3917,6 +3930,28 @@
|
||||||
<path d="M3 3v5h5" />
|
<path d="M3 3v5h5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="eq-export-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Export EQ settings to text"
|
||||||
|
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="eq-import-btn"
|
||||||
|
class="btn-secondary"
|
||||||
|
title="Import EQ settings from text or file"
|
||||||
|
style="font-size: 0.75rem; padding: 0.25rem 0.5rem"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="eq-import-file"
|
||||||
|
accept=".txt"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="custom-preset-controls">
|
<div class="custom-preset-controls">
|
||||||
|
|
@ -4036,10 +4071,41 @@
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="eq-preamp-controls"
|
||||||
|
style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem"
|
||||||
|
>
|
||||||
|
<label style="font-size: 0.75rem; opacity: 0.8">Preamp:</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="eq-preamp-slider"
|
||||||
|
min="-20"
|
||||||
|
max="20"
|
||||||
|
step="0.1"
|
||||||
|
value="0"
|
||||||
|
style="flex: 1; max-width: 120px"
|
||||||
|
title="Preamp gain in dB"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="eq-preamp-input"
|
||||||
|
min="-20"
|
||||||
|
max="20"
|
||||||
|
step="0.1"
|
||||||
|
value="0"
|
||||||
|
style="width: 60px; padding: 0.25rem; font-size: 0.75rem"
|
||||||
|
title="Preamp value in dB"
|
||||||
|
/>
|
||||||
|
<span style="font-size: 0.75rem">dB</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="equalizer-bands" id="equalizer-bands">
|
<div class="equalizer-bands-wrapper">
|
||||||
<!-- Bands will be dynamically generated by JavaScript -->
|
<canvas id="eq-response-canvas" class="eq-response-canvas"></canvas>
|
||||||
|
<div class="equalizer-bands" id="equalizer-bands">
|
||||||
|
<!-- Bands will be dynamically generated by JavaScript -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="equalizer-scale">
|
<div class="equalizer-scale">
|
||||||
|
|
|
||||||
|
|
@ -438,7 +438,7 @@ export class LosslessAPI {
|
||||||
if (!album) throw new Error('Album not found');
|
if (!album) throw new Error('Album not found');
|
||||||
|
|
||||||
// If album exists but has no artist, try to extract from tracks
|
// If album exists but has no artist, try to extract from tracks
|
||||||
if (album && !album.artist && tracksSection?.items && tracksSection.items.length > 0) {
|
if (!album.artist && tracksSection?.items && tracksSection.items.length > 0) {
|
||||||
const firstTrack = tracksSection.items[0];
|
const firstTrack = tracksSection.items[0];
|
||||||
const track = firstTrack.item || firstTrack;
|
const track = firstTrack.item || firstTrack;
|
||||||
if (track && track.artist) {
|
if (track && track.artist) {
|
||||||
|
|
@ -447,7 +447,7 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If album exists but has no releaseDate, try to extract from tracks
|
// If album exists but has no releaseDate, try to extract from tracks
|
||||||
if (album && !album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) {
|
if (!album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) {
|
||||||
const firstTrack = tracksSection.items[0];
|
const firstTrack = tracksSection.items[0];
|
||||||
const track = firstTrack.item || firstTrack;
|
const track = firstTrack.item || firstTrack;
|
||||||
|
|
||||||
|
|
|
||||||
127
js/app.js
127
js/app.js
|
|
@ -7,7 +7,7 @@ import {
|
||||||
downloadQualitySettings,
|
downloadQualitySettings,
|
||||||
sidebarSettings,
|
sidebarSettings,
|
||||||
pwaUpdateSettings,
|
pwaUpdateSettings,
|
||||||
queueBehaviorSettings,
|
modalSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { UIRenderer } from './ui.js';
|
import { UIRenderer } from './ui.js';
|
||||||
import { Player } from './player.js';
|
import { Player } from './player.js';
|
||||||
|
|
@ -377,10 +377,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua);
|
const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua);
|
||||||
const hasFileSystemApi = 'showDirectoryPicker' in window;
|
const hasFileSystemApi = 'showDirectoryPicker' in window;
|
||||||
|
const isNeutralino =
|
||||||
|
window.NL_MODE ||
|
||||||
|
window.location.search.includes('mode=neutralino') ||
|
||||||
|
window.location.search.includes('nl_port=');
|
||||||
|
|
||||||
if (!isChromeOrEdge || !hasFileSystemApi) {
|
if (!isNeutralino && (!isChromeOrEdge || !hasFileSystemApi)) {
|
||||||
selectLocalBtn.style.display = 'none';
|
selectLocalBtn.style.display = 'none';
|
||||||
browserWarning.style.display = 'block';
|
browserWarning.style.display = 'block';
|
||||||
|
} else if (isNeutralino) {
|
||||||
|
selectLocalBtn.style.display = 'flex';
|
||||||
|
browserWarning.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1968,10 +1975,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) {
|
if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) {
|
||||||
const isChange = e.target.closest('#change-local-folder-btn') !== null;
|
const isChange = e.target.closest('#change-local-folder-btn') !== null;
|
||||||
try {
|
try {
|
||||||
const handle = await window.showDirectoryPicker({
|
const isNeutralino =
|
||||||
id: 'music-folder',
|
window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
|
||||||
mode: 'read',
|
let handle;
|
||||||
});
|
let path;
|
||||||
|
|
||||||
|
if (isNeutralino) {
|
||||||
|
path = await window.Neutralino.os.showFolderDialog('Select Music Folder');
|
||||||
|
if (!path) return;
|
||||||
|
// Mock a handle object for UI compatibility
|
||||||
|
handle = { name: path.split(/[/\\]/).pop() || path, isNeutralino: true, path };
|
||||||
|
} else {
|
||||||
|
handle = await window.showDirectoryPicker({
|
||||||
|
id: 'music-folder',
|
||||||
|
mode: 'read',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await db.saveSetting('local_folder_handle', handle);
|
await db.saveSetting('local_folder_handle', handle);
|
||||||
if (isChange) {
|
if (isChange) {
|
||||||
|
|
@ -1988,32 +2007,67 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
|
const { readTrackMetadata } = await loadMetadataModule();
|
||||||
|
|
||||||
async function scanDirectory(dirHandle) {
|
if (isNeutralino) {
|
||||||
for await (const entry of dirHandle.values()) {
|
async function scanDirectoryNeu(dirPath) {
|
||||||
if (entry.kind === 'file') {
|
const entries = await window.Neutralino.filesystem.readDirectory(dirPath);
|
||||||
const name = entry.name.toLowerCase();
|
for (const entry of entries) {
|
||||||
if (
|
if (entry.entry === '.' || entry.entry === '..') continue;
|
||||||
name.endsWith('.flac') ||
|
const fullPath = `${dirPath}/${entry.entry}`;
|
||||||
name.endsWith('.mp3') ||
|
if (entry.type === 'FILE') {
|
||||||
name.endsWith('.m4a') ||
|
const name = entry.entry.toLowerCase();
|
||||||
name.endsWith('.wav') ||
|
if (
|
||||||
name.endsWith('.ogg')
|
name.endsWith('.flac') ||
|
||||||
) {
|
name.endsWith('.mp3') ||
|
||||||
const file = await entry.getFile();
|
name.endsWith('.m4a') ||
|
||||||
const { readTrackMetadata } = await loadMetadataModule();
|
name.endsWith('.wav') ||
|
||||||
const metadata = await readTrackMetadata(file);
|
name.endsWith('.ogg')
|
||||||
metadata.id = `local-${idCounter++}-${file.name}`;
|
) {
|
||||||
tracks.push(metadata);
|
try {
|
||||||
|
const buffer = await window.Neutralino.filesystem.readBinaryFile(fullPath);
|
||||||
|
const stats = await window.Neutralino.filesystem.getStats(fullPath);
|
||||||
|
const file = new File([buffer], entry.entry, {
|
||||||
|
lastModified: stats.mtime,
|
||||||
|
});
|
||||||
|
const metadata = await readTrackMetadata(file);
|
||||||
|
metadata.id = `local-${idCounter++}-${entry.entry}`;
|
||||||
|
tracks.push(metadata);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read file:', fullPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (entry.type === 'DIRECTORY') {
|
||||||
|
await scanDirectoryNeu(fullPath);
|
||||||
}
|
}
|
||||||
} else if (entry.kind === 'directory') {
|
|
||||||
await scanDirectory(entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await scanDirectoryNeu(path);
|
||||||
|
} else {
|
||||||
|
async function scanDirectory(dirHandle) {
|
||||||
|
for await (const entry of dirHandle.values()) {
|
||||||
|
if (entry.kind === 'file') {
|
||||||
|
const name = entry.name.toLowerCase();
|
||||||
|
if (
|
||||||
|
name.endsWith('.flac') ||
|
||||||
|
name.endsWith('.mp3') ||
|
||||||
|
name.endsWith('.m4a') ||
|
||||||
|
name.endsWith('.wav') ||
|
||||||
|
name.endsWith('.ogg')
|
||||||
|
) {
|
||||||
|
const file = await entry.getFile();
|
||||||
|
const metadata = await readTrackMetadata(file);
|
||||||
|
metadata.id = `local-${idCounter++}-${file.name}`;
|
||||||
|
tracks.push(metadata);
|
||||||
|
}
|
||||||
|
} else if (entry.kind === 'directory') {
|
||||||
|
await scanDirectory(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await scanDirectory(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
await scanDirectory(handle);
|
|
||||||
|
|
||||||
tracks.sort((a, b) => {
|
tracks.sort((a, b) => {
|
||||||
const artistA = a.artist.name || '';
|
const artistA = a.artist.name || '';
|
||||||
const artistB = b.artist.name || '';
|
const artistB = b.artist.name || '';
|
||||||
|
|
@ -2120,9 +2174,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close side panel (queue/lyrics) on navigation if setting is enabled
|
// Intercept back navigation to close modals first if setting is enabled
|
||||||
if (queueBehaviorSettings.shouldCloseOnNavigation()) {
|
if (event && modalSettings.shouldInterceptBackToClose() && modalSettings.hasOpenModalsOrPanels()) {
|
||||||
sidePanelManager.close();
|
sidePanelManager.close();
|
||||||
|
modalSettings.closeAllModals();
|
||||||
|
history.pushState(history.state || { app: true }, '', window.location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close side panel (queue/lyrics) and modals on navigation if setting is enabled
|
||||||
|
if (modalSettings.shouldCloseOnNavigation()) {
|
||||||
|
sidePanelManager.close();
|
||||||
|
modalSettings.closeAllModals();
|
||||||
}
|
}
|
||||||
|
|
||||||
await router();
|
await router();
|
||||||
|
|
@ -2370,6 +2433,12 @@ function showUpdateNotification(updateCallback) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function showMissingTracksNotification(missingTracks) {
|
function showMissingTracksNotification(missingTracks) {
|
||||||
const modal = document.getElementById('missing-tracks-modal');
|
const modal = document.getElementById('missing-tracks-modal');
|
||||||
const listUl = document.getElementById('missing-tracks-list-ul');
|
const listUl = document.getElementById('missing-tracks-list-ul');
|
||||||
|
|
@ -2378,7 +2447,7 @@ function showMissingTracksNotification(missingTracks) {
|
||||||
.map((track) => {
|
.map((track) => {
|
||||||
const text =
|
const text =
|
||||||
typeof track === 'string' ? track : `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
|
typeof track === 'string' ? track : `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
|
||||||
return `<li>${text}</li>`;
|
return `<li>${escapeHtml(text)}</li>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ class AudioContextManager {
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
// Reconnect the audio graph without interrupting playback
|
||||||
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch event for UI update
|
// Dispatch event for UI update
|
||||||
|
|
@ -177,6 +179,8 @@ class AudioContextManager {
|
||||||
if (this.isInitialized && this.audioContext) {
|
if (this.isInitialized && this.audioContext) {
|
||||||
this._destroyEQ();
|
this._destroyEQ();
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
|
// Reconnect the audio graph without interrupting playback
|
||||||
|
this._connectGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch event for UI update
|
// Dispatch event for UI update
|
||||||
|
|
@ -203,6 +207,16 @@ class AudioContextManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.filters = [];
|
this.filters = [];
|
||||||
|
|
||||||
|
// Destroy preamp node
|
||||||
|
if (this.preampNode) {
|
||||||
|
try {
|
||||||
|
this.preampNode.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.preampNode = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -211,6 +225,15 @@ class AudioContextManager {
|
||||||
_createEQ() {
|
_createEQ() {
|
||||||
if (!this.audioContext) return;
|
if (!this.audioContext) return;
|
||||||
|
|
||||||
|
// Create preamp node
|
||||||
|
if (!this.preampNode) {
|
||||||
|
this.preampNode = this.audioContext.createGain();
|
||||||
|
}
|
||||||
|
// Set preamp gain
|
||||||
|
const preampValue = this.preamp || 0;
|
||||||
|
const gainValue = Math.pow(10, preampValue / 20);
|
||||||
|
this.preampNode.gain.value = gainValue;
|
||||||
|
|
||||||
// Create biquad filters for each frequency band
|
// Create biquad filters for each frequency band
|
||||||
this.filters = this.frequencies.map((freq, index) => {
|
this.filters = this.frequencies.map((freq, index) => {
|
||||||
const filter = this.audioContext.createBiquadFilter();
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
|
@ -366,13 +389,18 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isEQEnabled && this.filters.length > 0) {
|
if (this.isEQEnabled && this.filters.length > 0) {
|
||||||
// EQ enabled: lastNode -> EQ filters -> output -> analyser -> volume -> destination
|
// EQ enabled: lastNode -> preamp -> EQ filters -> output -> analyser -> volume -> destination
|
||||||
// Connect filter chain
|
// Connect filter chain
|
||||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||||
this.filters[i].connect(this.filters[i + 1]);
|
this.filters[i].connect(this.filters[i + 1]);
|
||||||
}
|
}
|
||||||
// Connect input to first filter and last filter to output
|
// Connect preamp to first filter
|
||||||
lastNode.connect(this.filters[0]);
|
if (this.preampNode) {
|
||||||
|
lastNode.connect(this.preampNode);
|
||||||
|
this.preampNode.connect(this.filters[0]);
|
||||||
|
} else {
|
||||||
|
lastNode.connect(this.filters[0]);
|
||||||
|
}
|
||||||
this.filters[this.filters.length - 1].connect(this.outputNode);
|
this.filters[this.filters.length - 1].connect(this.outputNode);
|
||||||
this.outputNode.connect(this.analyser);
|
this.outputNode.connect(this.analyser);
|
||||||
this.analyser.connect(this.volumeNode);
|
this.analyser.connect(this.volumeNode);
|
||||||
|
|
@ -609,6 +637,119 @@ class AudioContextManager {
|
||||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
this.isMonoAudioEnabled = monoAudioSettings.isEnabled();
|
||||||
|
this.preamp = equalizerSettings.getPreamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set preamp value in dB
|
||||||
|
* @param {number} db - Preamp value in dB (-20 to +20)
|
||||||
|
*/
|
||||||
|
setPreamp(db) {
|
||||||
|
const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
|
this.preamp = clampedDb;
|
||||||
|
equalizerSettings.setPreamp(clampedDb);
|
||||||
|
|
||||||
|
// Update preamp node if it exists
|
||||||
|
if (this.preampNode && this.audioContext) {
|
||||||
|
const gainValue = Math.pow(10, clampedDb / 20);
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current preamp value
|
||||||
|
* @returns {number} Current preamp value in dB
|
||||||
|
*/
|
||||||
|
getPreamp() {
|
||||||
|
return this.preamp || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export equalizer settings to text format
|
||||||
|
* @returns {string} Exported settings in text format
|
||||||
|
*/
|
||||||
|
exportEQToText() {
|
||||||
|
const lines = [];
|
||||||
|
const preampValue = this.getPreamp();
|
||||||
|
lines.push(`Preamp: ${preampValue.toFixed(1)} dB`);
|
||||||
|
|
||||||
|
this.frequencies.forEach((freq, index) => {
|
||||||
|
const gain = this.currentGains[index] || 0;
|
||||||
|
const filterNum = index + 1;
|
||||||
|
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import equalizer settings from text format
|
||||||
|
* @param {string} text - Text format settings
|
||||||
|
* @returns {boolean} True if import was successful
|
||||||
|
*/
|
||||||
|
importEQFromText(text) {
|
||||||
|
try {
|
||||||
|
const lines = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line);
|
||||||
|
const filters = [];
|
||||||
|
let preamp = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Parse preamp
|
||||||
|
const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i);
|
||||||
|
if (preampMatch) {
|
||||||
|
preamp = parseFloat(preampMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
|
const filterMatch = line.match(
|
||||||
|
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
|
||||||
|
);
|
||||||
|
if (filterMatch) {
|
||||||
|
const type = filterMatch[1].toUpperCase();
|
||||||
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
|
const gain = parseFloat(filterMatch[3]);
|
||||||
|
const q = parseFloat(filterMatch[4]);
|
||||||
|
filters.push({ type, freq, gain, q });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
console.warn('[AudioContext] No valid filters found in import text');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preamp
|
||||||
|
this.setPreamp(preamp);
|
||||||
|
|
||||||
|
// If different number of bands, adjust
|
||||||
|
if (filters.length !== this.bandCount) {
|
||||||
|
const newCount = Math.max(
|
||||||
|
equalizerSettings.MIN_BANDS,
|
||||||
|
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||||
|
);
|
||||||
|
this.setBandCount(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract gains from filters
|
||||||
|
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||||
|
this.setAllGains(gains);
|
||||||
|
|
||||||
|
// Store filter frequencies if different
|
||||||
|
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||||
|
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||||
|
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AudioContext] Failed to import EQ settings:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,68 @@ export const os = {
|
||||||
window.parent.postMessage({ type: 'NL_OS_SHOW_SAVE_DIALOG', id, title, options }, '*');
|
window.parent.postMessage({ type: 'NL_OS_SHOW_SAVE_DIALOG', id, title, options }, '*');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
showFolderDialog: async (title, options) => {
|
||||||
|
if (!isNeutralino) return;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
resolve(event.data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'NL_OS_SHOW_FOLDER_DIALOG', id, title, options }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filesystem = {
|
export const filesystem = {
|
||||||
|
readBinaryFile: async (path) => {
|
||||||
|
if (!isNeutralino) return;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
if (event.data.error) reject(event.data.error);
|
||||||
|
else resolve(event.data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'NL_FS_READ_BINARY', id, path }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readDirectory: async (path) => {
|
||||||
|
if (!isNeutralino) return;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
if (event.data.error) reject(event.data.error);
|
||||||
|
else resolve(event.data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'NL_FS_READ_DIR', id, path }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getStats: async (path) => {
|
||||||
|
if (!isNeutralino) return;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = Math.random().toString(36).substring(7);
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
if (event.data.error) reject(event.data.error);
|
||||||
|
else resolve(event.data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'NL_FS_STATS', id, path }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
writeBinaryFile: async (path, buffer) => {
|
writeBinaryFile: async (path, buffer) => {
|
||||||
if (!isNeutralino) return;
|
if (!isNeutralino) return;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
SVG_CLOSE,
|
SVG_CLOSE,
|
||||||
getCoverBlob,
|
getCoverBlob,
|
||||||
getExtensionFromBlob,
|
getExtensionFromBlob,
|
||||||
|
escapeHtml,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
||||||
import { addMetadataToAudio } from './metadata.js';
|
import { addMetadataToAudio } from './metadata.js';
|
||||||
|
|
@ -45,11 +46,11 @@ export function showNotification(message) {
|
||||||
const notifEl = document.createElement('div');
|
const notifEl = document.createElement('div');
|
||||||
notifEl.className = 'download-task';
|
notifEl.className = 'download-task';
|
||||||
|
|
||||||
notifEl.innerHTML = `
|
const innerDiv = document.createElement('div');
|
||||||
<div style="display: flex; align-items: start;">
|
innerDiv.style.display = 'flex';
|
||||||
${message}
|
innerDiv.style.alignItems = 'start';
|
||||||
</div>
|
innerDiv.textContent = message;
|
||||||
`;
|
notifEl.appendChild(innerDiv);
|
||||||
|
|
||||||
container.appendChild(notifEl);
|
container.appendChild(notifEl);
|
||||||
|
|
||||||
|
|
@ -1019,7 +1020,7 @@ function createBulkDownloadNotification(type, name, _totalItems) {
|
||||||
<div style="font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem;">
|
<div style="font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem;">
|
||||||
Downloading ${typeLabel}
|
Downloading ${typeLabel}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 0.85rem; color: var(--muted-foreground); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${name}</div>
|
<div style="font-size: 0.85rem; color: var(--muted-foreground); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(name)}</div>
|
||||||
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
||||||
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
138
js/equalizer.js
138
js/equalizer.js
|
|
@ -174,6 +174,9 @@ export class Equalizer {
|
||||||
// Store current gains
|
// Store current gains
|
||||||
this.currentGains = new Array(this.bandCount).fill(0);
|
this.currentGains = new Array(this.bandCount).fill(0);
|
||||||
|
|
||||||
|
// Store current preamp value
|
||||||
|
this.preamp = 0;
|
||||||
|
|
||||||
// Load saved settings
|
// Load saved settings
|
||||||
this._loadSettings();
|
this._loadSettings();
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +293,10 @@ export class Equalizer {
|
||||||
this.inputNode = this.audioContext.createGain();
|
this.inputNode = this.audioContext.createGain();
|
||||||
this.outputNode = this.audioContext.createGain();
|
this.outputNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// Create preamp gain node
|
||||||
|
this.preampNode = this.audioContext.createGain();
|
||||||
|
this._updatePreampGain();
|
||||||
|
|
||||||
// Connect the filter chain
|
// Connect the filter chain
|
||||||
this._connectFilters();
|
this._connectFilters();
|
||||||
|
|
||||||
|
|
@ -325,6 +332,11 @@ export class Equalizer {
|
||||||
_connectFilters() {
|
_connectFilters() {
|
||||||
if (!this.filters.length) return;
|
if (!this.filters.length) return;
|
||||||
|
|
||||||
|
// Connect preamp to first filter
|
||||||
|
if (this.preampNode) {
|
||||||
|
this.preampNode.connect(this.filters[0]);
|
||||||
|
}
|
||||||
|
|
||||||
// Chain filters together
|
// Chain filters together
|
||||||
for (let i = 0; i < this.filters.length - 1; i++) {
|
for (let i = 0; i < this.filters.length - 1; i++) {
|
||||||
this.filters[i].connect(this.filters[i + 1]);
|
this.filters[i].connect(this.filters[i + 1]);
|
||||||
|
|
@ -356,7 +368,7 @@ export class Equalizer {
|
||||||
* Get the input node for external connection
|
* Get the input node for external connection
|
||||||
*/
|
*/
|
||||||
getInputNode() {
|
getInputNode() {
|
||||||
return this.filters[0] || null;
|
return this.preampNode || this.filters[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -530,6 +542,38 @@ export class Equalizer {
|
||||||
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max);
|
||||||
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
this.frequencyLabels = generateFrequencyLabels(this.frequencies);
|
||||||
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
this.currentGains = equalizerSettings.getGains(this.bandCount);
|
||||||
|
this.preamp = equalizerSettings.getPreamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update preamp gain value
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_updatePreampGain() {
|
||||||
|
if (this.preampNode && this.audioContext) {
|
||||||
|
const gainValue = Math.pow(10, this.preamp / 20);
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this.preampNode.gain.setTargetAtTime(gainValue, now, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set preamp value in dB
|
||||||
|
* @param {number} db - Preamp value in dB (-20 to +20)
|
||||||
|
*/
|
||||||
|
setPreamp(db) {
|
||||||
|
const clampedDb = Math.max(-20, Math.min(20, parseFloat(db) || 0));
|
||||||
|
this.preamp = clampedDb;
|
||||||
|
equalizerSettings.setPreamp(clampedDb);
|
||||||
|
this._updatePreampGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current preamp value
|
||||||
|
* @returns {number} Current preamp value in dB
|
||||||
|
*/
|
||||||
|
getPreamp() {
|
||||||
|
return this.preamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -554,12 +598,104 @@ export class Equalizer {
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
this.preampNode?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
this.filters = [];
|
this.filters = [];
|
||||||
this.inputNode = null;
|
this.inputNode = null;
|
||||||
this.outputNode = null;
|
this.outputNode = null;
|
||||||
|
this.preampNode = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export equalizer settings to text format
|
||||||
|
* @returns {string} Exported settings in text format
|
||||||
|
*/
|
||||||
|
exportToText() {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Preamp: ${this.preamp.toFixed(1)} dB`);
|
||||||
|
|
||||||
|
this.frequencies.forEach((freq, index) => {
|
||||||
|
const gain = this.currentGains[index] || 0;
|
||||||
|
const filterNum = index + 1;
|
||||||
|
lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q 0.71`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import equalizer settings from text format
|
||||||
|
* @param {string} text - Text format settings
|
||||||
|
* @returns {boolean} True if import was successful
|
||||||
|
*/
|
||||||
|
importFromText(text) {
|
||||||
|
try {
|
||||||
|
const lines = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line);
|
||||||
|
const filters = [];
|
||||||
|
let preamp = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Parse preamp
|
||||||
|
const preampMatch = line.match(/^Preamp:\s*([+-]?\d+\.?\d*)\s*dB$/i);
|
||||||
|
if (preampMatch) {
|
||||||
|
preamp = parseFloat(preampMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filter lines (handle "Filter:" and "Filter X:" formats)
|
||||||
|
const filterMatch = line.match(
|
||||||
|
/^Filter\s*\d*:\s*ON\s+(\w+)\s+Fc\s+(\d+)\s+Hz\s+Gain\s*([+-]?\d+\.?\d*)\s*dB\s+Q\s+(\d+\.?\d*)/i
|
||||||
|
);
|
||||||
|
if (filterMatch) {
|
||||||
|
const type = filterMatch[1].toUpperCase();
|
||||||
|
const freq = parseInt(filterMatch[2], 10);
|
||||||
|
const gain = parseFloat(filterMatch[3]);
|
||||||
|
const q = parseFloat(filterMatch[4]);
|
||||||
|
filters.push({ type, freq, gain, q });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
console.warn('[Equalizer] No valid filters found in import text');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preamp
|
||||||
|
this.setPreamp(preamp);
|
||||||
|
|
||||||
|
// If different number of bands, adjust
|
||||||
|
if (filters.length !== this.bandCount) {
|
||||||
|
const newCount = Math.max(
|
||||||
|
equalizerSettings.MIN_BANDS,
|
||||||
|
Math.min(equalizerSettings.MAX_BANDS, filters.length)
|
||||||
|
);
|
||||||
|
this.setBandCount(newCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract gains from filters
|
||||||
|
const gains = filters.slice(0, this.bandCount).map((f) => f.gain);
|
||||||
|
this.setAllGains(gains);
|
||||||
|
|
||||||
|
// Store filter frequencies if different
|
||||||
|
const newFreqs = filters.slice(0, this.bandCount).map((f) => f.freq);
|
||||||
|
if (JSON.stringify(newFreqs) !== JSON.stringify(this.frequencies)) {
|
||||||
|
equalizerSettings.setFreqRange(newFreqs[0], newFreqs[newFreqs.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Equalizer] Failed to import settings:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|
|
||||||
73
js/events.js
73
js/events.js
|
|
@ -11,6 +11,7 @@ import {
|
||||||
getTrackArtists,
|
getTrackArtists,
|
||||||
positionMenu,
|
positionMenu,
|
||||||
getShareUrl,
|
getShareUrl,
|
||||||
|
escapeHtml,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js';
|
import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js';
|
||||||
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js';
|
||||||
|
|
@ -1213,25 +1214,25 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
infoHTML = `
|
infoHTML = `
|
||||||
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
||||||
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${item.title}</h3>
|
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${escapeHtml(item.title)}</h3>
|
||||||
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
||||||
<div style="margin-bottom: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-bottom: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--primary); font-weight: 500;">Unreleased Track</p>
|
<p style="color: var(--primary); font-weight: 500;">Unreleased Track</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; gap: 0.5rem;">
|
<div style="display: grid; gap: 0.5rem;">
|
||||||
${item.artists ? `<p><strong style="color: var(--foreground);">Artist:</strong> ${Array.isArray(item.artists) ? item.artists.map((a) => a.name || a).join(', ') : item.artists}</p>` : ''}
|
${item.artists ? `<p><strong style="color: var(--foreground);">Artist:</strong> ${escapeHtml(Array.isArray(item.artists) ? item.artists.map((a) => a.name || a).join(', ') : item.artists)}</p>` : ''}
|
||||||
${item.trackerInfo.artist ? `<p><strong style="color: var(--foreground);">Tracked Artist:</strong> ${item.trackerInfo.artist}</p>` : ''}
|
${item.trackerInfo.artist ? `<p><strong style="color: var(--foreground);">Tracked Artist:</strong> ${escapeHtml(item.trackerInfo.artist)}</p>` : ''}
|
||||||
${item.trackerInfo.project ? `<p><strong style="color: var(--foreground);">Project:</strong> ${item.trackerInfo.project}</p>` : ''}
|
${item.trackerInfo.project ? `<p><strong style="color: var(--foreground);">Project:</strong> ${escapeHtml(item.trackerInfo.project)}</p>` : ''}
|
||||||
${item.trackerInfo.era ? `<p><strong style="color: var(--foreground);">Era:</strong> ${item.trackerInfo.era}</p>` : ''}
|
${item.trackerInfo.era ? `<p><strong style="color: var(--foreground);">Era:</strong> ${escapeHtml(item.trackerInfo.era)}</p>` : ''}
|
||||||
${item.trackerInfo.timeline ? `<p><strong style="color: var(--foreground);">Timeline:</strong> ${item.trackerInfo.timeline}</p>` : ''}
|
${item.trackerInfo.timeline ? `<p><strong style="color: var(--foreground);">Timeline:</strong> ${escapeHtml(item.trackerInfo.timeline)}</p>` : ''}
|
||||||
${item.trackerInfo.category ? `<p><strong style="color: var(--foreground);">Category:</strong> ${item.trackerInfo.category}</p>` : ''}
|
${item.trackerInfo.category ? `<p><strong style="color: var(--foreground);">Category:</strong> ${escapeHtml(item.trackerInfo.category)}</p>` : ''}
|
||||||
${item.trackerInfo.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${item.trackerInfo.trackNumber}</p>` : ''}
|
${item.trackerInfo.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${escapeHtml(String(item.trackerInfo.trackNumber))}</p>` : ''}
|
||||||
<p><strong style="color: var(--foreground);">Duration:</strong> ${formatTime(item.duration)}</p>
|
<p><strong style="color: var(--foreground);">Duration:</strong> ${escapeHtml(formatTime(item.duration))}</p>
|
||||||
${releaseDate !== 'Unknown' ? `<p><strong style="color: var(--foreground);">Release Date:</strong> ${dateDisplay}</p>` : ''}
|
${releaseDate !== 'Unknown' ? `<p><strong style="color: var(--foreground);">Release Date:</strong> ${escapeHtml(dateDisplay)}</p>` : ''}
|
||||||
${item.trackerInfo.addedDate ? `<p><strong style="color: var(--foreground);">Added to Tracker:</strong> ${addedDate}</p>` : ''}
|
${item.trackerInfo.addedDate ? `<p><strong style="color: var(--foreground);">Added to Tracker:</strong> ${escapeHtml(addedDate)}</p>` : ''}
|
||||||
${item.trackerInfo.leakedDate ? `<p><strong style="color: var(--foreground);">Leak Date:</strong> ${new Date(item.trackerInfo.leakedDate).toLocaleDateString()}</p>` : ''}
|
${item.trackerInfo.leakedDate ? `<p><strong style="color: var(--foreground);">Leak Date:</strong> ${escapeHtml(new Date(item.trackerInfo.leakedDate).toLocaleDateString())}</p>` : ''}
|
||||||
${item.trackerInfo.recordingDate ? `<p><strong style="color: var(--foreground);">Recording Date:</strong> ${new Date(item.trackerInfo.recordingDate).toLocaleDateString()}</p>` : ''}
|
${item.trackerInfo.recordingDate ? `<p><strong style="color: var(--foreground);">Recording Date:</strong> ${escapeHtml(new Date(item.trackerInfo.recordingDate).toLocaleDateString())}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
|
|
@ -1239,7 +1240,7 @@ export async function handleTrackAction(
|
||||||
? `
|
? `
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Description</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Description</p>
|
||||||
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.description}</p>
|
<p style="font-size: 0.85rem; line-height: 1.6;">${escapeHtml(item.trackerInfo.description)}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
|
|
@ -1250,7 +1251,7 @@ export async function handleTrackAction(
|
||||||
? `
|
? `
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Notes</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Notes</p>
|
||||||
<p style="font-size: 0.85rem; line-height: 1.6;">${item.trackerInfo.notes}</p>
|
<p style="font-size: 0.85rem; line-height: 1.6;">${escapeHtml(item.trackerInfo.notes)}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
|
|
@ -1261,17 +1262,17 @@ export async function handleTrackAction(
|
||||||
? `
|
? `
|
||||||
<div style="margin-top: 1rem;">
|
<div style="margin-top: 1rem;">
|
||||||
<p style="margin-bottom: 0.5rem;"><strong style="color: var(--foreground);">Source URL:</strong></p>
|
<p style="margin-bottom: 0.5rem;"><strong style="color: var(--foreground);">Source URL:</strong></p>
|
||||||
<a href="${item.trackerInfo.sourceUrl}" target="_blank" style="color: var(--primary); word-break: break-all; font-size: 0.85rem; display: block; padding: 0.5rem; background: var(--accent); border-radius: 6px; text-decoration: none;">
|
<a href="${escapeHtml(item.trackerInfo.sourceUrl)}" target="_blank" style="color: var(--primary); word-break: break-all; font-size: 0.85rem; display: block; padding: 0.5rem; background: var(--accent); border-radius: 6px; text-decoration: none;">
|
||||||
${item.trackerInfo.sourceUrl}
|
${escapeHtml(item.trackerInfo.sourceUrl)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${escapeHtml(item.id)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button onclick="this.closest('.modal-overlay').remove()" class="btn-primary" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
<button class="btn-primary track-info-close-btn" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1283,19 +1284,19 @@ export async function handleTrackAction(
|
||||||
|
|
||||||
infoHTML = `
|
infoHTML = `
|
||||||
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
<div style="padding: 1.5rem; max-width: 500px; max-height: 80vh; overflow-y: auto;">
|
||||||
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${item.title}</h3>
|
<h3 style="margin-bottom: 1rem; font-size: 1.3rem; font-weight: 600;">${escapeHtml(item.title)}</h3>
|
||||||
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
<div style="color: var(--muted-foreground); font-size: 0.9rem; line-height: 1.8;">
|
||||||
<div style="display: grid; gap: 0.5rem;">
|
<div style="display: grid; gap: 0.5rem;">
|
||||||
<p><strong style="color: var(--foreground);">Artist:</strong> ${getTrackArtists(item)}</p>
|
<p><strong style="color: var(--foreground);">Artist:</strong> ${escapeHtml(getTrackArtists(item))}</p>
|
||||||
<p><strong style="color: var(--foreground);">Album:</strong> ${item.album?.title || 'Unknown'}</p>
|
<p><strong style="color: var(--foreground);">Album:</strong> ${escapeHtml(item.album?.title || 'Unknown')}</p>
|
||||||
${item.album?.artist?.name ? `<p><strong style="color: var(--foreground);">Album Artist:</strong> ${item.album.artist.name}</p>` : ''}
|
${item.album?.artist?.name ? `<p><strong style="color: var(--foreground);">Album Artist:</strong> ${escapeHtml(item.album.artist.name)}</p>` : ''}
|
||||||
<p><strong style="color: var(--foreground);">Release Date:</strong> ${dateDisplay}</p>
|
<p><strong style="color: var(--foreground);">Release Date:</strong> ${escapeHtml(dateDisplay)}</p>
|
||||||
<p><strong style="color: var(--foreground);">Duration:</strong> ${formatTime(item.duration)}</p>
|
<p><strong style="color: var(--foreground);">Duration:</strong> ${escapeHtml(formatTime(item.duration))}</p>
|
||||||
${item.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${item.trackNumber}</p>` : ''}
|
${item.trackNumber ? `<p><strong style="color: var(--foreground);">Track Number:</strong> ${escapeHtml(String(item.trackNumber))}</p>` : ''}
|
||||||
${item.discNumber ? `<p><strong style="color: var(--foreground);">Disc Number:</strong> ${item.discNumber}</p>` : ''}
|
${item.discNumber ? `<p><strong style="color: var(--foreground);">Disc Number:</strong> ${escapeHtml(String(item.discNumber))}</p>` : ''}
|
||||||
${item.version ? `<p><strong style="color: var(--foreground);">Version:</strong> ${item.version}</p>` : ''}
|
${item.version ? `<p><strong style="color: var(--foreground);">Version:</strong> ${escapeHtml(item.version)}</p>` : ''}
|
||||||
${item.explicit ? `<p><strong style="color: var(--foreground);">Explicit:</strong> Yes</p>` : ''}
|
${item.explicit ? `<p><strong style="color: var(--foreground);">Explicit:</strong> Yes</p>` : ''}
|
||||||
<p><strong style="color: var(--foreground);">Quality:</strong> ${quality} ${bitrate ? `(${bitrate})` : ''}</p>
|
<p><strong style="color: var(--foreground);">Quality:</strong> ${escapeHtml(quality)} ${bitrate ? `(${escapeHtml(bitrate)})` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
|
|
@ -1304,7 +1305,7 @@ export async function handleTrackAction(
|
||||||
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
<div style="margin-top: 1rem; padding: 0.75rem; background: var(--accent); border-radius: 8px;">
|
||||||
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
<p style="color: var(--foreground); font-weight: 500; margin-bottom: 0.5rem;">Credits</p>
|
||||||
<div style="font-size: 0.85rem; line-height: 1.6;">
|
<div style="font-size: 0.85rem; line-height: 1.6;">
|
||||||
${item.credits.map((c) => `<p>${c.type}: ${c.name}</p>`).join('')}
|
${item.credits.map((c) => `<p>${escapeHtml(c.type)}: ${escapeHtml(c.name)}</p>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
@ -1314,7 +1315,7 @@ export async function handleTrackAction(
|
||||||
${
|
${
|
||||||
item.composers && item.composers.length > 0
|
item.composers && item.composers.length > 0
|
||||||
? `
|
? `
|
||||||
<p style="margin-top: 0.5rem;"><strong style="color: var(--foreground);">Composers:</strong> ${item.composers.map((c) => c.name).join(', ')}</p>
|
<p style="margin-top: 0.5rem;"><strong style="color: var(--foreground);">Composers:</strong> ${escapeHtml(item.composers.map((c) => c.name).join(', '))}</p>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
@ -1329,10 +1330,10 @@ export async function handleTrackAction(
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${item.id}</p>` : ''}
|
${item.id ? `<p style="margin-top: 1rem; font-size: 0.8rem; color: var(--muted);"><strong>Track ID:</strong> ${escapeHtml(item.id)}</p>` : ''}
|
||||||
${item.album?.id ? `<p style="font-size: 0.8rem; color: var(--muted);"><strong>Album ID:</strong> ${item.album.id}</p>` : ''}
|
${item.album?.id ? `<p style="font-size: 0.8rem; color: var(--muted);"><strong>Album ID:</strong> ${escapeHtml(item.album.id)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button onclick="this.closest('.modal-overlay').remove()" class="btn-primary" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
<button class="btn-primary track-info-close-btn" style="margin-top: 1.5rem; width: 100%;">Close</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -1346,6 +1347,10 @@ export async function handleTrackAction(
|
||||||
modal.onclick = (e) => {
|
modal.onclick = (e) => {
|
||||||
if (e.target === modal) modal.remove();
|
if (e.target === modal) modal.remove();
|
||||||
};
|
};
|
||||||
|
const closeBtn = modal.querySelector('.track-info-close-btn');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => modal.remove();
|
||||||
|
}
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
} else if (action === 'open-original-url') {
|
} else if (action === 'open-original-url') {
|
||||||
// Open the original source URL for the track
|
// Open the original source URL for the track
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
getTrackArtistsHTML,
|
getTrackArtistsHTML,
|
||||||
getTrackYearDisplay,
|
getTrackYearDisplay,
|
||||||
createQualityBadgeHTML,
|
createQualityBadgeHTML,
|
||||||
|
escapeHtml,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import {
|
import {
|
||||||
queueManager,
|
queueManager,
|
||||||
|
|
@ -166,7 +167,7 @@ export class Player {
|
||||||
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover);
|
if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover);
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
const qualityBadge = createQualityBadgeHTML(track);
|
const qualityBadge = createQualityBadgeHTML(track);
|
||||||
titleEl.innerHTML = `${trackTitle} ${qualityBadge}`;
|
titleEl.innerHTML = `${escapeHtml(trackTitle)} ${qualityBadge}`;
|
||||||
}
|
}
|
||||||
if (albumEl) {
|
if (albumEl) {
|
||||||
const albumTitle = track.album?.title || '';
|
const albumTitle = track.album?.title || '';
|
||||||
|
|
@ -356,7 +357,8 @@ export class Player {
|
||||||
const yearDisplay = getTrackYearDisplay(track);
|
const yearDisplay = getTrackYearDisplay(track);
|
||||||
|
|
||||||
document.querySelector('.now-playing-bar .cover').src = this.api.getCoverUrl(track.album?.cover);
|
document.querySelector('.now-playing-bar .cover').src = this.api.getCoverUrl(track.album?.cover);
|
||||||
document.querySelector('.now-playing-bar .title').innerHTML = `${trackTitle} ${createQualityBadgeHTML(track)}`;
|
document.querySelector('.now-playing-bar .title').innerHTML =
|
||||||
|
`${escapeHtml(trackTitle)} ${createQualityBadgeHTML(track)}`;
|
||||||
const albumEl = document.querySelector('.now-playing-bar .album');
|
const albumEl = document.querySelector('.now-playing-bar .album');
|
||||||
if (albumEl) {
|
if (albumEl) {
|
||||||
const albumTitle = track.album?.title || '';
|
const albumTitle = track.album?.title || '';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { sanitizeForFilename } from './utils.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get track artists string
|
* Helper function to get track artists string
|
||||||
*/
|
*/
|
||||||
|
|
@ -277,8 +275,17 @@ export async function parseJSPF(jspfText, api, onProgress) {
|
||||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||||
*/
|
*/
|
||||||
export async function parseXSPF(xspfText, api, onProgress) {
|
export async function parseXSPF(xspfText, api, onProgress) {
|
||||||
|
// Validate input to prevent potential XXE attacks
|
||||||
|
if (!xspfText || typeof xspfText !== 'string' || xspfText.length > 10 * 1024 * 1024) {
|
||||||
|
throw new Error('Invalid XSPF content');
|
||||||
|
}
|
||||||
|
// Reject potential XXE payloads
|
||||||
|
if (xspfText.includes('<!ENTITY') || xspfText.includes('<!DOCTYPE')) {
|
||||||
|
throw new Error('XSPF content contains potentially dangerous declarations');
|
||||||
|
}
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const xmlDoc = parser.parseFromString(xspfText, 'text/xml');
|
const xmlDoc = parser.parseFromString(xspfText, 'application/xml');
|
||||||
|
|
||||||
const trackList = xmlDoc.getElementsByTagName('track');
|
const trackList = xmlDoc.getElementsByTagName('track');
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
|
|
@ -329,8 +336,17 @@ export async function parseXSPF(xspfText, api, onProgress) {
|
||||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||||
*/
|
*/
|
||||||
export async function parseXML(xmlText, api, onProgress) {
|
export async function parseXML(xmlText, api, onProgress) {
|
||||||
|
// Validate input to prevent potential XXE attacks
|
||||||
|
if (!xmlText || typeof xmlText !== 'string' || xmlText.length > 10 * 1024 * 1024) {
|
||||||
|
throw new Error('Invalid XML content');
|
||||||
|
}
|
||||||
|
// Reject potential XXE payloads
|
||||||
|
if (xmlText.includes('<!ENTITY') || xmlText.includes('<!DOCTYPE')) {
|
||||||
|
throw new Error('XML content contains potentially dangerous declarations');
|
||||||
|
}
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
const xmlDoc = parser.parseFromString(xmlText, 'application/xml');
|
||||||
|
|
||||||
// Try different track element names
|
// Try different track element names
|
||||||
let trackElements = xmlDoc.getElementsByTagName('track');
|
let trackElements = xmlDoc.getElementsByTagName('track');
|
||||||
|
|
|
||||||
429
js/settings.js
429
js/settings.js
|
|
@ -32,7 +32,7 @@ import {
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
musicProviderSettings,
|
musicProviderSettings,
|
||||||
analyticsSettings,
|
analyticsSettings,
|
||||||
queueBehaviorSettings,
|
modalSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||||
import { getButterchurnPresets } from './visualizers/butterchurn.js';
|
import { getButterchurnPresets } from './visualizers/butterchurn.js';
|
||||||
|
|
@ -886,11 +886,17 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const resetEqFreqBtn = document.getElementById('reset-eq-freq-btn');
|
const resetEqFreqBtn = document.getElementById('reset-eq-freq-btn');
|
||||||
const resetEqRangeBtn = document.getElementById('reset-eq-range-btn');
|
const resetEqRangeBtn = document.getElementById('reset-eq-range-btn');
|
||||||
const eqScaleContainer = document.querySelector('.equalizer-scale');
|
const eqScaleContainer = document.querySelector('.equalizer-scale');
|
||||||
|
const eqPreampSlider = document.getElementById('eq-preamp-slider');
|
||||||
|
const eqPreampInput = document.getElementById('eq-preamp-input');
|
||||||
|
const eqExportBtn = document.getElementById('eq-export-btn');
|
||||||
|
const eqImportBtn = document.getElementById('eq-import-btn');
|
||||||
|
const eqImportFile = document.getElementById('eq-import-file');
|
||||||
|
|
||||||
// Current settings
|
// Current settings
|
||||||
let currentBandCount = equalizerSettings.getBandCount();
|
let currentBandCount = equalizerSettings.getBandCount();
|
||||||
let currentRange = equalizerSettings.getRange();
|
let currentRange = equalizerSettings.getRange();
|
||||||
let currentFreqRange = equalizerSettings.getFreqRange();
|
let currentFreqRange = equalizerSettings.getFreqRange();
|
||||||
|
let currentPreamp = equalizerSettings.getPreamp();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate frequency labels for given band count and frequency range
|
* Generate frequency labels for given band count and frequency range
|
||||||
|
|
@ -1004,6 +1010,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
updateBandValueDisplay(bandEl, gains[index]);
|
updateBandValueDisplay(bandEl, gains[index]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Redraw the EQ curve after updating all bands
|
||||||
|
drawEQCurve();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1012,6 +1021,10 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const updateEQContainerVisibility = (enabled) => {
|
const updateEQContainerVisibility = (enabled) => {
|
||||||
if (eqContainer) {
|
if (eqContainer) {
|
||||||
eqContainer.style.display = enabled ? 'block' : 'none';
|
eqContainer.style.display = enabled ? 'block' : 'none';
|
||||||
|
if (enabled) {
|
||||||
|
// Redraw curve when container becomes visible
|
||||||
|
requestAnimationFrame(drawEQCurve);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1060,6 +1073,152 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
deleteCustomPresetBtn.style.display = isCustom ? 'flex' : 'none';
|
deleteCustomPresetBtn.style.display = isCustom ? 'flex' : 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw smooth EQ response curve on canvas
|
||||||
|
*/
|
||||||
|
const drawEQCurve = () => {
|
||||||
|
const canvas = document.getElementById('eq-response-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Skip if canvas has no size (not visible yet)
|
||||||
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
|
// Set canvas size accounting for DPR
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const width = rect.width;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Get all current gain values
|
||||||
|
const eqBands = eqBandsContainer?.querySelectorAll('.eq-band');
|
||||||
|
if (!eqBands || eqBands.length === 0) return;
|
||||||
|
|
||||||
|
// Get the actual highlight color from CSS
|
||||||
|
const tempEl = document.createElement('div');
|
||||||
|
tempEl.style.color = 'rgb(var(--highlight-rgb))';
|
||||||
|
document.body.appendChild(tempEl);
|
||||||
|
const highlightColor = getComputedStyle(tempEl).color;
|
||||||
|
document.body.removeChild(tempEl);
|
||||||
|
|
||||||
|
const gains = [];
|
||||||
|
const positions = [];
|
||||||
|
const range = currentRange;
|
||||||
|
const rangeTotal = range.max - range.min;
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
eqBands.forEach((bandEl) => {
|
||||||
|
const slider = bandEl.querySelector('.eq-slider');
|
||||||
|
const gain = slider ? parseFloat(slider.value) : 0;
|
||||||
|
gains.push(gain);
|
||||||
|
|
||||||
|
// Get actual center position of the band element relative to canvas
|
||||||
|
const bandRect = bandEl.getBoundingClientRect();
|
||||||
|
const x = bandRect.left + bandRect.width / 2 - canvasRect.left;
|
||||||
|
positions.push(x);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate y positions - account for slider thumb size (18px)
|
||||||
|
// The track is 120px, but thumb center moves within (120 - 18) = 102px range
|
||||||
|
const trackHeight = height;
|
||||||
|
const thumbSize = 18;
|
||||||
|
const usableTrack = trackHeight - thumbSize;
|
||||||
|
const trackOffset = thumbSize / 2;
|
||||||
|
|
||||||
|
const getY = (gain) => {
|
||||||
|
const normalized = (gain - range.min) / rangeTotal;
|
||||||
|
// Invert because canvas Y=0 is at top, slider max is at top
|
||||||
|
return trackOffset + (1 - normalized) * usableTrack;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create points array
|
||||||
|
const points = gains.map((gain, i) => ({
|
||||||
|
x: positions[i],
|
||||||
|
y: getY(gain),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (points.length < 2) return;
|
||||||
|
|
||||||
|
// Parse RGB values from color string
|
||||||
|
const rgbMatch = highlightColor.match(/\d+/g);
|
||||||
|
const r = rgbMatch ? parseInt(rgbMatch[0]) : 128;
|
||||||
|
const g = rgbMatch ? parseInt(rgbMatch[1]) : 128;
|
||||||
|
const b = rgbMatch ? parseInt(rgbMatch[2]) : 128;
|
||||||
|
|
||||||
|
// Calculate control points for smooth curve
|
||||||
|
const getControlPoints = (i) => {
|
||||||
|
const p0 = points[i === 0 ? i : i - 1];
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
const p3 = points[i + 2] || p2;
|
||||||
|
|
||||||
|
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||||
|
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||||
|
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||||
|
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||||
|
|
||||||
|
return { cp1x, cp1y, cp2x, cp2y };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw filled area from curve to bottom
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.3)`);
|
||||||
|
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.05)`);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, height);
|
||||||
|
ctx.lineTo(points[0].x, points[0].y);
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i);
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineTo(points[points.length - 1].x, height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw the curve line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const { cp1x, cp1y, cp2x, cp2y } = getControlPoints(i);
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw dots at each band point
|
||||||
|
points.forEach((point) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Add white center to dots for visibility
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.x, point.y, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize band slider event listeners
|
* Initialize band slider event listeners
|
||||||
*/
|
*/
|
||||||
|
|
@ -1069,6 +1228,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
const savedGains = equalizerSettings.getGains(currentBandCount);
|
const savedGains = equalizerSettings.getGains(currentBandCount);
|
||||||
|
|
||||||
|
// FL Studio-style absolute position drag state
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
eqBands.forEach((bandEl) => {
|
eqBands.forEach((bandEl) => {
|
||||||
const bandIndex = parseInt(bandEl.dataset.band, 10);
|
const bandIndex = parseInt(bandEl.dataset.band, 10);
|
||||||
const slider = bandEl.querySelector('.eq-slider');
|
const slider = bandEl.querySelector('.eq-slider');
|
||||||
|
|
@ -1084,6 +1246,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const gain = parseFloat(e.target.value);
|
const gain = parseFloat(e.target.value);
|
||||||
audioContextManager.setBandGain(bandIndex, gain);
|
audioContextManager.setBandGain(bandIndex, gain);
|
||||||
updateBandValueDisplay(bandEl, gain);
|
updateBandValueDisplay(bandEl, gain);
|
||||||
|
drawEQCurve();
|
||||||
|
|
||||||
// When manually adjusting, check if we should clear preset
|
// When manually adjusting, check if we should clear preset
|
||||||
if (eqPresetSelect && eqPresetSelect.value !== 'flat') {
|
if (eqPresetSelect && eqPresetSelect.value !== 'flat') {
|
||||||
|
|
@ -1104,9 +1267,72 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
slider.value = 0;
|
slider.value = 0;
|
||||||
audioContextManager.setBandGain(bandIndex, 0);
|
audioContextManager.setBandGain(bandIndex, 0);
|
||||||
updateBandValueDisplay(bandEl, 0);
|
updateBandValueDisplay(bandEl, 0);
|
||||||
|
drawEQCurve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// FL Studio-style absolute drag: mousedown starts drag mode
|
||||||
|
bandEl.addEventListener('mousedown', (e) => {
|
||||||
|
// Only handle left mouse button
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
isDragging = true;
|
||||||
|
document.body.style.cursor = 'ns-resize';
|
||||||
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Global mousemove: whichever band is under cursor, set slider to cursor Y position
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
// Find which band is under the cursor
|
||||||
|
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const bandUnderCursor = elementUnderCursor?.closest('.eq-band');
|
||||||
|
|
||||||
|
if (bandUnderCursor) {
|
||||||
|
const slider = bandUnderCursor.querySelector('.eq-slider');
|
||||||
|
|
||||||
|
if (slider) {
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
const min = parseFloat(slider.min);
|
||||||
|
const max = parseFloat(slider.max);
|
||||||
|
const step = parseFloat(slider.step) || 0.5;
|
||||||
|
|
||||||
|
// Calculate relative Y position within slider (0 = bottom, 1 = top)
|
||||||
|
const relativeY = (rect.bottom - e.clientY) / rect.height;
|
||||||
|
const clampedY = Math.max(0, Math.min(1, relativeY));
|
||||||
|
|
||||||
|
// Map to slider value range
|
||||||
|
let newValue = min + clampedY * (max - min);
|
||||||
|
|
||||||
|
// Round to step
|
||||||
|
newValue = Math.round(newValue / step) * step;
|
||||||
|
|
||||||
|
// Only update if value changed
|
||||||
|
if (parseFloat(slider.value) !== newValue) {
|
||||||
|
slider.value = newValue;
|
||||||
|
const bandIndex = parseInt(bandUnderCursor.dataset.band, 10);
|
||||||
|
audioContextManager.setBandGain(bandIndex, newValue);
|
||||||
|
updateBandValueDisplay(bandUnderCursor, newValue);
|
||||||
|
drawEQCurve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global mouseup: stop dragging
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial curve draw with delay to ensure canvas has proper dimensions
|
||||||
|
setTimeout(() => {
|
||||||
|
drawEQCurve();
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize EQ toggle
|
// Initialize EQ toggle
|
||||||
|
|
@ -1119,6 +1345,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
const enabled = e.target.checked;
|
const enabled = e.target.checked;
|
||||||
audioContextManager.toggleEQ(enabled);
|
audioContextManager.toggleEQ(enabled);
|
||||||
updateEQContainerVisibility(enabled);
|
updateEQContainerVisibility(enabled);
|
||||||
|
|
||||||
|
// Redraw curve after a brief delay to allow container to become visible
|
||||||
|
if (enabled) {
|
||||||
|
setTimeout(() => {
|
||||||
|
drawEQCurve();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1131,9 +1364,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
if (newCount >= equalizerSettings.MIN_BANDS && newCount <= equalizerSettings.MAX_BANDS) {
|
if (newCount >= equalizerSettings.MIN_BANDS && newCount <= equalizerSettings.MAX_BANDS) {
|
||||||
currentBandCount = newCount;
|
currentBandCount = newCount;
|
||||||
|
|
||||||
// Save new band count and update audio context
|
// Save new band count and update audio context (interpolates gains automatically)
|
||||||
equalizerSettings.setBandCount(newCount);
|
equalizerSettings.setBandCount(newCount);
|
||||||
audioContextManager.setBandCount?.(newCount) || audioContextManager.reinitialize?.();
|
audioContextManager.setBandCount?.(newCount);
|
||||||
|
|
||||||
// Regenerate UI
|
// Regenerate UI
|
||||||
generateEQBands(
|
generateEQBands(
|
||||||
|
|
@ -1144,16 +1377,25 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
currentFreqRange.max
|
currentFreqRange.max
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset to flat and apply
|
// Get interpolated gains from audio context
|
||||||
const flatGains = new Array(newCount).fill(0);
|
const interpolatedGains = audioContextManager.getGains?.() || equalizerSettings.getGains(newCount);
|
||||||
audioContextManager.setAllGains(flatGains);
|
updateAllBandUI(interpolatedGains);
|
||||||
updateAllBandUI(flatGains);
|
|
||||||
|
|
||||||
|
// Keep current preset or set to custom if modified
|
||||||
if (eqPresetSelect) {
|
if (eqPresetSelect) {
|
||||||
eqPresetSelect.value = 'flat';
|
const currentPreset = eqPresetSelect.value;
|
||||||
equalizerSettings.setPreset('flat');
|
if (!currentPreset.startsWith('custom_')) {
|
||||||
|
eqPresetSelect.value = 'custom';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateDeleteButtonVisibility();
|
updateDeleteButtonVisibility();
|
||||||
|
|
||||||
|
// Show brief feedback
|
||||||
|
const originalText = eqBandCountInput.style.backgroundColor;
|
||||||
|
eqBandCountInput.style.backgroundColor = 'var(--highlight)';
|
||||||
|
setTimeout(() => {
|
||||||
|
eqBandCountInput.style.backgroundColor = originalText;
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1490,6 +1732,125 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize preamp control
|
||||||
|
const updatePreampUI = (value) => {
|
||||||
|
currentPreamp = value;
|
||||||
|
if (eqPreampSlider) eqPreampSlider.value = value;
|
||||||
|
if (eqPreampInput) eqPreampInput.value = value;
|
||||||
|
audioContextManager.setPreamp?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eqPreampSlider) {
|
||||||
|
// Set initial value
|
||||||
|
eqPreampSlider.value = currentPreamp;
|
||||||
|
|
||||||
|
// Handle slider input
|
||||||
|
eqPreampSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
updatePreampUI(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eqPreampInput) {
|
||||||
|
// Set initial value
|
||||||
|
eqPreampInput.value = currentPreamp;
|
||||||
|
|
||||||
|
// Handle text input
|
||||||
|
eqPreampInput.addEventListener('change', (e) => {
|
||||||
|
let value = parseFloat(e.target.value);
|
||||||
|
// Clamp to valid range
|
||||||
|
value = Math.max(-20, Math.min(20, value || 0));
|
||||||
|
updatePreampUI(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle enter key
|
||||||
|
eqPreampInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize import/export controls
|
||||||
|
if (eqExportBtn) {
|
||||||
|
eqExportBtn.addEventListener('click', () => {
|
||||||
|
const text = audioContextManager.exportEQToText?.();
|
||||||
|
if (text) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
eqExportBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => {
|
||||||
|
eqExportBtn.textContent = 'Export';
|
||||||
|
}, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: create and download file
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'equalizer-settings.txt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eqImportBtn && eqImportFile) {
|
||||||
|
eqImportBtn.addEventListener('click', () => {
|
||||||
|
eqImportFile.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
eqImportFile.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target.result;
|
||||||
|
const success = audioContextManager.importEQFromText?.(text);
|
||||||
|
if (success) {
|
||||||
|
// Update UI
|
||||||
|
currentPreamp = equalizerSettings.getPreamp();
|
||||||
|
updatePreampUI(currentPreamp);
|
||||||
|
|
||||||
|
// Update band count if changed
|
||||||
|
currentBandCount = equalizerSettings.getBandCount();
|
||||||
|
if (eqBandCountInput) eqBandCountInput.value = currentBandCount;
|
||||||
|
|
||||||
|
// Regenerate bands and update UI
|
||||||
|
generateEQBands(
|
||||||
|
currentBandCount,
|
||||||
|
currentRange.min,
|
||||||
|
currentRange.max,
|
||||||
|
currentFreqRange.min,
|
||||||
|
currentFreqRange.max
|
||||||
|
);
|
||||||
|
const gains = audioContextManager.getGains?.() || equalizerSettings.getGains(currentBandCount);
|
||||||
|
updateAllBandUI(gains);
|
||||||
|
|
||||||
|
eqImportBtn.textContent = 'Imported!';
|
||||||
|
setTimeout(() => {
|
||||||
|
eqImportBtn.textContent = 'Import';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
eqImportBtn.textContent = 'Invalid!';
|
||||||
|
setTimeout(() => {
|
||||||
|
eqImportBtn.textContent = 'Import';
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generate initial EQ bands with current ranges
|
// Generate initial EQ bands with current ranges
|
||||||
generateEQBands(currentBandCount, currentRange.min, currentRange.max, currentFreqRange.min, currentFreqRange.max);
|
generateEQBands(currentBandCount, currentRange.min, currentRange.max, currentFreqRange.min, currentFreqRange.max);
|
||||||
|
|
||||||
|
|
@ -1524,6 +1885,22 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Redraw EQ curve on window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
requestAnimationFrame(drawEQCurve);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redraw EQ curve when a new track loads (audio metadata loaded)
|
||||||
|
const audioPlayer = document.getElementById('audio-player');
|
||||||
|
if (audioPlayer) {
|
||||||
|
audioPlayer.addEventListener('loadedmetadata', () => {
|
||||||
|
// Small delay to ensure the visualizer and EQ are fully ready
|
||||||
|
setTimeout(() => {
|
||||||
|
drawEQCurve();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Now Playing Mode
|
// Now Playing Mode
|
||||||
const nowPlayingMode = document.getElementById('now-playing-mode');
|
const nowPlayingMode = document.getElementById('now-playing-mode');
|
||||||
if (nowPlayingMode) {
|
if (nowPlayingMode) {
|
||||||
|
|
@ -1533,12 +1910,21 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue Close on Navigation Toggle
|
// Close Modals on Navigation Toggle
|
||||||
const queueCloseOnNavigationToggle = document.getElementById('queue-close-on-navigation-toggle');
|
const closeModalsOnNavigationToggle = document.getElementById('close-modals-on-navigation-toggle');
|
||||||
if (queueCloseOnNavigationToggle) {
|
if (closeModalsOnNavigationToggle) {
|
||||||
queueCloseOnNavigationToggle.checked = queueBehaviorSettings.shouldCloseOnNavigation();
|
closeModalsOnNavigationToggle.checked = modalSettings.shouldCloseOnNavigation();
|
||||||
queueCloseOnNavigationToggle.addEventListener('change', (e) => {
|
closeModalsOnNavigationToggle.addEventListener('change', (e) => {
|
||||||
queueBehaviorSettings.setCloseOnNavigation(e.target.checked);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2513,11 +2899,16 @@ function initializeFontSettings() {
|
||||||
let fontName = input;
|
let fontName = input;
|
||||||
|
|
||||||
// Check if it's a Google Fonts URL
|
// Check if it's a Google Fonts URL
|
||||||
if (input.includes('fonts.google.com')) {
|
try {
|
||||||
const parsed = fontSettings.parseGoogleFontsUrl(input);
|
const urlObj = new URL(input);
|
||||||
if (parsed) {
|
if (urlObj.hostname === 'fonts.google.com') {
|
||||||
fontName = parsed;
|
const parsed = fontSettings.parseGoogleFontsUrl(input);
|
||||||
|
if (parsed) {
|
||||||
|
fontName = parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a URL, treat as font name
|
||||||
}
|
}
|
||||||
|
|
||||||
fontSettings.loadGoogleFont(fontName);
|
fontSettings.loadGoogleFont(fontName);
|
||||||
|
|
|
||||||
237
js/storage.js
237
js/storage.js
|
|
@ -29,7 +29,7 @@ export const apiSettings = {
|
||||||
if (isSimpleArray) {
|
if (isSimpleArray) {
|
||||||
groupedInstances.api = [...data.api];
|
groupedInstances.api = [...data.api];
|
||||||
} else {
|
} else {
|
||||||
for (const [, config] of Object.entries(data.api)) {
|
for (const [_key, config] of Object.entries(data.api)) {
|
||||||
if (config.cors === false && Array.isArray(config.urls)) {
|
if (config.cors === false && Array.isArray(config.urls)) {
|
||||||
groupedInstances.api.push(...config.urls);
|
groupedInstances.api.push(...config.urls);
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +95,22 @@ export const apiSettings = {
|
||||||
|
|
||||||
// love it when local storage doesnt update
|
// love it when local storage doesnt update
|
||||||
if (instancesObj?.api?.length === 2) {
|
if (instancesObj?.api?.length === 2) {
|
||||||
const hasBinimum = instancesObj.api.some((url) => url.includes('tidal-api.binimum.org'));
|
const hasBinimum = instancesObj.api.some((url) => {
|
||||||
const hasSamidy = instancesObj.api.some((url) => url.includes('monochrome-api.samidy.com'));
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname === 'tidal-api.binimum.org';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const hasSamidy = instancesObj.api.some((url) => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname === 'monochrome-api.samidy.com';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (hasBinimum && hasSamidy) {
|
if (hasBinimum && hasSamidy) {
|
||||||
localStorage.removeItem(this.STORAGE_KEY);
|
localStorage.removeItem(this.STORAGE_KEY);
|
||||||
|
|
@ -219,7 +233,7 @@ export const themeManager = {
|
||||||
purple: {},
|
purple: {},
|
||||||
forest: {},
|
forest: {},
|
||||||
mocha: {},
|
mocha: {},
|
||||||
machiatto: {},
|
macchiato: {},
|
||||||
frappe: {},
|
frappe: {},
|
||||||
latte: {},
|
latte: {},
|
||||||
},
|
},
|
||||||
|
|
@ -278,6 +292,22 @@ export const themeManager = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Simple obfuscation to avoid clear-text storage of sensitive data
|
||||||
|
function encodeSensitiveData(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const encoded = btoa(text.split('').reverse().join(''));
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSensitiveData(encoded) {
|
||||||
|
if (!encoded) return '';
|
||||||
|
try {
|
||||||
|
return atob(encoded).split('').reverse().join('');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const lastFMStorage = {
|
export const lastFMStorage = {
|
||||||
STORAGE_KEY: 'lastfm-enabled',
|
STORAGE_KEY: 'lastfm-enabled',
|
||||||
LOVE_ON_LIKE_KEY: 'lastfm-love-on-like',
|
LOVE_ON_LIKE_KEY: 'lastfm-love-on-like',
|
||||||
|
|
@ -338,26 +368,28 @@ export const lastFMStorage = {
|
||||||
|
|
||||||
getCustomApiKey() {
|
getCustomApiKey() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.CUSTOM_API_KEY) || '';
|
const stored = localStorage.getItem(this.CUSTOM_API_KEY);
|
||||||
|
return decodeSensitiveData(stored) || '';
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomApiKey(key) {
|
setCustomApiKey(key) {
|
||||||
localStorage.setItem(this.CUSTOM_API_KEY, key);
|
localStorage.setItem(this.CUSTOM_API_KEY, encodeSensitiveData(key));
|
||||||
},
|
},
|
||||||
|
|
||||||
getCustomApiSecret() {
|
getCustomApiSecret() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.CUSTOM_API_SECRET) || '';
|
const stored = localStorage.getItem(this.CUSTOM_API_SECRET);
|
||||||
|
return decodeSensitiveData(stored) || '';
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomApiSecret(secret) {
|
setCustomApiSecret(secret) {
|
||||||
localStorage.setItem(this.CUSTOM_API_SECRET, secret);
|
localStorage.setItem(this.CUSTOM_API_SECRET, encodeSensitiveData(secret));
|
||||||
},
|
},
|
||||||
|
|
||||||
clearCustomCredentials() {
|
clearCustomCredentials() {
|
||||||
|
|
@ -797,6 +829,7 @@ export const equalizerSettings = {
|
||||||
RANGE_MAX_KEY: 'equalizer-range-max',
|
RANGE_MAX_KEY: 'equalizer-range-max',
|
||||||
FREQ_MIN_KEY: 'equalizer-freq-min',
|
FREQ_MIN_KEY: 'equalizer-freq-min',
|
||||||
FREQ_MAX_KEY: 'equalizer-freq-max',
|
FREQ_MAX_KEY: 'equalizer-freq-max',
|
||||||
|
PREAMP_KEY: 'equalizer-preamp',
|
||||||
DEFAULT_BAND_COUNT: 16,
|
DEFAULT_BAND_COUNT: 16,
|
||||||
MIN_BANDS: 3,
|
MIN_BANDS: 3,
|
||||||
MAX_BANDS: 32,
|
MAX_BANDS: 32,
|
||||||
|
|
@ -808,6 +841,9 @@ export const equalizerSettings = {
|
||||||
DEFAULT_FREQ_MAX: 20000,
|
DEFAULT_FREQ_MAX: 20000,
|
||||||
ABSOLUTE_FREQ_MIN: 10,
|
ABSOLUTE_FREQ_MIN: 10,
|
||||||
ABSOLUTE_FREQ_MAX: 96000,
|
ABSOLUTE_FREQ_MAX: 96000,
|
||||||
|
DEFAULT_PREAMP: 0,
|
||||||
|
PREAMP_MIN: -20,
|
||||||
|
PREAMP_MAX: 20,
|
||||||
|
|
||||||
isEnabled() {
|
isEnabled() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -911,7 +947,7 @@ export const equalizerSettings = {
|
||||||
const stored = localStorage.getItem(this.FREQ_MIN_KEY);
|
const stored = localStorage.getItem(this.FREQ_MIN_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const val = parseInt(stored, 10);
|
const val = parseInt(stored, 10);
|
||||||
if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < this.DEFAULT_FREQ_MAX) {
|
if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < this.ABSOLUTE_FREQ_MAX) {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -923,7 +959,20 @@ export const equalizerSettings = {
|
||||||
|
|
||||||
setFreqMin(value) {
|
setFreqMin(value) {
|
||||||
const val = parseInt(value, 10);
|
const val = parseInt(value, 10);
|
||||||
if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < this.getFreqMax()) {
|
// Get effective max from storage without recursive call
|
||||||
|
let effectiveMax = this.DEFAULT_FREQ_MAX;
|
||||||
|
try {
|
||||||
|
const storedMax = localStorage.getItem(this.FREQ_MAX_KEY);
|
||||||
|
if (storedMax) {
|
||||||
|
const parsedMax = parseInt(storedMax, 10);
|
||||||
|
if (!isNaN(parsedMax) && parsedMax > this.ABSOLUTE_FREQ_MIN && parsedMax <= this.ABSOLUTE_FREQ_MAX) {
|
||||||
|
effectiveMax = parsedMax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore and use default max */
|
||||||
|
}
|
||||||
|
if (!isNaN(val) && val >= this.ABSOLUTE_FREQ_MIN && val < effectiveMax) {
|
||||||
localStorage.setItem(this.FREQ_MIN_KEY, val.toString());
|
localStorage.setItem(this.FREQ_MIN_KEY, val.toString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -932,11 +981,23 @@ export const equalizerSettings = {
|
||||||
|
|
||||||
getFreqMax() {
|
getFreqMax() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(this.FREQ_MAX_KEY);
|
const storedMax = localStorage.getItem(this.FREQ_MAX_KEY);
|
||||||
if (stored) {
|
if (storedMax) {
|
||||||
const val = parseInt(stored, 10);
|
const maxVal = parseInt(storedMax, 10);
|
||||||
if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) {
|
if (!isNaN(maxVal) && maxVal > this.ABSOLUTE_FREQ_MIN && maxVal <= this.ABSOLUTE_FREQ_MAX) {
|
||||||
return val;
|
// Get stored min without recursive call
|
||||||
|
try {
|
||||||
|
const storedMin = localStorage.getItem(this.FREQ_MIN_KEY);
|
||||||
|
if (storedMin) {
|
||||||
|
const minVal = parseInt(storedMin, 10);
|
||||||
|
if (!isNaN(minVal) && maxVal <= minVal) {
|
||||||
|
return this.DEFAULT_FREQ_MAX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return maxVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -946,9 +1007,21 @@ export const equalizerSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setFreqMax(value) {
|
setFreqMax(value) {
|
||||||
const val = parseInt(value, 10);
|
const maxVal = parseInt(value, 10);
|
||||||
if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) {
|
if (!isNaN(maxVal) && maxVal > this.ABSOLUTE_FREQ_MIN && maxVal <= this.ABSOLUTE_FREQ_MAX) {
|
||||||
localStorage.setItem(this.FREQ_MAX_KEY, val.toString());
|
// Check against stored min without recursive call
|
||||||
|
try {
|
||||||
|
const storedMin = localStorage.getItem(this.FREQ_MIN_KEY);
|
||||||
|
if (storedMin) {
|
||||||
|
const minVal = parseInt(storedMin, 10);
|
||||||
|
if (!isNaN(minVal) && maxVal <= minVal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
localStorage.setItem(this.FREQ_MAX_KEY, maxVal.toString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -967,6 +1040,30 @@ export const equalizerSettings = {
|
||||||
return validMin && validMax;
|
return validMin && validMax;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPreamp() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.PREAMP_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const val = parseFloat(stored);
|
||||||
|
if (!isNaN(val) && val >= this.PREAMP_MIN && val <= this.PREAMP_MAX) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return this.DEFAULT_PREAMP;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreamp(value) {
|
||||||
|
const val = parseFloat(value);
|
||||||
|
if (!isNaN(val) && val >= this.PREAMP_MIN && val <= this.PREAMP_MAX) {
|
||||||
|
localStorage.setItem(this.PREAMP_KEY, val.toString());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
getGains(bandCount) {
|
getGains(bandCount) {
|
||||||
const count = bandCount || this.getBandCount();
|
const count = bandCount || this.getBandCount();
|
||||||
try {
|
try {
|
||||||
|
|
@ -1121,7 +1218,7 @@ export const equalizerSettings = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(gains) && gains.length === 16) {
|
if (Array.isArray(gains) && gains.length === this.DEFAULT_BAND_COUNT) {
|
||||||
presets[presetId].gains = gains.map((g) => Math.round(g * 10) / 10);
|
presets[presetId].gains = gains.map((g) => Math.round(g * 10) / 10);
|
||||||
presets[presetId].updatedAt = Date.now();
|
presets[presetId].updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
@ -1813,7 +1910,17 @@ export const fontSettings = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadGoogleFont(familyName) {
|
async loadGoogleFont(familyName) {
|
||||||
const encodedFamily = familyName.replace(/\s+/g, '+');
|
// Validate familyName to prevent injection
|
||||||
|
if (!familyName || typeof familyName !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only allow alphanumeric, spaces, and basic punctuation in font names
|
||||||
|
const sanitizedFamily = familyName.replace(/[^a-zA-Z0-9\s\-_,.]/g, '');
|
||||||
|
if (!sanitizedFamily) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedFamily = encodeURIComponent(sanitizedFamily);
|
||||||
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
|
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
|
||||||
|
|
||||||
let link = document.getElementById(this.FONT_LINK_ID);
|
let link = document.getElementById(this.FONT_LINK_ID);
|
||||||
|
|
@ -2085,16 +2192,15 @@ export const musicProviderSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueBehaviorSettings = {
|
export const modalSettings = {
|
||||||
STORAGE_KEY: 'queue-close-on-navigation',
|
STORAGE_KEY: 'close-modals-on-navigation',
|
||||||
|
INTERCEPT_BACK_KEY: 'intercept-back-to-close-modals',
|
||||||
|
|
||||||
shouldCloseOnNavigation() {
|
shouldCloseOnNavigation() {
|
||||||
try {
|
try {
|
||||||
// Default to true on mobile, false on desktop
|
|
||||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||||
if (saved === null) {
|
if (saved === null) {
|
||||||
// Auto-detect: default to true for mobile/touch devices
|
return false;
|
||||||
return window.matchMedia('(pointer: coarse)').matches;
|
|
||||||
}
|
}
|
||||||
return saved === 'true';
|
return saved === 'true';
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -2105,6 +2211,87 @@ export const queueBehaviorSettings = {
|
||||||
setCloseOnNavigation(enabled) {
|
setCloseOnNavigation(enabled) {
|
||||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
shouldInterceptBackToClose() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(this.INTERCEPT_BACK_KEY);
|
||||||
|
if (saved === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return saved === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setInterceptBackToClose(enabled) {
|
||||||
|
localStorage.setItem(this.INTERCEPT_BACK_KEY, enabled ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
hasOpenModalsOrPanels() {
|
||||||
|
const sidePanel = document.getElementById('side-panel');
|
||||||
|
if (sidePanel && sidePanel.classList.contains('active')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (document.querySelector('.modal.active')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (document.querySelector('.modal-overlay')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const modalIds = [
|
||||||
|
'playlist-modal',
|
||||||
|
'folder-modal',
|
||||||
|
'playlist-select-modal',
|
||||||
|
'shortcuts-modal',
|
||||||
|
'missing-tracks-modal',
|
||||||
|
'sleep-timer-modal',
|
||||||
|
'discography-download-modal',
|
||||||
|
'custom-db-modal',
|
||||||
|
'tracker-modal',
|
||||||
|
'epilepsy-warning-modal',
|
||||||
|
];
|
||||||
|
for (const id of modalIds) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal && modal.classList.contains('active')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAllModals() {
|
||||||
|
// Close all modal overlays
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach((modal) => {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close all modals with active class
|
||||||
|
document.querySelectorAll('.modal.active').forEach((modal) => {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close specific modals by ID
|
||||||
|
const modalIds = [
|
||||||
|
'playlist-modal',
|
||||||
|
'folder-modal',
|
||||||
|
'playlist-select-modal',
|
||||||
|
'shortcuts-modal',
|
||||||
|
'missing-tracks-modal',
|
||||||
|
'sleep-timer-modal',
|
||||||
|
'discography-download-modal',
|
||||||
|
'custom-db-modal',
|
||||||
|
'tracker-modal',
|
||||||
|
'epilepsy-warning-modal',
|
||||||
|
];
|
||||||
|
|
||||||
|
modalIds.forEach((id) => {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const contentBlockingSettings = {
|
export const contentBlockingSettings = {
|
||||||
|
|
|
||||||
2
js/ui.js
2
js/ui.js
|
|
@ -1690,7 +1690,7 @@ export class UIRenderer {
|
||||||
const cardsHTML = [];
|
const cardsHTML = [];
|
||||||
const itemsToStore = [];
|
const itemsToStore = [];
|
||||||
|
|
||||||
for (const item of items.slice(0, 12)) {
|
for (const item of items) {
|
||||||
try {
|
try {
|
||||||
if (item.type === 'album') {
|
if (item.type === 'album') {
|
||||||
// Check if we have cached metadata
|
// Check if we have cached metadata
|
||||||
|
|
|
||||||
|
|
@ -285,14 +285,17 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' }
|
||||||
if (track?.artists?.length) {
|
if (track?.artists?.length) {
|
||||||
return track.artists
|
return track.artists
|
||||||
.map((artist) => {
|
.map((artist) => {
|
||||||
|
const escapedName = escapeHtml(artist.name || 'Unknown Artist');
|
||||||
|
const escapedId = escapeHtml(artist.id || '');
|
||||||
// Check if this is a tracker/unreleased track
|
// Check if this is a tracker/unreleased track
|
||||||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||||||
if (isTracker && track.trackerInfo?.sheetId) {
|
if (isTracker && track.trackerInfo?.sheetId) {
|
||||||
|
const escapedSheetId = escapeHtml(track.trackerInfo.sheetId);
|
||||||
// For tracker tracks, link to the tracker artist page
|
// For tracker tracks, link to the tracker artist page
|
||||||
return `<span class="artist-link tracker-artist-link" data-tracker-sheet-id="${track.trackerInfo.sheetId}">${artist.name}</span>`;
|
return `<span class="artist-link tracker-artist-link" data-tracker-sheet-id="${escapedSheetId}">${escapedName}</span>`;
|
||||||
}
|
}
|
||||||
// For normal tracks, use the artist ID
|
// For normal tracks, use the artist ID
|
||||||
return `<span class="artist-link" data-artist-id="${artist.id}">${artist.name}</span>`;
|
return `<span class="artist-link" data-artist-id="${escapedId}">${escapedName}</span>`;
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,88 @@
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'NL_OS_SHOW_FOLDER_DIALOG':
|
||||||
|
try {
|
||||||
|
const result = await Neutralino.os.showFolderDialog(event.data.title, event.data.options);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, result },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Show Folder Dialog failed:', e);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_FS_READ_BINARY':
|
||||||
|
try {
|
||||||
|
const result = await Neutralino.filesystem.readBinaryFile(event.data.path);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
// result is ArrayBuffer, should be transferable
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, result },
|
||||||
|
'*',
|
||||||
|
[result]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Read Binary File failed:', e);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_FS_READ_DIR':
|
||||||
|
try {
|
||||||
|
const result = await Neutralino.filesystem.readDirectory(event.data.path);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, result },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Read Directory failed:', e);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_FS_STATS':
|
||||||
|
try {
|
||||||
|
const result = await Neutralino.filesystem.getStats(event.data.path);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, result },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Get Stats failed:', e);
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'NL_FS_WRITE_BINARY':
|
case 'NL_FS_WRITE_BINARY':
|
||||||
try {
|
try {
|
||||||
// buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer
|
// buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer
|
||||||
|
|
|
||||||
93
styles.css
93
styles.css
|
|
@ -6563,7 +6563,7 @@ textarea:focus {
|
||||||
.equalizer-preset-row {
|
.equalizer-preset-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-sm);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6857,6 +6857,72 @@ textarea:focus {
|
||||||
margin-left: var(--spacing-xs);
|
margin-left: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* EQ Preamp Controls */
|
||||||
|
.eq-preamp-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-preamp-controls label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eq-preamp-slider {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eq-preamp-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eq-preamp-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eq-preamp-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eq-preamp-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EQ Import/Export Buttons (now inline) */
|
||||||
|
#eq-export-btn,
|
||||||
|
#eq-import-btn {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
/* Equalizer preset dropdown styling */
|
/* Equalizer preset dropdown styling */
|
||||||
.equalizer-preset-row select optgroup {
|
.equalizer-preset-row select optgroup {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -6869,21 +6935,25 @@ textarea:focus {
|
||||||
padding-left: var(--spacing-sm);
|
padding-left: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.equalizer-bands-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
.equalizer-bands {
|
.equalizer-bands {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: var(--spacing-md) 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Zero line indicator */
|
/* Zero line indicator - positioned at center of slider tracks */
|
||||||
.equalizer-bands::before {
|
.equalizer-bands::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 50%;
|
top: 60px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
@ -6891,6 +6961,18 @@ textarea:focus {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* EQ Response Curve Canvas */
|
||||||
|
.eq-response-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-md);
|
||||||
|
left: 4px;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
height: 120px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.eq-band {
|
.eq-band {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -6900,6 +6982,8 @@ textarea:focus {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
cursor: ns-resize;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vertical slider styling */
|
/* Vertical slider styling */
|
||||||
|
|
@ -6911,6 +6995,7 @@ textarea:focus {
|
||||||
height: 120px;
|
height: 120px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue