From 2fce3e382e48f9f8976e92daf6d83d3e9e664ad2 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 17 Feb 2026 17:38:59 +0000 Subject: [PATCH 1/9] make all editors picks show at once and make the line going through equalizer be at midpoint --- js/ui.js | 2 +- package-lock.json | 15 --------------- styles.css | 4 ++-- 3 files changed, 3 insertions(+), 18 deletions(-) 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/package-lock.json b/package-lock.json index 088afe7..2152da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1610,7 +1609,6 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1652,7 +1650,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1696,7 +1693,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3274,7 +3270,6 @@ "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz", "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20" }, @@ -3323,7 +3318,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3347,7 +3341,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3645,7 +3638,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4683,7 +4675,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7305,7 +7296,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7389,7 +7379,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8495,7 +8484,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -8946,7 +8934,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9321,7 +9308,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9758,7 +9744,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/styles.css b/styles.css index 86d160e..6050c52 100644 --- a/styles.css +++ b/styles.css @@ -6877,13 +6877,13 @@ textarea:focus { 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: calc(var(--spacing-md) + 60px); height: 1px; background: var(--border); opacity: 0.5; From f20f3dbb9dcb1b23fc066eaf0c949a78183bcfe0 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 17 Feb 2026 20:22:47 +0000 Subject: [PATCH 2/9] EQ changes --- index.html | 57 +++++++- js/audio-context.js | 147 ++++++++++++++++++- js/equalizer.js | 138 +++++++++++++++++- js/settings.js | 333 ++++++++++++++++++++++++++++++++++++++++++-- js/storage.js | 28 ++++ styles.css | 88 +++++++++++- 6 files changed, 774 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index 5925d9a..fcd7af9 100644 --- a/index.html +++ b/index.html @@ -3770,6 +3770,28 @@ + + +
@@ -3889,10 +3911,41 @@ Reset
+ +
+ + + + dB +
-
- +
+ +
+ +
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/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/settings.js b/js/settings.js index b4832b3..1f71e5a 100644 --- a/js/settings.js +++ b/js/settings.js @@ -886,11 +886,17 @@ export function initializeSettings(scrobbler, player, api, ui) { const resetEqFreqBtn = document.getElementById('reset-eq-freq-btn'); const resetEqRangeBtn = document.getElementById('reset-eq-range-btn'); 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 let currentBandCount = equalizerSettings.getBandCount(); let currentRange = equalizerSettings.getRange(); let currentFreqRange = equalizerSettings.getFreqRange(); + let currentPreamp = equalizerSettings.getPreamp(); /** * 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]); } }); + + // Redraw the EQ curve after updating all bands + drawEQCurve(); }; /** @@ -1012,6 +1021,10 @@ export function initializeSettings(scrobbler, player, api, ui) { const updateEQContainerVisibility = (enabled) => { 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 */ @@ -1084,6 +1243,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 +1264,15 @@ export function initializeSettings(scrobbler, player, api, ui) { slider.value = 0; audioContextManager.setBandGain(bandIndex, 0); updateBandValueDisplay(bandEl, 0); + drawEQCurve(); }); } }); + + // Initial curve draw with delay to ensure canvas has proper dimensions + setTimeout(() => { + drawEQCurve(); + }, 100); }; // Initialize EQ toggle @@ -1119,6 +1285,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 +1304,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 +1317,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 +1672,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 +1825,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) { diff --git a/js/storage.js b/js/storage.js index 60548fe..0e5422a 100644 --- a/js/storage.js +++ b/js/storage.js @@ -797,6 +797,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 +809,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 { @@ -967,6 +971,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 { diff --git a/styles.css b/styles.css index 6050c52..14a3992 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,11 +6935,15 @@ 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; } @@ -6883,7 +6953,7 @@ textarea:focus { position: absolute; left: 0; right: 0; - top: calc(var(--spacing-md) + 60px); + 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; From 120c725233f7b3ef5381c6856ce7bec086595421 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Tue, 17 Feb 2026 23:49:51 +0000 Subject: [PATCH 3/9] guess who goin to jail tonight --- js/settings.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 3 +++ 2 files changed, 63 insertions(+) diff --git a/js/settings.js b/js/settings.js index 1f71e5a..c73e2e2 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1228,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'); @@ -1266,6 +1269,63 @@ export function initializeSettings(scrobbler, player, api, ui) { 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 = ''; } }); diff --git a/styles.css b/styles.css index 14a3992..883de4b 100644 --- a/styles.css +++ b/styles.css @@ -6982,6 +6982,8 @@ textarea:focus { min-width: 0; position: relative; z-index: 1; + cursor: ns-resize; + user-select: none; } /* Vertical slider styling */ @@ -6993,6 +6995,7 @@ textarea:focus { height: 120px; background: transparent; cursor: pointer; + user-select: none; position: relative; } From 58b77e904bfc965590014cbc9a3cee4bb4f04ce3 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 18 Feb 2026 03:17:37 +0000 Subject: [PATCH 4/9] fix codeql stuff --- js/downloads.js | 13 ++++---- js/events.js | 73 ++++++++++++++++++++++------------------- js/playlist-importer.js | 18 ++++++++++ js/settings.js | 13 +++++--- js/storage.js | 56 +++++++++++++++++++++++++++---- 5 files changed, 122 insertions(+), 51 deletions(-) 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/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/playlist-importer.js b/js/playlist-importer.js index ca39d17..b9af91d 100644 --- a/js/playlist-importer.js +++ b/js/playlist-importer.js @@ -277,6 +277,15 @@ 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(' 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); @@ -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() { @@ -1857,7 +1889,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); From 62fe4fca8ee7f3d19ef91db82549f055052b6ff9 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 18 Feb 2026 03:22:20 +0000 Subject: [PATCH 5/9] whoops --- js/playlist-importer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/playlist-importer.js b/js/playlist-importer.js index b9af91d..9e1bbc7 100644 --- a/js/playlist-importer.js +++ b/js/playlist-importer.js @@ -287,7 +287,7 @@ export async function parseXSPF(xspfText, api, onProgress) { } const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xspfText, 'text/xml'); + const xmlDoc = parser.parseFromString(xspfText, 'application/xml'); const trackList = xmlDoc.getElementsByTagName('track'); const tracks = []; @@ -348,7 +348,7 @@ export async function parseXML(xmlText, api, onProgress) { } const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); + const xmlDoc = parser.parseFromString(xmlText, 'application/xml'); // Try different track element names let trackElements = xmlDoc.getElementsByTagName('track'); From 03a7dcda525201e0a6bb117805e585a6a017812b Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 18 Feb 2026 03:30:20 +0000 Subject: [PATCH 6/9] fix codeql suggestions --- extensions/js.neutralino.discordrpc/bridge.py | 28 ++++++--- js/api.js | 4 +- js/playlist-importer.js | 2 - js/storage.js | 63 +++++++++++++++---- 4 files changed, 73 insertions(+), 24 deletions(-) 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(' 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/playlist-importer.js b/js/playlist-importer.js index 9e1bbc7..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 */ diff --git a/js/storage.js b/js/storage.js index 492adac..0950ff7 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); } @@ -233,7 +233,7 @@ export const themeManager = { purple: {}, forest: {}, mocha: {}, - machiatto: {}, + macchiato: {}, frappe: {}, latte: {}, }, @@ -947,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; } } @@ -959,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; } @@ -968,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 { @@ -982,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; @@ -1181,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(); } From 64ff09910bdecf23f1437eecb03eedef5aa44ab2 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 18 Feb 2026 03:50:00 +0000 Subject: [PATCH 7/9] close modals on navigation --- index.html | 8 ++++---- js/app.js | 7 ++++--- js/settings.js | 14 +++++++------- js/storage.js | 42 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index fcd7af9..a564724 100644 --- a/index.html +++ b/index.html @@ -3097,14 +3097,14 @@
- 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
diff --git a/js/app.js b/js/app.js index 984cd72..5507f3b 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'; @@ -2117,9 +2117,10 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - // Close side panel (queue/lyrics) on navigation if setting is enabled - if (queueBehaviorSettings.shouldCloseOnNavigation()) { + // Close side panel (queue/lyrics) and modals on navigation if setting is enabled + if (modalSettings.shouldCloseOnNavigation()) { sidePanelManager.close(); + modalSettings.closeAllModals(); } await router(); diff --git a/js/settings.js b/js/settings.js index 1654e75..88caab0 100644 --- a/js/settings.js +++ b/js/settings.js @@ -32,7 +32,7 @@ import { contentBlockingSettings, musicProviderSettings, analyticsSettings, - queueBehaviorSettings, + modalSettings, } from './storage.js'; import { audioContextManager, EQ_PRESETS } from './audio-context.js'; import { getButterchurnPresets } from './visualizers/butterchurn.js'; @@ -1910,12 +1910,12 @@ 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); }); } diff --git a/js/storage.js b/js/storage.js index 0950ff7..20b28ad 100644 --- a/js/storage.js +++ b/js/storage.js @@ -2208,16 +2208,15 @@ export const musicProviderSettings = { }, }; -export const queueBehaviorSettings = { - STORAGE_KEY: 'queue-close-on-navigation', +export const modalSettings = { + STORAGE_KEY: 'close-modals-on-navigation', shouldCloseOnNavigation() { try { - // Default to true on mobile, false on desktop + // Default to false to preserve existing behavior 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 { @@ -2228,6 +2227,39 @@ export const queueBehaviorSettings = { setCloseOnNavigation(enabled) { localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : '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 = { From a9f3acb289ffa3d5492344e750a12b9404893cb6 Mon Sep 17 00:00:00 2001 From: Eduard Prigoana Date: Wed, 18 Feb 2026 04:19:12 +0000 Subject: [PATCH 8/9] fix4dwayne --- index.html | 13 +++++++++++++ js/app.js | 16 +++++++++++++++- js/player.js | 6 ++++-- js/settings.js | 9 +++++++++ js/storage.js | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- js/utils.js | 7 +++++-- 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index a564724..0cc4e9d 100644 --- a/index.html +++ b/index.html @@ -3108,6 +3108,19 @@
+
+
+ Intercept Back to Close Modals + When pressing back, close open modals/panels first without navigating. + Press back again to actually go back. +
+ +
diff --git a/js/app.js b/js/app.js index 5507f3b..5af1126 100644 --- a/js/app.js +++ b/js/app.js @@ -2117,6 +2117,14 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + // 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(); @@ -2293,6 +2301,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'); @@ -2301,7 +2315,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/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/settings.js b/js/settings.js index 88caab0..a4a03b5 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1919,6 +1919,15 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + // Intercept Back to Close Modals Toggle + const interceptBackToCloseToggle = document.getElementById('intercept-back-to-close-modals-toggle'); + if (interceptBackToCloseToggle) { + interceptBackToCloseToggle.checked = modalSettings.shouldInterceptBackToClose(); + interceptBackToCloseToggle.addEventListener('change', (e) => { + modalSettings.setInterceptBackToClose(e.target.checked); + }); + } + // Compact Artist Toggle const compactArtistToggle = document.getElementById('compact-artist-toggle'); if (compactArtistToggle) { diff --git a/js/storage.js b/js/storage.js index 20b28ad..97fc89f 100644 --- a/js/storage.js +++ b/js/storage.js @@ -2210,10 +2210,10 @@ export const musicProviderSettings = { export const modalSettings = { STORAGE_KEY: 'close-modals-on-navigation', + INTERCEPT_BACK_KEY: 'intercept-back-to-close-modals', shouldCloseOnNavigation() { try { - // Default to false to preserve existing behavior const saved = localStorage.getItem(this.STORAGE_KEY); if (saved === null) { return false; @@ -2228,6 +2228,54 @@ export const modalSettings = { 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) => { 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(', '); } From d05a0ea9c07a5b5cc2468d0b65a9944ac64c00d0 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Wed, 18 Feb 2026 21:33:34 +0100 Subject: [PATCH 9/9] FIX: local files in desktop app --- js/app.js | 104 ++++++++++++++++++++++++-------- js/desktop/neutralino-bridge.js | 59 ++++++++++++++++++ public/neutralino_loader.html | 82 +++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 25 deletions(-) diff --git a/js/app.js b/js/app.js index 5af1126..b9a41ef 100644 --- a/js/app.js +++ b/js/app.js @@ -374,10 +374,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'; } } @@ -1965,10 +1972,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) { @@ -1985,32 +2004,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 || ''; 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/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