diff --git a/extensions/js.neutralino.discordrpc/bridge.py b/extensions/js.neutralino.discordrpc/bridge.py index 240bf23..9c20031 100644 --- a/extensions/js.neutralino.discordrpc/bridge.py +++ b/extensions/js.neutralino.discordrpc/bridge.py @@ -22,7 +22,9 @@ def recv_packet(s): op, length = struct.unpack('
- Close Queue on Navigation + Close Modals on Navigation Close the queue panel when navigating back or to a new page (useful for - mobile)Close open modals and panels (like lyrics, queue) when navigating back or + to a new page
+
+
+
+ Intercept Back to Close Modals + When pressing back, close open modals/panels first without navigating. + Press back again to actually go back. +
+
@@ -3917,6 +3930,28 @@ + + +
@@ -4036,10 +4071,41 @@ Reset
+ +
+ + + + dB +
-
- +
+ +
+ +
diff --git a/js/api.js b/js/api.js index 6d8427c..9a76e82 100644 --- a/js/api.js +++ b/js/api.js @@ -438,7 +438,7 @@ export class LosslessAPI { if (!album) throw new Error('Album not found'); // 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 track = firstTrack.item || firstTrack; 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 && !album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) { + if (!album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) { const firstTrack = tracksSection.items[0]; const track = firstTrack.item || firstTrack; diff --git a/js/app.js b/js/app.js index cf29cc9..4ff1b99 100644 --- a/js/app.js +++ b/js/app.js @@ -7,7 +7,7 @@ import { downloadQualitySettings, sidebarSettings, pwaUpdateSettings, - queueBehaviorSettings, + modalSettings, } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; @@ -377,10 +377,17 @@ document.addEventListener('DOMContentLoaded', async () => { const ua = navigator.userAgent; const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua); 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'; 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')) { const isChange = e.target.closest('#change-local-folder-btn') !== null; try { - const handle = await window.showDirectoryPicker({ - id: 'music-folder', - mode: 'read', - }); + const isNeutralino = + window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino')); + 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); if (isChange) { @@ -1988,32 +2007,67 @@ document.addEventListener('DOMContentLoaded', async () => { const tracks = []; let idCounter = 0; + const { readTrackMetadata } = await loadMetadataModule(); - 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 { readTrackMetadata } = await loadMetadataModule(); - const metadata = await readTrackMetadata(file); - metadata.id = `local-${idCounter++}-${file.name}`; - tracks.push(metadata); + if (isNeutralino) { + async function scanDirectoryNeu(dirPath) { + const entries = await window.Neutralino.filesystem.readDirectory(dirPath); + for (const entry of entries) { + if (entry.entry === '.' || entry.entry === '..') continue; + const fullPath = `${dirPath}/${entry.entry}`; + if (entry.type === 'FILE') { + const name = entry.entry.toLowerCase(); + if ( + name.endsWith('.flac') || + name.endsWith('.mp3') || + name.endsWith('.m4a') || + name.endsWith('.wav') || + name.endsWith('.ogg') + ) { + 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) => { const artistA = a.artist.name || ''; const artistB = b.artist.name || ''; @@ -2120,9 +2174,18 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - // Close side panel (queue/lyrics) on navigation if setting is enabled - if (queueBehaviorSettings.shouldCloseOnNavigation()) { + // Intercept back navigation to close modals first if setting is enabled + if (event && modalSettings.shouldInterceptBackToClose() && modalSettings.hasOpenModalsOrPanels()) { 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(); @@ -2370,6 +2433,12 @@ function showUpdateNotification(updateCallback) { }); } +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + function showMissingTracksNotification(missingTracks) { const modal = document.getElementById('missing-tracks-modal'); const listUl = document.getElementById('missing-tracks-list-ul'); @@ -2378,7 +2447,7 @@ function showMissingTracksNotification(missingTracks) { .map((track) => { const text = typeof track === 'string' ? track : `${track.artist ? track.artist + ' - ' : ''}${track.title}`; - return `
  • ${text}
  • `; + return `
  • ${escapeHtml(text)}
  • `; }) .join(''); diff --git a/js/audio-context.js b/js/audio-context.js index 666e810..7e3670f 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -142,6 +142,8 @@ class AudioContextManager { if (this.isInitialized && this.audioContext) { this._destroyEQ(); this._createEQ(); + // Reconnect the audio graph without interrupting playback + this._connectGraph(); } // Dispatch event for UI update @@ -177,6 +179,8 @@ class AudioContextManager { if (this.isInitialized && this.audioContext) { this._destroyEQ(); this._createEQ(); + // Reconnect the audio graph without interrupting playback + this._connectGraph(); } // Dispatch event for UI update @@ -203,6 +207,16 @@ class AudioContextManager { }); } this.filters = []; + + // Destroy preamp node + if (this.preampNode) { + try { + this.preampNode.disconnect(); + } catch { + /* ignore */ + } + this.preampNode = null; + } } /** @@ -211,6 +225,15 @@ class AudioContextManager { _createEQ() { 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 this.filters = this.frequencies.map((freq, index) => { const filter = this.audioContext.createBiquadFilter(); @@ -366,13 +389,18 @@ class AudioContextManager { } 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 for (let i = 0; i < this.filters.length - 1; i++) { this.filters[i].connect(this.filters[i + 1]); } - // Connect input to first filter and last filter to output - lastNode.connect(this.filters[0]); + // Connect preamp to first filter + 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.outputNode.connect(this.analyser); this.analyser.connect(this.volumeNode); @@ -609,6 +637,119 @@ class AudioContextManager { this.frequencies = generateFrequencies(this.bandCount, this.freqRange.min, this.freqRange.max); this.currentGains = equalizerSettings.getGains(this.bandCount); 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; + } } } diff --git a/js/desktop/neutralino-bridge.js b/js/desktop/neutralino-bridge.js index c4ad4d4..92eb066 100644 --- a/js/desktop/neutralino-bridge.js +++ b/js/desktop/neutralino-bridge.js @@ -84,9 +84,68 @@ export const os = { 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 = { + 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) => { if (!isNeutralino) return; return new Promise((resolve, reject) => { diff --git a/js/downloads.js b/js/downloads.js index df5f4cf..559fb72 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -9,6 +9,7 @@ import { SVG_CLOSE, getCoverBlob, getExtensionFromBlob, + escapeHtml, } from './utils.js'; import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js'; import { addMetadataToAudio } from './metadata.js'; @@ -45,11 +46,11 @@ export function showNotification(message) { const notifEl = document.createElement('div'); notifEl.className = 'download-task'; - notifEl.innerHTML = ` -
    - ${message} -
    - `; + const innerDiv = document.createElement('div'); + innerDiv.style.display = 'flex'; + innerDiv.style.alignItems = 'start'; + innerDiv.textContent = message; + notifEl.appendChild(innerDiv); container.appendChild(notifEl); @@ -1019,7 +1020,7 @@ function createBulkDownloadNotification(type, name, _totalItems) {
    Downloading ${typeLabel}
    -
    ${name}
    +
    ${escapeHtml(name)}
    diff --git a/js/equalizer.js b/js/equalizer.js index 6c87bb6..09b70d8 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -174,6 +174,9 @@ export class Equalizer { // Store current gains this.currentGains = new Array(this.bandCount).fill(0); + // Store current preamp value + this.preamp = 0; + // Load saved settings this._loadSettings(); } @@ -290,6 +293,10 @@ export class Equalizer { this.inputNode = this.audioContext.createGain(); this.outputNode = this.audioContext.createGain(); + // Create preamp gain node + this.preampNode = this.audioContext.createGain(); + this._updatePreampGain(); + // Connect the filter chain this._connectFilters(); @@ -325,6 +332,11 @@ export class Equalizer { _connectFilters() { if (!this.filters.length) return; + // Connect preamp to first filter + if (this.preampNode) { + this.preampNode.connect(this.filters[0]); + } + // Chain filters together for (let i = 0; i < this.filters.length - 1; i++) { this.filters[i].connect(this.filters[i + 1]); @@ -356,7 +368,7 @@ export class Equalizer { * Get the input node for external connection */ 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.frequencyLabels = generateFrequencyLabels(this.frequencies); 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 { /* ignore */ } + try { + this.preampNode?.disconnect(); + } catch { + /* ignore */ + } this.filters = []; this.inputNode = null; this.outputNode = null; + this.preampNode = null; 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 diff --git a/js/events.js b/js/events.js index da37696..6cea0c6 100644 --- a/js/events.js +++ b/js/events.js @@ -11,6 +11,7 @@ import { getTrackArtists, positionMenu, getShareUrl, + escapeHtml, } from './utils.js'; import { lastFMStorage, libreFmSettings, waveformSettings } from './storage.js'; import { showNotification, downloadTrackWithMetadata, downloadAlbumAsZip, downloadPlaylistAsZip } from './downloads.js'; @@ -1213,25 +1214,25 @@ export async function handleTrackAction( infoHTML = `
    -

    ${item.title}

    +

    ${escapeHtml(item.title)}

    Unreleased Track

    - ${item.artists ? `

    Artist: ${Array.isArray(item.artists) ? item.artists.map((a) => a.name || a).join(', ') : item.artists}

    ` : ''} - ${item.trackerInfo.artist ? `

    Tracked Artist: ${item.trackerInfo.artist}

    ` : ''} - ${item.trackerInfo.project ? `

    Project: ${item.trackerInfo.project}

    ` : ''} - ${item.trackerInfo.era ? `

    Era: ${item.trackerInfo.era}

    ` : ''} - ${item.trackerInfo.timeline ? `

    Timeline: ${item.trackerInfo.timeline}

    ` : ''} - ${item.trackerInfo.category ? `

    Category: ${item.trackerInfo.category}

    ` : ''} - ${item.trackerInfo.trackNumber ? `

    Track Number: ${item.trackerInfo.trackNumber}

    ` : ''} -

    Duration: ${formatTime(item.duration)}

    - ${releaseDate !== 'Unknown' ? `

    Release Date: ${dateDisplay}

    ` : ''} - ${item.trackerInfo.addedDate ? `

    Added to Tracker: ${addedDate}

    ` : ''} - ${item.trackerInfo.leakedDate ? `

    Leak Date: ${new Date(item.trackerInfo.leakedDate).toLocaleDateString()}

    ` : ''} - ${item.trackerInfo.recordingDate ? `

    Recording Date: ${new Date(item.trackerInfo.recordingDate).toLocaleDateString()}

    ` : ''} + ${item.artists ? `

    Artist: ${escapeHtml(Array.isArray(item.artists) ? item.artists.map((a) => a.name || a).join(', ') : item.artists)}

    ` : ''} + ${item.trackerInfo.artist ? `

    Tracked Artist: ${escapeHtml(item.trackerInfo.artist)}

    ` : ''} + ${item.trackerInfo.project ? `

    Project: ${escapeHtml(item.trackerInfo.project)}

    ` : ''} + ${item.trackerInfo.era ? `

    Era: ${escapeHtml(item.trackerInfo.era)}

    ` : ''} + ${item.trackerInfo.timeline ? `

    Timeline: ${escapeHtml(item.trackerInfo.timeline)}

    ` : ''} + ${item.trackerInfo.category ? `

    Category: ${escapeHtml(item.trackerInfo.category)}

    ` : ''} + ${item.trackerInfo.trackNumber ? `

    Track Number: ${escapeHtml(String(item.trackerInfo.trackNumber))}

    ` : ''} +

    Duration: ${escapeHtml(formatTime(item.duration))}

    + ${releaseDate !== 'Unknown' ? `

    Release Date: ${escapeHtml(dateDisplay)}

    ` : ''} + ${item.trackerInfo.addedDate ? `

    Added to Tracker: ${escapeHtml(addedDate)}

    ` : ''} + ${item.trackerInfo.leakedDate ? `

    Leak Date: ${escapeHtml(new Date(item.trackerInfo.leakedDate).toLocaleDateString())}

    ` : ''} + ${item.trackerInfo.recordingDate ? `

    Recording Date: ${escapeHtml(new Date(item.trackerInfo.recordingDate).toLocaleDateString())}

    ` : ''}
    ${ @@ -1239,7 +1240,7 @@ export async function handleTrackAction( ? `

    Description

    -

    ${item.trackerInfo.description}

    +

    ${escapeHtml(item.trackerInfo.description)}

    ` : '' @@ -1250,7 +1251,7 @@ export async function handleTrackAction( ? `

    Notes

    -

    ${item.trackerInfo.notes}

    +

    ${escapeHtml(item.trackerInfo.notes)}

    ` : '' @@ -1261,17 +1262,17 @@ export async function handleTrackAction( ? ` ` : '' } - ${item.id ? `

    Track ID: ${item.id}

    ` : ''} + ${item.id ? `

    Track ID: ${escapeHtml(item.id)}

    ` : ''}
    - +
    `; } else { @@ -1283,19 +1284,19 @@ export async function handleTrackAction( infoHTML = `
    -

    ${item.title}

    +

    ${escapeHtml(item.title)}

    -

    Artist: ${getTrackArtists(item)}

    -

    Album: ${item.album?.title || 'Unknown'}

    - ${item.album?.artist?.name ? `

    Album Artist: ${item.album.artist.name}

    ` : ''} -

    Release Date: ${dateDisplay}

    -

    Duration: ${formatTime(item.duration)}

    - ${item.trackNumber ? `

    Track Number: ${item.trackNumber}

    ` : ''} - ${item.discNumber ? `

    Disc Number: ${item.discNumber}

    ` : ''} - ${item.version ? `

    Version: ${item.version}

    ` : ''} +

    Artist: ${escapeHtml(getTrackArtists(item))}

    +

    Album: ${escapeHtml(item.album?.title || 'Unknown')}

    + ${item.album?.artist?.name ? `

    Album Artist: ${escapeHtml(item.album.artist.name)}

    ` : ''} +

    Release Date: ${escapeHtml(dateDisplay)}

    +

    Duration: ${escapeHtml(formatTime(item.duration))}

    + ${item.trackNumber ? `

    Track Number: ${escapeHtml(String(item.trackNumber))}

    ` : ''} + ${item.discNumber ? `

    Disc Number: ${escapeHtml(String(item.discNumber))}

    ` : ''} + ${item.version ? `

    Version: ${escapeHtml(item.version)}

    ` : ''} ${item.explicit ? `

    Explicit: Yes

    ` : ''} -

    Quality: ${quality} ${bitrate ? `(${bitrate})` : ''}

    +

    Quality: ${escapeHtml(quality)} ${bitrate ? `(${escapeHtml(bitrate)})` : ''}

    ${ @@ -1304,7 +1305,7 @@ export async function handleTrackAction(

    Credits

    - ${item.credits.map((c) => `

    ${c.type}: ${c.name}

    `).join('')} + ${item.credits.map((c) => `

    ${escapeHtml(c.type)}: ${escapeHtml(c.name)}

    `).join('')}
    ` @@ -1314,7 +1315,7 @@ export async function handleTrackAction( ${ item.composers && item.composers.length > 0 ? ` -

    Composers: ${item.composers.map((c) => c.name).join(', ')}

    +

    Composers: ${escapeHtml(item.composers.map((c) => c.name).join(', '))}

    ` : '' } @@ -1329,10 +1330,10 @@ export async function handleTrackAction( : '' } - ${item.id ? `

    Track ID: ${item.id}

    ` : ''} - ${item.album?.id ? `

    Album ID: ${item.album.id}

    ` : ''} + ${item.id ? `

    Track ID: ${escapeHtml(item.id)}

    ` : ''} + ${item.album?.id ? `

    Album ID: ${escapeHtml(item.album.id)}

    ` : ''}
    - +
    `; } @@ -1346,6 +1347,10 @@ export async function handleTrackAction( modal.onclick = (e) => { if (e.target === modal) modal.remove(); }; + const closeBtn = modal.querySelector('.track-info-close-btn'); + if (closeBtn) { + closeBtn.onclick = () => modal.remove(); + } document.body.appendChild(modal); } else if (action === 'open-original-url') { // Open the original source URL for the track diff --git a/js/player.js b/js/player.js index 4839f4e..a094d17 100644 --- a/js/player.js +++ b/js/player.js @@ -8,6 +8,7 @@ import { getTrackArtistsHTML, getTrackYearDisplay, createQualityBadgeHTML, + escapeHtml, } from './utils.js'; import { queueManager, @@ -166,7 +167,7 @@ export class Player { if (coverEl) coverEl.src = this.api.getCoverUrl(track.album?.cover); if (titleEl) { const qualityBadge = createQualityBadgeHTML(track); - titleEl.innerHTML = `${trackTitle} ${qualityBadge}`; + titleEl.innerHTML = `${escapeHtml(trackTitle)} ${qualityBadge}`; } if (albumEl) { const albumTitle = track.album?.title || ''; @@ -356,7 +357,8 @@ export class Player { const yearDisplay = getTrackYearDisplay(track); 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'); if (albumEl) { const albumTitle = track.album?.title || ''; diff --git a/js/playlist-importer.js b/js/playlist-importer.js index ca39d17..24675d6 100644 --- a/js/playlist-importer.js +++ b/js/playlist-importer.js @@ -1,5 +1,3 @@ -import { sanitizeForFilename } from './utils.js'; - /** * Helper function to get track artists string */ @@ -277,8 +275,17 @@ export async function parseJSPF(jspfText, api, onProgress) { * @returns {Promise<{tracks: Array, missingTracks: Array}>} */ 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('} */ 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(' { if (eqContainer) { 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'; }; + /** + * 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 */ @@ -1069,6 +1228,9 @@ export function initializeSettings(scrobbler, player, api, ui) { const savedGains = equalizerSettings.getGains(currentBandCount); + // FL Studio-style absolute position drag state + let isDragging = false; + eqBands.forEach((bandEl) => { const bandIndex = parseInt(bandEl.dataset.band, 10); const slider = bandEl.querySelector('.eq-slider'); @@ -1084,6 +1246,7 @@ export function initializeSettings(scrobbler, player, api, ui) { const gain = parseFloat(e.target.value); audioContextManager.setBandGain(bandIndex, gain); updateBandValueDisplay(bandEl, gain); + drawEQCurve(); // When manually adjusting, check if we should clear preset if (eqPresetSelect && eqPresetSelect.value !== 'flat') { @@ -1104,9 +1267,72 @@ export function initializeSettings(scrobbler, player, api, ui) { slider.value = 0; audioContextManager.setBandGain(bandIndex, 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 @@ -1119,6 +1345,13 @@ export function initializeSettings(scrobbler, player, api, ui) { const enabled = e.target.checked; audioContextManager.toggleEQ(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) { currentBandCount = newCount; - // Save new band count and update audio context + // Save new band count and update audio context (interpolates gains automatically) equalizerSettings.setBandCount(newCount); - audioContextManager.setBandCount?.(newCount) || audioContextManager.reinitialize?.(); + audioContextManager.setBandCount?.(newCount); // Regenerate UI generateEQBands( @@ -1144,16 +1377,25 @@ export function initializeSettings(scrobbler, player, api, ui) { currentFreqRange.max ); - // Reset to flat and apply - const flatGains = new Array(newCount).fill(0); - audioContextManager.setAllGains(flatGains); - updateAllBandUI(flatGains); + // Get interpolated gains from audio context + const interpolatedGains = audioContextManager.getGains?.() || equalizerSettings.getGains(newCount); + updateAllBandUI(interpolatedGains); + // Keep current preset or set to custom if modified if (eqPresetSelect) { - eqPresetSelect.value = 'flat'; - equalizerSettings.setPreset('flat'); + const currentPreset = eqPresetSelect.value; + if (!currentPreset.startsWith('custom_')) { + eqPresetSelect.value = 'custom'; + } } 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 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 const nowPlayingMode = document.getElementById('now-playing-mode'); if (nowPlayingMode) { @@ -1533,12 +1910,21 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } - // Queue Close on Navigation Toggle - const queueCloseOnNavigationToggle = document.getElementById('queue-close-on-navigation-toggle'); - if (queueCloseOnNavigationToggle) { - queueCloseOnNavigationToggle.checked = queueBehaviorSettings.shouldCloseOnNavigation(); - queueCloseOnNavigationToggle.addEventListener('change', (e) => { - queueBehaviorSettings.setCloseOnNavigation(e.target.checked); + // Close Modals on Navigation Toggle + const closeModalsOnNavigationToggle = document.getElementById('close-modals-on-navigation-toggle'); + if (closeModalsOnNavigationToggle) { + closeModalsOnNavigationToggle.checked = modalSettings.shouldCloseOnNavigation(); + closeModalsOnNavigationToggle.addEventListener('change', (e) => { + modalSettings.setCloseOnNavigation(e.target.checked); + }); + } + + // Intercept Back to Close Modals Toggle + const interceptBackToCloseToggle = document.getElementById('intercept-back-to-close-modals-toggle'); + if (interceptBackToCloseToggle) { + interceptBackToCloseToggle.checked = modalSettings.shouldInterceptBackToClose(); + interceptBackToCloseToggle.addEventListener('change', (e) => { + modalSettings.setInterceptBackToClose(e.target.checked); }); } @@ -2513,11 +2899,16 @@ function initializeFontSettings() { let fontName = input; // Check if it's a Google Fonts URL - if (input.includes('fonts.google.com')) { - const parsed = fontSettings.parseGoogleFontsUrl(input); - if (parsed) { - fontName = parsed; + try { + const urlObj = new URL(input); + if (urlObj.hostname === 'fonts.google.com') { + const parsed = fontSettings.parseGoogleFontsUrl(input); + if (parsed) { + fontName = parsed; + } } + } catch { + // Not a URL, treat as font name } fontSettings.loadGoogleFont(fontName); diff --git a/js/storage.js b/js/storage.js index 10ff337..e4a0d99 100644 --- a/js/storage.js +++ b/js/storage.js @@ -29,7 +29,7 @@ export const apiSettings = { if (isSimpleArray) { groupedInstances.api = [...data.api]; } 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)) { groupedInstances.api.push(...config.urls); } @@ -95,8 +95,22 @@ export const apiSettings = { // love it when local storage doesnt update if (instancesObj?.api?.length === 2) { - const hasBinimum = instancesObj.api.some((url) => url.includes('tidal-api.binimum.org')); - const hasSamidy = instancesObj.api.some((url) => url.includes('monochrome-api.samidy.com')); + const hasBinimum = instancesObj.api.some((url) => { + 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) { localStorage.removeItem(this.STORAGE_KEY); @@ -219,7 +233,7 @@ export const themeManager = { purple: {}, forest: {}, mocha: {}, - machiatto: {}, + macchiato: {}, frappe: {}, 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 = { STORAGE_KEY: 'lastfm-enabled', LOVE_ON_LIKE_KEY: 'lastfm-love-on-like', @@ -338,26 +368,28 @@ export const lastFMStorage = { getCustomApiKey() { try { - return localStorage.getItem(this.CUSTOM_API_KEY) || ''; + const stored = localStorage.getItem(this.CUSTOM_API_KEY); + return decodeSensitiveData(stored) || ''; } catch { return ''; } }, setCustomApiKey(key) { - localStorage.setItem(this.CUSTOM_API_KEY, key); + localStorage.setItem(this.CUSTOM_API_KEY, encodeSensitiveData(key)); }, getCustomApiSecret() { try { - return localStorage.getItem(this.CUSTOM_API_SECRET) || ''; + const stored = localStorage.getItem(this.CUSTOM_API_SECRET); + return decodeSensitiveData(stored) || ''; } catch { return ''; } }, setCustomApiSecret(secret) { - localStorage.setItem(this.CUSTOM_API_SECRET, secret); + localStorage.setItem(this.CUSTOM_API_SECRET, encodeSensitiveData(secret)); }, clearCustomCredentials() { @@ -797,6 +829,7 @@ export const equalizerSettings = { RANGE_MAX_KEY: 'equalizer-range-max', FREQ_MIN_KEY: 'equalizer-freq-min', FREQ_MAX_KEY: 'equalizer-freq-max', + PREAMP_KEY: 'equalizer-preamp', DEFAULT_BAND_COUNT: 16, MIN_BANDS: 3, MAX_BANDS: 32, @@ -808,6 +841,9 @@ export const equalizerSettings = { DEFAULT_FREQ_MAX: 20000, ABSOLUTE_FREQ_MIN: 10, ABSOLUTE_FREQ_MAX: 96000, + DEFAULT_PREAMP: 0, + PREAMP_MIN: -20, + PREAMP_MAX: 20, isEnabled() { try { @@ -911,7 +947,7 @@ export const equalizerSettings = { const stored = localStorage.getItem(this.FREQ_MIN_KEY); if (stored) { 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; } } @@ -923,7 +959,20 @@ export const equalizerSettings = { setFreqMin(value) { 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()); return true; } @@ -932,11 +981,23 @@ export const equalizerSettings = { getFreqMax() { try { - const stored = localStorage.getItem(this.FREQ_MAX_KEY); - if (stored) { - const val = parseInt(stored, 10); - if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) { - return val; + const storedMax = localStorage.getItem(this.FREQ_MAX_KEY); + if (storedMax) { + const maxVal = parseInt(storedMax, 10); + if (!isNaN(maxVal) && maxVal > this.ABSOLUTE_FREQ_MIN && maxVal <= this.ABSOLUTE_FREQ_MAX) { + // 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 { @@ -946,9 +1007,21 @@ export const equalizerSettings = { }, setFreqMax(value) { - const val = parseInt(value, 10); - if (!isNaN(val) && val > this.getFreqMin() && val <= this.ABSOLUTE_FREQ_MAX) { - localStorage.setItem(this.FREQ_MAX_KEY, val.toString()); + const maxVal = parseInt(value, 10); + if (!isNaN(maxVal) && maxVal > this.ABSOLUTE_FREQ_MIN && maxVal <= this.ABSOLUTE_FREQ_MAX) { + // 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 false; @@ -967,6 +1040,30 @@ export const equalizerSettings = { 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) { const count = bandCount || this.getBandCount(); 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].updatedAt = Date.now(); } @@ -1813,7 +1910,17 @@ export const fontSettings = { }, 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`; let link = document.getElementById(this.FONT_LINK_ID); @@ -2085,16 +2192,15 @@ export const musicProviderSettings = { }, }; -export const queueBehaviorSettings = { - STORAGE_KEY: 'queue-close-on-navigation', +export const modalSettings = { + STORAGE_KEY: 'close-modals-on-navigation', + INTERCEPT_BACK_KEY: 'intercept-back-to-close-modals', shouldCloseOnNavigation() { try { - // Default to true on mobile, false on desktop const saved = localStorage.getItem(this.STORAGE_KEY); if (saved === null) { - // Auto-detect: default to true for mobile/touch devices - return window.matchMedia('(pointer: coarse)').matches; + return false; } return saved === 'true'; } catch { @@ -2105,6 +2211,87 @@ export const queueBehaviorSettings = { setCloseOnNavigation(enabled) { 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 = { diff --git a/js/ui.js b/js/ui.js index b066efc..121b6bc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1690,7 +1690,7 @@ export class UIRenderer { const cardsHTML = []; const itemsToStore = []; - for (const item of items.slice(0, 12)) { + for (const item of items) { try { if (item.type === 'album') { // Check if we have cached metadata diff --git a/js/utils.js b/js/utils.js index 10be675..d95c626 100644 --- a/js/utils.js +++ b/js/utils.js @@ -285,14 +285,17 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } if (track?.artists?.length) { return track.artists .map((artist) => { + const escapedName = escapeHtml(artist.name || 'Unknown Artist'); + const escapedId = escapeHtml(artist.id || ''); // Check if this is a tracker/unreleased track const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-')); if (isTracker && track.trackerInfo?.sheetId) { + const escapedSheetId = escapeHtml(track.trackerInfo.sheetId); // For tracker tracks, link to the tracker artist page - return `${artist.name}`; + return `${escapedName}`; } // For normal tracks, use the artist ID - return `${artist.name}`; + return `${escapedName}`; }) .join(', '); } diff --git a/public/neutralino_loader.html b/public/neutralino_loader.html index e91542c..0b7c3a7 100644 --- a/public/neutralino_loader.html +++ b/public/neutralino_loader.html @@ -228,6 +228,88 @@ } 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': try { // buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer diff --git a/styles.css b/styles.css index ebd4065..58d2fa2 100644 --- a/styles.css +++ b/styles.css @@ -6563,7 +6563,7 @@ textarea:focus { .equalizer-preset-row { display: flex; align-items: center; - gap: var(--spacing-md); + gap: var(--spacing-sm); flex-wrap: wrap; } @@ -6857,6 +6857,72 @@ textarea:focus { 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-row select optgroup { font-weight: 600; @@ -6869,21 +6935,25 @@ textarea:focus { padding-left: var(--spacing-sm); } +.equalizer-bands-wrapper { + position: relative; + padding: var(--spacing-md) 0; +} + .equalizer-bands { display: flex; justify-content: space-between; gap: 4px; - padding: var(--spacing-md) 0; position: relative; } -/* Zero line indicator */ +/* Zero line indicator - positioned at center of slider tracks */ .equalizer-bands::before { content: ''; position: absolute; left: 0; right: 0; - top: 50%; + top: 60px; height: 1px; background: var(--border); opacity: 0.5; @@ -6891,6 +6961,18 @@ textarea:focus { 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 { display: flex; flex-direction: column; @@ -6900,6 +6982,8 @@ textarea:focus { min-width: 0; position: relative; z-index: 1; + cursor: ns-resize; + user-select: none; } /* Vertical slider styling */ @@ -6911,6 +6995,7 @@ textarea:focus { height: 120px; background: transparent; cursor: pointer; + user-select: none; position: relative; }