From 0b1bb3cd119f68ba9ac0dfd6d16a61d2ec395b71 Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sat, 4 Apr 2026 23:48:20 -0400 Subject: [PATCH 01/76] Refine fullscreen player to look more like apple music --- index.html | 156 +++++++------ js/icons.ts | 2 + js/lyrics.js | 69 ++++++ js/ui.js | 313 +++++++++++++++++++------- styles.css | 621 ++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1003 insertions(+), 158 deletions(-) diff --git a/index.html b/index.html index 8ba694e..2898360 100644 --- a/index.html +++ b/index.html @@ -144,77 +144,99 @@ - -
- Album Cover +
+ + +
+
+
+
+
+ Album Cover +
-
-

-

-
- - - - - -
- -
-
-
- 0:00 -
-
+
+
+

+

+
+
+ + + + + +
+
- 0:00 -
-
- - - - - - - -
-
- -
-
+ +
+
+ 0:00 +
+
+
+ 0:00 +
+
+ + + + + + + +
+
+ +
+
+
+
+ +
diff --git a/js/icons.ts b/js/icons.ts index 39ebf20..a2ed6ec 100644 --- a/js/icons.ts +++ b/js/icons.ts @@ -11,6 +11,8 @@ export { default as SVG_CLOCK } from '!lucide/clock.svg?svg&icon'; export { default as SVG_CLOSE } from '!lucide/x.svg?svg&icon'; export { default as SVG_DISC } from '!lucide/disc.svg?svg&icon'; export { default as SVG_DOWNLOAD } from '!lucide/download.svg?svg&icon'; +export { default as SVG_EYE } from '!lucide/eye.svg?svg&icon'; +export { default as SVG_EYE_OFF } from '!lucide/eye-off.svg?svg&icon'; export { default as SVG_EQUAL } from '!lucide/equal.svg?svg&icon'; export { default as SVG_FACEBOOK } from '../images/facebook.svg?svg&icon'; export { default as SVG_FOLDER_PLUS } from '!lucide/folder-plus.svg?svg&icon'; diff --git a/js/lyrics.js b/js/lyrics.js index e59ffae..62f245e 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -964,6 +964,74 @@ themeObserver.observe(document.documentElement, { attributeFilter: ['data-theme', 'style'], }); +function applyFullscreenLyricsShadowTweaks(amLyrics, container) { + if (!amLyrics || container?.id !== 'fullscreen-lyrics-content') return; + + const injectStyle = () => { + const root = amLyrics.shadowRoot; + if (!root) return false; + + let styleEl = root.getElementById('monochrome-fullscreen-lyrics-tweaks'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'monochrome-fullscreen-lyrics-tweaks'; + root.appendChild(styleEl); + } + + styleEl.textContent = ` + .lyrics-container { + scrollbar-width: none !important; + -ms-overflow-style: none !important; + } + + .lyrics-container::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; + background: transparent !important; + } + + .lyrics-line { + transition: + opacity 0.42s ease, + transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms), + filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important; + } + + .lyrics-line-container { + transition: + transform 0.72s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.3s ease, + color 0.3s ease !important; + } + + .lyrics-line.active .lyrics-line-container, + .lyrics-line.pre-active .lyrics-line-container { + transition: + transform 0.56s cubic-bezier(0.22, 1, 0.36, 1), + background-color 0.22s ease, + color 0.22s ease !important; + } + `; + + return true; + }; + + if (injectStyle()) return; + + let attempts = 0; + const maxAttempts = 24; + const tryInject = () => { + if (injectStyle()) return; + attempts += 1; + if (attempts < maxAttempts) { + requestAnimationFrame(tryInject); + } + }; + + requestAnimationFrame(tryInject); +} + async function renderLyricsComponent(container, track, audioPlayer, lyricsManager) { container.innerHTML = '
Loading lyrics...
'; @@ -1006,6 +1074,7 @@ async function renderLyricsComponent(container, track, audioPlayer, lyricsManage amLyrics.style.width = '100%'; container.appendChild(amLyrics); + applyFullscreenLyricsShadowTweaks(amLyrics, container); lyricsManager.setupLyricsObserver(amLyrics); diff --git a/js/ui.js b/js/ui.js index 8019611..8f4c3c6 100644 --- a/js/ui.js +++ b/js/ui.js @@ -15,7 +15,7 @@ import { escapeHtml, getShareUrl, } from './utils.js'; -import { openLyricsPanel } from './lyrics.js'; +import { openLyricsPanel, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js'; import { recentActivityManager, backgroundSettings, @@ -27,9 +27,6 @@ import { contentBlockingSettings, settingsUiState, fullscreenCoverNoRoundSettings, - fullscreenCoverVanillaTiltSettings, - fullscreenCoverTiltDistanceSettings, - fullscreenCoverTiltSpeedSettings, } from './storage.js'; import { db } from './db.js'; import { getVibrantColorFromImage } from './vibrant-color.js'; @@ -61,6 +58,8 @@ import { SVG_HEART, SVG_VOLUME, SVG_MUTE, + SVG_EYE, + SVG_EYE_OFF, SVG_HEART_FILLED, SVG_CLOSE, SVG_SORT, @@ -89,6 +88,11 @@ import { SVG_CHECKBOX, } from './icons.js'; +const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => { + if (!button) return; + button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24); +}; + function sortTracks(tracks, sortType) { if (sortType === 'custom') return [...tracks]; const sorted = [...tracks]; @@ -151,6 +155,8 @@ export class UIRenderer { this.renderLock = false; this.lastRecommendedTracks = []; this.currentArtistId = null; + this.fullscreenLyricsVisible = true; + this.fullscreenPlaybackStateCleanup = null; // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { @@ -177,20 +183,8 @@ export class UIRenderer { } else { overlay.classList.remove('fullscreen-cover-no-round'); } - if (coverImage) { - if (fullscreenCoverVanillaTiltSettings.isEnabled() && window.VanillaTilt) { - if (coverImage.vanillaTilt) { - coverImage.vanillaTilt.destroy(); - } - window.VanillaTilt.init(coverImage, { - max: fullscreenCoverTiltDistanceSettings.getValue(), - speed: fullscreenCoverTiltSpeedSettings.getValue(), - glare: true, - 'max-glare': 0.3, - }); - } else if (coverImage.vanillaTilt) { - coverImage.vanillaTilt.destroy(); - } + if (coverImage?.vanillaTilt) { + coverImage.vanillaTilt.destroy(); } } }); @@ -1117,6 +1111,23 @@ export class UIRenderer { root.style.removeProperty('--track-hover-bg'); } + getFullscreenQualityBadgeHTML(track) { + const nowPlayingTitle = document.querySelector('.now-playing-bar .title'); + if (nowPlayingTitle && this.player?.currentTrack?.id === track?.id) { + const badges = Array.from(nowPlayingTitle.querySelectorAll('.shaka-quality-badge, .quality-badge')); + const liveBadge = badges.find((badge) => getComputedStyle(badge).display !== 'none') || badges[0]; + if (liveBadge) { + const badgeClone = liveBadge.cloneNode(true); + if (badgeClone instanceof HTMLElement) { + badgeClone.style.removeProperty('display'); + } + return badgeClone.outerHTML; + } + } + + return createQualityBadgeHTML(track); + } + async updateFullscreenMetadata(track, nextTrack) { if (!track) return; const overlay = document.getElementById('fullscreen-cover-overlay'); @@ -1214,7 +1225,7 @@ export class UIRenderer { await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80')); } - const qualityBadge = createQualityBadgeHTML(track); + const qualityBadge = this.getFullscreenQualityBadgeHTML(track); title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`; artist.textContent = getTrackArtists(track); @@ -1228,11 +1239,14 @@ export class UIRenderer { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) { if (!track) return; + this.fullscreenVisualizerSuppressed = true; if (window.location.hash !== '#fullscreen') { window.history.pushState({ fullscreen: true }, '', '#fullscreen'); } const overlay = document.getElementById('fullscreen-cover-overlay'); const nextTrackEl = document.getElementById('fullscreen-next-track'); + const lyricsPane = document.getElementById('fullscreen-lyrics-pane'); + const lyricsContent = document.getElementById('fullscreen-lyrics-content'); const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); await this.updateFullscreenMetadata(track, nextTrack); @@ -1245,27 +1259,33 @@ export class UIRenderer { nextTrackEl.classList.remove('animate-in'); } - if (lyricsManager && activeElement) { - lyricsToggleBtn.style.display = 'flex'; - lyricsToggleBtn.classList.remove('active'); - - const toggleLyrics = () => { - openLyricsPanel(track, activeElement, lyricsManager); - lyricsToggleBtn.classList.toggle('active'); - }; - - const newToggleBtn = lyricsToggleBtn.cloneNode(true); - lyricsToggleBtn.parentNode.replaceChild(newToggleBtn, lyricsToggleBtn); - newToggleBtn.addEventListener('click', toggleLyrics); + const canRenderLyrics = Boolean(lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'); + if (canRenderLyrics) { + lyricsToggleBtn.style.display = 'none'; + overlay.classList.remove('lyrics-unavailable'); + clearFullscreenLyricsSync(lyricsContent); + await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent); } else { lyricsToggleBtn.style.display = 'none'; + overlay.classList.add('lyrics-unavailable'); + if (lyricsContent) { + clearFullscreenLyricsSync(lyricsContent); + lyricsContent.innerHTML = '
Lyrics are not available for this track.
'; + } } const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.display = 'none'; + if (sidePanelManager.isActive('lyrics') || sidePanelManager.isActive('queue')) { + sidePanelManager.close(); + } + const mainContent = document.querySelector('.main-content'); + if (mainContent instanceof HTMLElement) { + this.fullscreenMainContentOverflow = mainContent.style.overflowY; + mainContent.style.overflowY = 'hidden'; + } this.setupFullscreenControls(); - overlay.style.display = 'flex'; if (fullscreenCoverNoRoundSettings.isEnabled()) { @@ -1275,75 +1295,41 @@ export class UIRenderer { } const coverImage = document.getElementById('fullscreen-cover-image'); - if (fullscreenCoverVanillaTiltSettings.isEnabled() && coverImage && window.VanillaTilt) { - window.VanillaTilt.init(coverImage, { - max: fullscreenCoverTiltDistanceSettings.getValue(), - speed: fullscreenCoverTiltSpeedSettings.getValue(), - glare: true, - 'max-glare': 0.3, - }); + if (coverImage?.vanillaTilt) { + coverImage.vanillaTilt.destroy(); } - const startVisualizer = async () => { - if (!visualizerSettings.isEnabled()) { - if (this.visualizer) this.visualizer.stop(); - return; - } - - if (!this.visualizer && activeElement) { - const canvas = document.getElementById('visualizer-canvas'); - if (canvas) { - this.visualizer = new Visualizer(canvas, activeElement); - await this.visualizer.initPresets(); - } - } - if (this.visualizer) { - await this.visualizer.start(); - } - - // Add visualizer-active class for enhanced drop shadow - overlay.classList.add('visualizer-active'); - }; - // Setup UI toggle button this.setupUIToggleButton(overlay); - - if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') { - await startVisualizer(); - } else { - const modal = document.getElementById('epilepsy-warning-modal'); - if (modal) { - modal.classList.add('active'); - - const acceptBtn = document.getElementById('epilepsy-accept-btn'); - const cancelBtn = document.getElementById('epilepsy-cancel-btn'); - - acceptBtn.onclick = async () => { - modal.classList.remove('active'); - localStorage.setItem('epilepsy-warning-dismissed', 'true'); - await startVisualizer(); - }; - cancelBtn.onclick = () => { - modal.classList.remove('active'); - this.closeFullscreenCover(); - }; - } else { - await startVisualizer(); - } - } + this.setupControlsAutoHide(overlay); + await this.refreshFullscreenVisualizerState(activeElement); } closeFullscreenCover() { const overlay = document.getElementById('fullscreen-cover-overlay'); const coverImage = document.getElementById('fullscreen-cover-image'); + const lyricsContent = document.getElementById('fullscreen-lyrics-content'); if (coverImage && coverImage.vanillaTilt) { coverImage.vanillaTilt.destroy(); } + if (lyricsContent) { + clearFullscreenLyricsSync(lyricsContent); + lyricsContent.innerHTML = '
Lyrics appear here.
'; + } overlay.style.display = 'none'; - overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round'); + overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round', 'fullscreen-paused'); const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.removeProperty('display'); + const mainContent = document.querySelector('.main-content'); + if (mainContent instanceof HTMLElement) { + if (typeof this.fullscreenMainContentOverflow === 'string' && this.fullscreenMainContentOverflow.length > 0) { + mainContent.style.overflowY = this.fullscreenMainContentOverflow; + } else { + mainContent.style.removeProperty('overflow-y'); + } + this.fullscreenMainContentOverflow = null; + } if (this.player?.currentTrack?.type === 'video') { const coverContainer = document.querySelector('.now-playing-bar .track-info'); @@ -1375,6 +1361,7 @@ export class UIRenderer { if (this.visualizer) { this.visualizer.stop(); } + this.fullscreenVisualizerSuppressed = false; // Clear UI toggle button timers if (this.uiToggleMouseTimer) { @@ -1383,13 +1370,115 @@ export class UIRenderer { } } + async startFullscreenVisualizer(activeElement, overlay) { + if (!activeElement) return; + + if (!this.visualizer) { + const canvas = document.getElementById('visualizer-canvas'); + if (canvas) { + this.visualizer = new Visualizer(canvas, activeElement); + await this.visualizer.initPresets(); + } + } + + if (this.visualizer) { + await this.visualizer.start(); + overlay.classList.add('visualizer-active'); + } + } + + async ensureVisualizerPermission(activeElement, overlay, { closeOnCancel = false } = {}) { + if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') { + await this.startFullscreenVisualizer(activeElement, overlay); + return true; + } + + const modal = document.getElementById('epilepsy-warning-modal'); + if (!modal) { + await this.startFullscreenVisualizer(activeElement, overlay); + return true; + } + + return await new Promise((resolve) => { + modal.classList.add('active'); + + const acceptBtn = document.getElementById('epilepsy-accept-btn'); + const cancelBtn = document.getElementById('epilepsy-cancel-btn'); + + acceptBtn.onclick = async () => { + modal.classList.remove('active'); + localStorage.setItem('epilepsy-warning-dismissed', 'true'); + await this.startFullscreenVisualizer(activeElement, overlay); + resolve(true); + }; + + cancelBtn.onclick = () => { + modal.classList.remove('active'); + if (closeOnCancel) { + this.closeFullscreenCover(); + } + resolve(false); + }; + }); + } + + async refreshFullscreenVisualizerState(activeElement, { closeOnCancel = false } = {}) { + const overlay = document.getElementById('fullscreen-cover-overlay'); + const visualizerBtn = document.getElementById('fs-visualizer-btn'); + const toggleBtn = document.getElementById('toggle-ui-btn'); + const isVideoTrack = this.player?.currentTrack?.type === 'video'; + const enabled = visualizerSettings.isEnabled() && !isVideoTrack && !this.fullscreenVisualizerSuppressed; + + if (!overlay) return; + + if (visualizerBtn) { + visualizerBtn.style.display = isVideoTrack ? 'none' : 'flex'; + visualizerBtn.classList.toggle('active', enabled); + visualizerBtn.title = enabled ? 'Disable Visualizer' : 'Use Visualizer'; + } + + if (!enabled) { + overlay.classList.remove('visualizer-active'); + overlay.classList.remove('ui-hidden'); + if (this.visualizer) { + this.visualizer.stop(); + } + if (toggleBtn) { + toggleBtn.classList.remove('active', 'visible'); + toggleBtn.title = 'Hide UI'; + setFullscreenUIToggleIcon(toggleBtn, false); + } + return; + } + + const allowed = await this.ensureVisualizerPermission(activeElement, overlay, { closeOnCancel }); + if (!allowed) { + this.fullscreenVisualizerSuppressed = true; + overlay.classList.remove('visualizer-active'); + if (this.visualizer) { + this.visualizer.stop(); + } + if (visualizerBtn) { + visualizerBtn.classList.remove('active'); + visualizerBtn.title = 'Use Visualizer'; + } + } + } + setupUIToggleButton(overlay) { const toggleBtn = document.getElementById('toggle-ui-btn'); if (!toggleBtn) return; + const updateToggleButtonIcon = () => { + const visualizerOnlyMode = + overlay.classList.contains('ui-hidden') && overlay.classList.contains('visualizer-active'); + setFullscreenUIToggleIcon(toggleBtn, visualizerOnlyMode); + }; + let isUIHidden = overlay.classList.contains('ui-hidden'); toggleBtn.classList.toggle('active', isUIHidden); toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI'; + updateToggleButtonIcon(); // Show button const showButton = () => { @@ -1408,12 +1497,39 @@ export class UIRenderer { showButton(); } - const toggleUI = (e) => { + const toggleUI = async (e) => { if (e) e.stopPropagation(); + if (!overlay.classList.contains('visualizer-active')) { + const isVideoTrack = this.player?.currentTrack?.type === 'video'; + if (isVideoTrack) { + overlay.classList.remove('ui-hidden'); + isUIHidden = false; + toggleBtn.classList.remove('active'); + toggleBtn.title = 'Hide UI'; + updateToggleButtonIcon(); + showButton(); + return; + } + + this.fullscreenVisualizerSuppressed = false; + visualizerSettings.setEnabled(true); + await this.refreshFullscreenVisualizerState(this.player?.activeElement); + + if (!overlay.classList.contains('visualizer-active')) { + overlay.classList.remove('ui-hidden'); + isUIHidden = false; + toggleBtn.classList.remove('active'); + toggleBtn.title = 'Hide UI'; + updateToggleButtonIcon(); + showButton(); + return; + } + } isUIHidden = !isUIHidden; overlay.classList.toggle('ui-hidden', isUIHidden); toggleBtn.classList.toggle('active', isUIHidden); toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI'; + updateToggleButtonIcon(); if (isUIHidden) { hideButton(); @@ -1458,12 +1574,21 @@ export class UIRenderer { }; } + setupControlsAutoHide(overlay) { + if (this.controlsIdleCleanup) this.controlsIdleCleanup(); + overlay.classList.remove('controls-idle'); + + this.controlsIdleCleanup = () => { + overlay.classList.remove('controls-idle'); + }; + } setupFullscreenControls() { const playBtn = document.getElementById('fs-play-pause-btn'); const prevBtn = document.getElementById('fs-prev-btn'); const nextBtn = document.getElementById('fs-next-btn'); const shuffleBtn = document.getElementById('fs-shuffle-btn'); const repeatBtn = document.getElementById('fs-repeat-btn'); + const visualizerBtn = document.getElementById('fs-visualizer-btn'); const progressBar = document.getElementById('fs-progress-bar'); const progressFill = document.getElementById('fs-progress-fill'); const currentTimeEl = document.getElementById('fs-current-time'); @@ -1524,6 +1649,22 @@ export class UIRenderer { } }; + if (visualizerBtn) { + visualizerBtn.onclick = async () => { + if (this.fullscreenVisualizerSuppressed) { + this.fullscreenVisualizerSuppressed = false; + visualizerSettings.setEnabled(true); + } else if (visualizerSettings.isEnabled()) { + visualizerSettings.setEnabled(false); + this.fullscreenVisualizerSuppressed = false; + } else { + this.fullscreenVisualizerSuppressed = false; + visualizerSettings.setEnabled(true); + } + await this.refreshFullscreenVisualizerState(this.player.activeElement); + }; + } + // Progress bar with drag support let isFsSeeking = false; let wasFsPlaying = false; diff --git a/styles.css b/styles.css index 2d3d2e4..c8b20e5 100644 --- a/styles.css +++ b/styles.css @@ -970,6 +970,7 @@ ul { display: grid; height: 100vh; height: 100dvh; + min-height: 0; grid-template: 'sidebar main' 1fr 'player player' auto / 210px 1fr; @@ -977,6 +978,7 @@ ul { .sidebar { grid-area: sidebar; + min-height: 0; background-color: var(--background); border-right: 1px solid var(--border); padding: 1.25rem; @@ -1023,7 +1025,10 @@ ul { .main-content { grid-area: main; + min-height: 0; + min-width: 0; overflow-y: auto; + overflow-x: hidden; padding: var(--spacing-xl); scroll-behavior: smooth; position: relative; @@ -3934,7 +3939,26 @@ input:checked + .slider::before { filter: var(--cover-filter); z-index: -1; background-image: var(--bg-image); - transition: background-image var(--transition); + transition: + background-image var(--transition), + filter 0.65s ease, + opacity 0.65s ease; +} + +#fullscreen-cover-overlay::after { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 22%, rgb(var(--highlight-rgb) / 0.28), transparent 36%), + radial-gradient(circle at 82% 18%, rgb(255 255 255 / 0.09), transparent 28%), + linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--highlight-rgb) / 0.12) 100%); + opacity: 0.36; + pointer-events: none; + z-index: 0; + transition: + opacity 0.65s ease, + background 0.65s ease; } #visualizer-container { @@ -3944,7 +3968,13 @@ input:checked + .slider::before { height: 100%; z-index: 0; pointer-events: none; - transition: opacity 0.3s ease; + filter: blur(14px) saturate(0.84) brightness(0.8); + transform: scale(1.04); + opacity: 0.82; + transition: + opacity 0.65s ease, + filter 0.65s ease, + transform 0.65s ease; } #visualizer-canvas { @@ -3963,6 +3993,7 @@ input:checked + .slider::before { height: 100%; position: relative; padding: 1rem; + overflow: hidden; } /* UI Toggle Button for Visualizer Mode - Rightmost position */ @@ -4079,11 +4110,27 @@ input:checked + .slider::before { /* When UI is hidden, only toggle button stays visible at right edge (when .visible class is added) */ #fullscreen-cover-overlay.ui-hidden .fullscreen-lyrics-toggle, -#fullscreen-cover-overlay.ui-hidden #close-fullscreen-cover-btn { +#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions { opacity: 0; pointer-events: none; } +#fullscreen-cover-overlay.ui-hidden::before, +#fullscreen-cover-overlay.ui-hidden::after { + opacity: 0; +} + +#fullscreen-cover-overlay.ui-hidden #visualizer-container { + filter: none; + transform: none; + opacity: 1; +} + +body:has(#fullscreen-cover-overlay.ui-hidden.inline-lyrics) #side-panel[data-view='lyrics'] { + opacity: 0; + pointer-events: none; + transition: opacity 0.5s ease; +} #fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-main-view, #fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-controls, #fullscreen-cover-overlay:not(.ui-hidden) #fullscreen-next-track { @@ -4092,6 +4139,59 @@ input:checked + .slider::before { transition: opacity 0.5s ease; } +/* Auto-hide controls on idle */ +#fullscreen-cover-overlay.controls-idle .fullscreen-track-info, +#fullscreen-cover-overlay.controls-idle .fullscreen-controls, +#fullscreen-cover-overlay.controls-idle #fullscreen-next-track, +#fullscreen-cover-overlay.controls-idle #toggle-ui-btn, +#fullscreen-cover-overlay.controls-idle .fullscreen-lyrics-toggle, +#fullscreen-cover-overlay.controls-idle .fullscreen-top-actions { + opacity: 0; + pointer-events: none; + transition: + opacity 0.6s ease, + transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +#fullscreen-cover-overlay.controls-idle #fullscreen-cover-image { + transform: translateY(4rem); + transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +#fullscreen-cover-overlay:not(.controls-idle) #fullscreen-cover-image { + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +#fullscreen-cover-overlay.controls-idle .fullscreen-controls { + transform: translateY(1.5rem); +} + +#fullscreen-cover-overlay.controls-idle .fullscreen-track-info { + transform: translateY(0.5rem); +} + +#fullscreen-cover-overlay.controls-idle #toggle-ui-btn, +#fullscreen-cover-overlay.controls-idle .fullscreen-lyrics-toggle, +#fullscreen-cover-overlay.controls-idle .fullscreen-top-actions { + transform: translateY(-0.5rem); +} + +#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-track-info, +#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-controls, +#fullscreen-cover-overlay:not(.controls-idle) #fullscreen-next-track, +#fullscreen-cover-overlay:not(.controls-idle) #toggle-ui-btn, +#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-lyrics-toggle, +#fullscreen-cover-overlay:not(.controls-idle) .fullscreen-top-actions { + opacity: 1; + transform: translateY(0); + transition: + opacity 0.4s ease, + transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +#fullscreen-cover-overlay.controls-idle { + cursor: none; +} #fullscreen-cover-image { max-width: 55vw; max-height: 45vh; @@ -4946,7 +5046,7 @@ input:checked + .slider::before { #download-notifications { position: fixed; - bottom: 120px; + bottom: calc(max(env(safe-area-inset-bottom), 0px) + 12px); right: 20px; z-index: 20000; max-width: 350px; @@ -6774,7 +6874,7 @@ img[src=''] { } #download-notifications { - bottom: 10px; + bottom: calc(max(env(safe-area-inset-bottom), 0px) + 10px); right: 10px; left: 10px; max-width: none; @@ -9936,3 +10036,514 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .contrib { font-size: 10px; } + +/* Fullscreen layout rebuild on PR 378 base */ +#fullscreen-cover-overlay .fullscreen-shell { + width: 100%; + height: 100%; + display: flex; + align-items: stretch; + justify-content: center; + min-height: 0; + overflow: hidden; +} + +#fullscreen-cover-overlay .fullscreen-main-view { + width: min(1240px, 100%); + height: 100%; + flex: 1; + display: grid; + grid-template-columns: minmax(360px, 430px) minmax(420px, 1fr); + gap: clamp(1.5rem, 3vw, 3rem); + align-items: center; + justify-content: center; + padding: clamp(4rem, 7vh, 5rem) clamp(2rem, 4vw, 3rem) clamp(3rem, 6vh, 4rem) clamp(4rem, 7vw, 6.25rem); + position: relative; + z-index: 1; + min-height: 0; + overflow: hidden; +} + +#fullscreen-cover-overlay .fullscreen-media-column, +#fullscreen-cover-overlay .fullscreen-lyrics-pane { + min-height: 0; +} + +#fullscreen-cover-overlay .fullscreen-media-column { + width: min(420px, 100%); + display: flex; + flex-direction: column; + gap: 0.95rem; + justify-self: center; + transform: translateX(clamp(0.75rem, 1.2vw, 1.4rem)); +} + +#fullscreen-cover-overlay .fullscreen-artwork-card { + width: min(420px, 100%); + aspect-ratio: 1 / 1; + border-radius: 18px; + overflow: hidden; + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.26); +} + +#fullscreen-cover-overlay #fullscreen-cover-image { + width: 100%; + height: 100%; + margin: 0; + max-width: none; + max-height: none; + object-fit: cover; + border-radius: 18px; + transform: none !important; +} + +#fullscreen-cover-overlay .fullscreen-track-info { + width: min(420px, 100%); + display: block; + text-align: left; + max-width: none; + padding: 0.15rem 0 0; + background: none; + border: 0; + box-shadow: none; + backdrop-filter: none; +} + +#fullscreen-cover-overlay .fullscreen-track-text { + min-width: 0; +} + +#fullscreen-cover-overlay #fullscreen-track-title { + margin: 0; + font-size: clamp(1.15rem, 1.5vw, 1.42rem); + line-height: 1.08; + letter-spacing: -0.03em; +} + +#fullscreen-cover-overlay #fullscreen-track-artist { + margin: 0.12rem 0 0; + font-size: 0.94rem; + color: rgb(255 255 255 / 0.74); +} + +#fullscreen-cover-overlay #toggle-fullscreen-lyrics-btn, +#fullscreen-cover-overlay .fullscreen-lyrics-toggle { + display: none !important; +} + +#fullscreen-cover-overlay .fullscreen-actions { + display: flex !important; + align-items: center; + gap: 0.5rem; + margin-top: 0.9rem; +} + +#fullscreen-cover-overlay .fullscreen-actions .btn-icon { + width: 38px; + height: 38px; + padding: 0; + border-radius: 999px; + color: rgb(255 255 255 / 0.74); + background: transparent; + transition: + color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; +} + +#fullscreen-cover-overlay .fullscreen-actions .btn-icon:hover { + color: rgb(255 255 255 / 0.96); + background: rgb(255 255 255 / 0.08); + transform: scale(1.03); +} + +#fullscreen-cover-overlay #fullscreen-next-track { + display: flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.85rem; + color: rgb(255 255 255 / 0.56); +} + +#fullscreen-cover-overlay #fullscreen-next-track .label { + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +#fullscreen-cover-overlay #fullscreen-next-track .value { + font-size: 0.84rem; + color: rgb(255 255 255 / 0.74); +} + +#fullscreen-cover-overlay .fullscreen-top-actions { + position: absolute; + top: 1.25rem; + left: calc(1.5rem + env(safe-area-inset-left)); + right: auto; + display: flex; + align-items: center; + gap: 0.4rem; + z-index: 12; +} + +#fullscreen-cover-overlay .fullscreen-top-actions button { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 999px; + padding: 0; + background: rgb(9 12 18 / 0.34); + color: rgb(255 255 255 / 0.72); + backdrop-filter: blur(10px); + transition: + color 0.2s ease, + background-color 0.2s ease, + opacity 0.2s ease; +} + +#fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn { + order: 2; +} + +#fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn, +#fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { + position: static; + top: auto; + right: auto; + left: auto; + bottom: auto; + margin: 0; + opacity: 1; +} + +#fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { + order: 1; +} + +#fullscreen-cover-overlay #toggle-ui-btn { + top: 1.25rem; + left: calc(80px + 2.3rem + env(safe-area-inset-left)); + right: auto; + width: 40px; + height: 40px; + background: rgb(9 12 18 / 0.34); + color: rgb(255 255 255 / 0.72); + backdrop-filter: blur(10px); + opacity: 1; + z-index: 12; +} + +#fullscreen-cover-overlay .fullscreen-controls { + width: min(420px, 100%); + margin-top: 0; + align-items: center; + gap: 0.85rem; + position: relative; +} + +#fullscreen-cover-overlay .fullscreen-buttons { + width: 100%; + justify-content: center; + gap: 0.4rem; +} + +#fullscreen-cover-overlay .fullscreen-buttons button { + width: 40px; + height: 40px; + color: rgb(255 255 255 / 0.72); + border-radius: 999px; + padding: 0; + transition: + color 0.2s ease, + transform 0.2s ease, + background-color 0.2s ease, + opacity 0.2s ease; +} + +#fullscreen-cover-overlay .fullscreen-buttons button:hover { + color: rgb(255 255 255 / 0.94); + background: rgb(255 255 255 / 0.08); + transform: scale(1.04); +} + +#fullscreen-cover-overlay .fullscreen-buttons button.active { + color: rgb(var(--highlight-rgb) / 0.98); +} + +#fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { + width: 54px; + height: 54px; + background: rgb(255 255 255 / 0.96); + color: rgb(11 15 21 / 0.92); + box-shadow: 0 12px 28px rgb(0 0 0 / 0.2); +} + +#fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn:hover { + background: rgb(255 255 255 / 1); + transform: scale(1.02); +} + +#fullscreen-cover-overlay .fullscreen-volume-container { + width: 238px; + max-width: 100%; + align-self: center; + justify-content: center; + margin-top: 0.2rem; + margin-inline: auto; + position: relative; +} + +#fullscreen-cover-overlay .fs-visualizer-btn, +#fullscreen-cover-overlay .fs-volume-btn { + width: 30px; + height: 30px; + padding: 0; + color: rgb(255 255 255 / 0.62); +} + +#fullscreen-cover-overlay .fs-visualizer-btn:hover, +#fullscreen-cover-overlay .fs-volume-btn:hover { + background: transparent; + color: rgb(255 255 255 / 0.9); +} + +#fullscreen-cover-overlay .fs-visualizer-btn.active { + color: rgb(var(--highlight-rgb) / 0.96); +} + +#fullscreen-cover-overlay .fs-volume-btn { + position: absolute; + left: -2.5rem; + top: 50%; + transform: translateY(-50%); +} + +#fullscreen-cover-overlay .fs-volume-btn:hover { + transform: translateY(-50%); +} + +#fullscreen-cover-overlay .fs-volume-bar { + width: 238px; + height: 4px; + background: rgb(255 255 255 / 0.24); + margin-inline: auto; +} + +#fullscreen-cover-overlay .fs-volume-bar:hover { + height: 4px; +} + +#fullscreen-cover-overlay .fs-volume-fill, +#fullscreen-cover-overlay .fullscreen-progress-container .progress-fill { + background: rgb(255 255 255 / 0.92); +} + +#fullscreen-cover-overlay .fullscreen-progress-container { + color: rgb(255 255 255 / 0.62); + font-size: 0.78rem; +} + +#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar { + height: 4px; + background: rgb(255 255 255 / 0.2); +} + +#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover { + height: 4px; +} + +#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill, +#fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill { + background: rgb(var(--highlight-rgb) / 0.94); +} + +#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill::after, +#fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:active .progress-fill::after, +#fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill::after, +#fullscreen-cover-overlay .fs-volume-bar:active .fs-volume-fill::after { + width: 10px; + height: 10px; + box-shadow: 0 4px 12px rgb(0 0 0 / 0.28); +} + +#fullscreen-cover-overlay .fullscreen-lyrics-pane { + display: flex; + align-items: stretch; + justify-content: flex-start; + overflow: hidden; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-shell, +#fullscreen-cover-overlay .fullscreen-lyrics-content, +#fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; + outline: none !important; + backdrop-filter: none !important; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-shell { + width: min(860px, 100%); + min-height: 0; + margin-left: clamp(4rem, 8vw, 8rem); +} + +#fullscreen-cover-overlay .fullscreen-lyrics-content { + min-height: 0; + height: 100%; + position: relative; + padding-left: clamp(2.5rem, 4vw, 4rem); + mask-image: none; + overflow: visible; + scrollbar-width: none; + -ms-overflow-style: none; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-content::-webkit-scrollbar { + display: none; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics { + --am-lyrics-highlight-color: #f6f4ef; + --lyrics-scroll-padding-top: 18%; + --lyplus-blur-amount: 0.16em; + --lyplus-blur-amount-near: 0.085em; + height: 100%; + width: 100%; + font-family: + 'SF Pro Display', + Inter, + sans-serif; + --lyplus-font-size-base: clamp(34px, 3vw, 52px); + --lyplus-padding-line: 8px; + --lyplus-text-color: rgba(246, 244, 239, 0.08); + --lyplus-active-color: #f6f4ef; + line-height: 1.32; + letter-spacing: -0.04em; + font-weight: 600; + isolation: isolate; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-content::after { + content: none; +} + +#fullscreen-cover-overlay .fullscreen-lyrics-empty, +#fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-loading, +#fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-error { + padding: clamp(5rem, 14vh, 7rem) 0 0 clamp(2rem, 5vw, 4.5rem); + background: none; + border: 0; +} + +#fullscreen-cover-overlay.lyrics-unavailable .fullscreen-lyrics-pane { + opacity: 0.55; +} + +@media (max-width: 980px) { + #fullscreen-cover-overlay .fullscreen-main-view { + grid-template-columns: 1fr; + width: min(760px, 100%); + gap: 1rem; + align-items: start; + padding: + calc(4.5rem + env(safe-area-inset-top)) + clamp(1rem, 4vw, 1.5rem) + calc(1.5rem + env(safe-area-inset-bottom)) + clamp(1rem, 4vw, 1.5rem); + } + + #fullscreen-cover-overlay .fullscreen-media-column { + justify-self: center; + transform: none; + } + + #fullscreen-cover-overlay .fullscreen-lyrics-pane { + display: none; + } +} + +@media (max-width: 768px) { + #fullscreen-cover-overlay .fullscreen-cover-content { + padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom)); + } + + #fullscreen-cover-overlay .fullscreen-top-actions { + top: calc(0.75rem + env(safe-area-inset-top)); + left: calc(1rem + env(safe-area-inset-left)); + gap: 0.35rem; + } + + #fullscreen-cover-overlay .fullscreen-top-actions button, + #fullscreen-cover-overlay #toggle-ui-btn { + width: 44px; + height: 44px; + background: rgb(9 12 18 / 0.5); + } + + #fullscreen-cover-overlay #toggle-ui-btn { + top: calc(0.75rem + env(safe-area-inset-top)); + left: calc(88px + 1.25rem + env(safe-area-inset-left)); + } + + #fullscreen-cover-overlay .fullscreen-main-view { + width: 100%; + gap: 0.85rem; + padding: + calc(4.25rem + env(safe-area-inset-top)) + 0.75rem + calc(1.5rem + env(safe-area-inset-bottom)) + 0.75rem; + } + + #fullscreen-cover-overlay .fullscreen-track-info, + #fullscreen-cover-overlay .fullscreen-controls, + #fullscreen-cover-overlay .fullscreen-media-column { + width: min(100%, 460px); + } + + #fullscreen-cover-overlay .fullscreen-actions { + width: 100%; + flex-wrap: wrap; + gap: 0.45rem; + } + + #fullscreen-cover-overlay .fullscreen-actions .btn-icon { + background: rgb(255 255 255 / 0.06); + } + + #fullscreen-cover-overlay .fullscreen-progress-container { + gap: 0.65rem; + } + + #fullscreen-cover-overlay .fullscreen-buttons { + gap: 0.2rem; + } + + #fullscreen-cover-overlay .fullscreen-buttons button { + width: 38px; + height: 38px; + } + + #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { + width: 52px; + height: 52px; + } + + #fullscreen-cover-overlay .fullscreen-volume-container { + width: min(220px, calc(100% - 2.75rem)); + } + + #fullscreen-cover-overlay .fs-volume-btn { + left: -2.25rem; + } + + #fullscreen-cover-overlay .fs-volume-bar { + width: 100%; + } +} From 759d9b75e29ff9a08e73dd90b9d7449a66589f29 Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sun, 5 Apr 2026 00:29:58 -0400 Subject: [PATCH 02/76] fix: buttons when in visualizer only mode --- js/ui.js | 25 ++++++++----------------- styles.css | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/js/ui.js b/js/ui.js index 8f4c3c6..0e12d30 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1532,29 +1532,20 @@ export class UIRenderer { updateToggleButtonIcon(); if (isUIHidden) { - hideButton(); + showButton(); } else { showButton(); } }; - // Mouse move handler const handleMouseMove = (e) => { - const rect = overlay.getBoundingClientRect(); - const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150; - - if (isUIHidden) { - if (overlay.classList.contains('is-video-mode')) { - if (isNearTopRight) { - showButton(); - } else { - hideButton(); - } - } else if (isNearTopRight) { - showButton(); - } else { - hideButton(); - } + if (!isUIHidden) return; + const btnRect = toggleBtn.getBoundingClientRect(); + const nearBtn = e.clientY < 100 && Math.abs(e.clientX - (btnRect.left + btnRect.width / 2)) < 150; + if (nearBtn) { + showButton(); + } else { + hideButton(); } }; diff --git a/styles.css b/styles.css index c8b20e5..b81d210 100644 --- a/styles.css +++ b/styles.css @@ -10224,6 +10224,20 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { order: 1; } +#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions { + opacity: 1; + pointer-events: auto; + transform: none; +} + +#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions button, +#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions #close-fullscreen-cover-btn, +#fullscreen-cover-overlay.ui-hidden .fullscreen-top-actions #fs-visualizer-btn { + opacity: 1; + pointer-events: auto; + transform: none; +} + #fullscreen-cover-overlay #toggle-ui-btn { top: 1.25rem; left: calc(80px + 2.3rem + env(safe-area-inset-left)); From e00368597df26227b3ae83df055af1d27f16c9bf Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sun, 5 Apr 2026 00:37:43 -0400 Subject: [PATCH 03/76] fix: mobile sizing --- js/side-panel.js | 13 +++++++++++++ js/ui.js | 31 +++++++++++++++++++++++++++++++ styles.css | 36 ++++++++++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/js/side-panel.js b/js/side-panel.js index 0f399ea..5da3b4a 100644 --- a/js/side-panel.js +++ b/js/side-panel.js @@ -15,6 +15,17 @@ export class SidePanelManager { } } + emitChange() { + window.dispatchEvent( + new CustomEvent('side-panel-changed', { + detail: { + active: this.panel.classList.contains('active'), + view: this.currentView, + }, + }) + ); + } + initResizer() { this.resizerElement.addEventListener('mousedown', this.startResize.bind(this)); @@ -86,6 +97,7 @@ export class SidePanelManager { if (renderContentCallback) renderContentCallback(this.contentElement); this.panel.classList.add('active'); + this.emitChange(); } close() { @@ -105,6 +117,7 @@ export class SidePanelManager { this.panel.classList.remove('active'); this.currentView = null; + this.emitChange(); // Optionally clear content after transition setTimeout(() => { if (!this.panel.classList.contains('active')) { diff --git a/js/ui.js b/js/ui.js index 0e12d30..5827d7f 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1302,6 +1302,7 @@ export class UIRenderer { // Setup UI toggle button this.setupUIToggleButton(overlay); this.setupControlsAutoHide(overlay); + this.setupFullscreenSidePanelSync(overlay); await this.refreshFullscreenVisualizerState(activeElement); } @@ -1368,6 +1369,16 @@ export class UIRenderer { clearTimeout(this.uiToggleMouseTimer); this.uiToggleMouseTimer = null; } + + if (this.controlsIdleCleanup) { + this.controlsIdleCleanup(); + this.controlsIdleCleanup = null; + } + + if (this.fullscreenSidePanelSyncCleanup) { + this.fullscreenSidePanelSyncCleanup(); + this.fullscreenSidePanelSyncCleanup = null; + } } async startFullscreenVisualizer(activeElement, overlay) { @@ -1573,6 +1584,26 @@ export class UIRenderer { overlay.classList.remove('controls-idle'); }; } + + setupFullscreenSidePanelSync(overlay) { + if (this.fullscreenSidePanelSyncCleanup) { + this.fullscreenSidePanelSyncCleanup(); + } + + const syncState = () => { + overlay.classList.toggle('queue-panel-active', sidePanelManager.isActive('queue')); + }; + + const handleChange = () => syncState(); + window.addEventListener('side-panel-changed', handleChange); + syncState(); + + this.fullscreenSidePanelSyncCleanup = () => { + window.removeEventListener('side-panel-changed', handleChange); + overlay.classList.remove('queue-panel-active'); + }; + } + setupFullscreenControls() { const playBtn = document.getElementById('fs-play-pause-btn'); const prevBtn = document.getElementById('fs-prev-btn'); diff --git a/styles.css b/styles.css index b81d210..57d66e9 100644 --- a/styles.css +++ b/styles.css @@ -10459,6 +10459,20 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 0.55; } +#fullscreen-cover-overlay.queue-panel-active .fullscreen-main-view { + grid-template-columns: 1fr; + width: min(760px, 100%); +} + +#fullscreen-cover-overlay.queue-panel-active .fullscreen-media-column { + justify-self: center; + transform: none; +} + +#fullscreen-cover-overlay.queue-panel-active .fullscreen-lyrics-pane { + display: none; +} + @media (max-width: 980px) { #fullscreen-cover-overlay .fullscreen-main-view { grid-template-columns: 1fr; @@ -10483,33 +10497,43 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } @media (max-width: 768px) { + #fullscreen-cover-overlay { + --fs-mobile-top-btn-size: 44px; + --fs-mobile-top-btn-gap: 0.6rem; + --fs-mobile-top-btn-left: calc(1rem + env(safe-area-inset-left)); + } + #fullscreen-cover-overlay .fullscreen-cover-content { padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom)); } #fullscreen-cover-overlay .fullscreen-top-actions { top: calc(0.75rem + env(safe-area-inset-top)); - left: calc(1rem + env(safe-area-inset-left)); - gap: 0.35rem; + left: var(--fs-mobile-top-btn-left); + gap: var(--fs-mobile-top-btn-gap); } #fullscreen-cover-overlay .fullscreen-top-actions button, #fullscreen-cover-overlay #toggle-ui-btn { - width: 44px; - height: 44px; + width: var(--fs-mobile-top-btn-size); + height: var(--fs-mobile-top-btn-size); background: rgb(9 12 18 / 0.5); } #fullscreen-cover-overlay #toggle-ui-btn { top: calc(0.75rem + env(safe-area-inset-top)); - left: calc(88px + 1.25rem + env(safe-area-inset-left)); + left: calc( + var(--fs-mobile-top-btn-left) + + (var(--fs-mobile-top-btn-size) * 2) + + (var(--fs-mobile-top-btn-gap) * 2) + ); } #fullscreen-cover-overlay .fullscreen-main-view { width: 100%; gap: 0.85rem; padding: - calc(4.25rem + env(safe-area-inset-top)) + calc(7.25rem + env(safe-area-inset-top)) 0.75rem calc(1.5rem + env(safe-area-inset-bottom)) 0.75rem; From 02021691ca822266612fe6c5334b854acd2ad36f Mon Sep 17 00:00:00 2001 From: a <252674497+ap5z@users.noreply.github.com> Date: Sun, 5 Apr 2026 09:12:35 -0400 Subject: [PATCH 04/76] Update styles.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- styles.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/styles.css b/styles.css index 57d66e9..d9d2a76 100644 --- a/styles.css +++ b/styles.css @@ -4126,11 +4126,6 @@ input:checked + .slider::before { opacity: 1; } -body:has(#fullscreen-cover-overlay.ui-hidden.inline-lyrics) #side-panel[data-view='lyrics'] { - opacity: 0; - pointer-events: none; - transition: opacity 0.5s ease; -} #fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-main-view, #fullscreen-cover-overlay:not(.ui-hidden) .fullscreen-controls, #fullscreen-cover-overlay:not(.ui-hidden) #fullscreen-next-track { From 476648ff8a33312fbcfc34ce5acdd1a9f0126a54 Mon Sep 17 00:00:00 2001 From: a <252674497+ap5z@users.noreply.github.com> Date: Sun, 5 Apr 2026 09:13:22 -0400 Subject: [PATCH 05/76] Update js/ui.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- js/ui.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/ui.js b/js/ui.js index 5827d7f..b1741fd 100644 --- a/js/ui.js +++ b/js/ui.js @@ -155,8 +155,6 @@ export class UIRenderer { this.renderLock = false; this.lastRecommendedTracks = []; this.currentArtistId = null; - this.fullscreenLyricsVisible = true; - this.fullscreenPlaybackStateCleanup = null; // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { From 7f2e0f2f889989007ab88622404e0f6a9fc13817 Mon Sep 17 00:00:00 2001 From: binimum <61615730+binimum@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:51:13 +0000 Subject: [PATCH 06/76] style: auto-fix linting issues --- public/editors-picks-old/2026-4-5.json | 75 +++++++------------------- public/editors-picks-old/index.json | 2 +- public/editors-picks.json | 2 +- vite.config.ts | 15 ++++-- 4 files changed, 32 insertions(+), 62 deletions(-) diff --git a/public/editors-picks-old/2026-4-5.json b/public/editors-picks-old/2026-4-5.json index cbff21b..3c4ca7f 100644 --- a/public/editors-picks-old/2026-4-5.json +++ b/public/editors-picks-old/2026-4-5.json @@ -12,10 +12,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -31,9 +28,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -49,9 +44,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -67,9 +60,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -85,9 +76,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -103,10 +92,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -122,10 +108,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -141,10 +124,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -160,9 +140,7 @@ "explicit": false, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -178,10 +156,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -197,9 +172,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -215,9 +188,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -233,9 +204,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -251,9 +220,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -269,9 +236,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -287,9 +252,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -305,9 +268,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } } -] \ No newline at end of file +] diff --git a/public/editors-picks-old/index.json b/public/editors-picks-old/index.json index d744f18..a7f355d 100644 --- a/public/editors-picks-old/index.json +++ b/public/editors-picks-old/index.json @@ -44,4 +44,4 @@ "label": "April Fools '26", "date": "2026-04-01" } -] \ No newline at end of file +] diff --git a/public/editors-picks.json b/public/editors-picks.json index 0637a08..fe51488 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -1 +1 @@ -[] \ No newline at end of file +[] diff --git a/vite.config.ts b/vite.config.ts index a4731eb..a440ea2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -83,10 +83,19 @@ export default defineConfig((_options) => { purgecss({ variables: false, // DO NOT REMOVE UNUSED VARIABLES (breaks web components like am-lyrics) safelist: { - standard: [/^am-lyrics/, /^lyplus-/, 'sidepanel', 'side-panel', 'active', 'show', /^data-/, /^modal-/], + standard: [ + /^am-lyrics/, + /^lyplus-/, + 'sidepanel', + 'side-panel', + 'active', + 'show', + /^data-/, + /^modal-/, + ], deep: [/^am-lyrics/], - greedy: [/^lyplus-/, /sidepanel/, /side-panel/] - } + greedy: [/^lyplus-/, /sidepanel/, /side-panel/], + }, }), authGatePlugin(), uploadPlugin(), From c29e3f5aeff21ed3fc91849955779b772a08ad1d Mon Sep 17 00:00:00 2001 From: edideaur Date: Sun, 5 Apr 2026 19:20:35 +0000 Subject: [PATCH 07/76] fix copilot setup steps workflow --- .github/workflows/copilot-setup-steps.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index ee3d1d8..4dccdc6 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -20,7 +20,6 @@ jobs: # Copilot will be given its own token for its operations. permissions: contents: read - workflows: write steps: - name: Checkout code From 242b2d3a032035ef21dafb62824042c3d00d5a99 Mon Sep 17 00:00:00 2001 From: edideaur Date: Sun, 5 Apr 2026 19:23:00 +0000 Subject: [PATCH 08/76] test editors picks workflow --- editors-picks-input.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/editors-picks-input.txt b/editors-picks-input.txt index 4c0954b..f47dff5 100644 --- a/editors-picks-input.txt +++ b/editors-picks-input.txt @@ -22,4 +22,5 @@ album:509761344 album:15621057 album:103897783 album:151728406 -album:199412873 \ No newline at end of file +album:199412873 +album:3280432 \ No newline at end of file From ed2d6c89b7437c0776ba96721664fafef5da00c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 19:23:15 +0000 Subject: [PATCH 09/76] chore: update editors picks --- public/editors-picks-old/2026-4-5.json | 275 +------------------------ public/editors-picks-old/index.json | 7 +- public/editors-picks.json | 2 +- 3 files changed, 8 insertions(+), 276 deletions(-) diff --git a/public/editors-picks-old/2026-4-5.json b/public/editors-picks-old/2026-4-5.json index 3c4ca7f..0637a08 100644 --- a/public/editors-picks-old/2026-4-5.json +++ b/public/editors-picks-old/2026-4-5.json @@ -1,274 +1 @@ -[ - { - "type": "album", - "id": 324660713, - "title": "JOECHILLWORLD", - "artist": { - "id": 40978758, - "name": "Devon Hendryx" - }, - "releaseDate": "2010-07-10", - "cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS", "HIRES_LOSSLESS"] - } - }, - { - "type": "album", - "id": 15427733, - "title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp", - "artist": { - "id": 4611745, - "name": "Spaceghostpurrp" - }, - "releaseDate": "2012-06-12", - "cover": "c78b7543-1cd8-4921-9155-e81d421353a0", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 464178301, - "title": "Never Forget", - "artist": { - "id": 5516508, - "name": "Chris Travis" - }, - "releaseDate": "2014-05-14", - "cover": "4ab11f0d-0768-4cce-8de5-1894134d5994", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 75115890, - "title": "Blood Shore Season 2", - "artist": { - "id": 6332342, - "name": "Xavier Wulf" - }, - "releaseDate": "2014-10-30", - "cover": "517303e5-d541-4704-b552-026427e05fcb", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 410197513, - "title": "THE PEAK", - "artist": { - "id": 33481052, - "name": "smokedope2016" - }, - "releaseDate": "2025-01-17", - "cover": "ea18084d-36ec-4cea-98a7-fe4684246986", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 418729278, - "title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT", - "artist": { - "id": 7958797, - "name": "JPEGMAFIA" - }, - "releaseDate": "2025-02-03", - "cover": "9c84302b-2584-4c0a-9db7-e648542f459f", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS", "HIRES_LOSSLESS"] - } - }, - { - "type": "album", - "id": 504004321, - "title": "Half Blood (BloodLuxe)", - "artist": { - "id": 50799233, - "name": "slayr" - }, - "releaseDate": "2025-11-05", - "cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS", "HIRES_LOSSLESS"] - } - }, - { - "type": "album", - "id": 510893864, - "title": "BULLY", - "artist": { - "id": 25022, - "name": "Kanye West" - }, - "releaseDate": "2026-03-28", - "cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS", "HIRES_LOSSLESS"] - } - }, - { - "type": "album", - "id": 325723583, - "title": "Replica", - "artist": { - "id": 3715530, - "name": "Oneohtrix Point Never" - }, - "releaseDate": "2011-11-05", - "cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809", - "explicit": false, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 336178142, - "title": "Pirate This Album", - "artist": { - "id": 8622751, - "name": "Shamana" - }, - "releaseDate": "2023-12-25", - "cover": "a8a647be-0331-4779-9a6e-31645a9abdab", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS", "HIRES_LOSSLESS"] - } - }, - { - "type": "album", - "id": 106369871, - "title": "Organic Thoughts from the Synthetic Mind", - "artist": { - "id": 6436013, - "name": "Shinjuku Mad" - }, - "releaseDate": "2009-07-01", - "cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 423471869, - "title": "pain", - "artist": { - "id": 44257324, - "name": "bleood" - }, - "releaseDate": "2025-03-11", - "cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 250986538, - "title": "Revolutionary, Vol. 1 (Bonus Edition)", - "artist": { - "id": 3604583, - "name": "Immortal Technique" - }, - "releaseDate": "2001-09-14", - "cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 509761344, - "title": "EMOTIONS", - "artist": { - "id": 49124576, - "name": "Nine Vicious" - }, - "releaseDate": "2026-04-03", - "cover": "f29b18d3-b19f-45b1-968a-0ad360647130", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 15621057, - "title": "Triple F Life: Friends, Fans & Family (Deluxe Version)", - "artist": { - "id": 3654061, - "name": "Waka Flocka Flame" - }, - "releaseDate": "2012-06-12", - "cover": "3199b7de-5e3d-486c-acf1-870ff4c60572", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 103897783, - "title": "Freewave 3", - "artist": { - "id": 7923685, - "name": "Lucki" - }, - "releaseDate": "2019-02-15", - "cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - }, - { - "type": "album", - "id": 151728406, - "title": "Niagara", - "artist": { - "id": 7607680, - "name": "redveil" - }, - "releaseDate": "2020-08-25", - "cover": "14690142-7fc8-4557-8a61-0721b7884822", - "explicit": true, - "audioQuality": "LOSSLESS", - "mediaMetadata": { - "tags": ["LOSSLESS"] - } - } -] +[] \ No newline at end of file diff --git a/public/editors-picks-old/index.json b/public/editors-picks-old/index.json index a7f355d..b9c0152 100644 --- a/public/editors-picks-old/index.json +++ b/public/editors-picks-old/index.json @@ -14,6 +14,11 @@ "label": "Spring 2026", "date": "2026-04-05" }, + { + "file": "2026-4-5.json", + "label": "Spring 2026", + "date": "2026-04-05" + }, { "file": "2026-4-3.json", "label": "Spring 2026", @@ -44,4 +49,4 @@ "label": "April Fools '26", "date": "2026-04-01" } -] +] \ No newline at end of file diff --git a/public/editors-picks.json b/public/editors-picks.json index fe51488..0637a08 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -1 +1 @@ -[] +[] \ No newline at end of file From c9e0f862cbd7b9b8850493affe00dddf4b4fd266 Mon Sep 17 00:00:00 2001 From: edideaur Date: Sun, 5 Apr 2026 19:31:35 +0000 Subject: [PATCH 10/76] fetch tidal token each run instead of hardcoding it --- editors-picks-input.txt | 3 ++- gen-editors-picks.py | 52 ++++++++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/editors-picks-input.txt b/editors-picks-input.txt index f47dff5..6418dc8 100644 --- a/editors-picks-input.txt +++ b/editors-picks-input.txt @@ -23,4 +23,5 @@ album:15621057 album:103897783 album:151728406 album:199412873 -album:3280432 \ No newline at end of file +album:3280432 +album:37927851 \ No newline at end of file diff --git a/gen-editors-picks.py b/gen-editors-picks.py index 0da586f..cfb6b2e 100644 --- a/gen-editors-picks.py +++ b/gen-editors-picks.py @@ -9,32 +9,58 @@ import hashlib import time import os import tempfile +import base64 INPUT_FILE = "editors-picks-input.txt" COUNTRY = "US" -# Tidal internal token replace when expired -TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA" +TIDAL_CLIENT_ID = "txNoH4kkV41MfH25" +TIDAL_CLIENT_SECRET = "dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98=" -TIDAL_HEADERS = { - "accept": "*/*", - "authorization": f"Bearer {TIDAL_TOKEN}", -} - -# PodcastIndex credentials -PODCAST_API_KEY = "YU5HMSDYBQQVYDF6QN4P" -PODCAST_API_SECRET = "8hCvpjSL7T$S7^5ftnf5MhqQwYUYVjM^fmUL3Ld$" -PODCASTINDEX_BASE = "https://api.podcastindex.org/api/1.0" +_tidal_token = None -# ── Tidal helpers ───────────────────────────────────────────────────────────── +def get_tidal_token(): + global _tidal_token + if _tidal_token: + return _tidal_token + + credentials = base64.b64encode(f"{TIDAL_CLIENT_ID}:{TIDAL_CLIENT_SECRET}".encode()).decode() + params = urllib.parse.urlencode({ + "client_id": TIDAL_CLIENT_ID, + "client_secret": TIDAL_CLIENT_SECRET, + "grant_type": "client_credentials", + }) + req = urllib.request.Request( + "https://auth.tidal.com/v1/oauth2/token", + data=params.encode(), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {credentials}", + }, + method="POST" + ) + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode()) + _tidal_token = data["access_token"] + return _tidal_token + except Exception as e: + print(f"Error getting Tidal token: {e}", file=sys.stderr) + return None + def tidal_get(path, params=None): if params is None: params = {} params.setdefault("countryCode", COUNTRY) + + token = get_tidal_token() + if not token: + return None + url = f"https://api.tidal.com/v1/{path}?{urllib.parse.urlencode(params)}" - req = urllib.request.Request(url, headers=TIDAL_HEADERS) + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) From 5198e5ddacda13b01e91f3ef7ba3c797d6a325f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 19:32:03 +0000 Subject: [PATCH 11/76] chore: update editors picks --- public/editors-picks-old/index.json | 5 + public/editors-picks.json | 368 +++++++++++++++++++++++++++- 2 files changed, 372 insertions(+), 1 deletion(-) diff --git a/public/editors-picks-old/index.json b/public/editors-picks-old/index.json index b9c0152..558a061 100644 --- a/public/editors-picks-old/index.json +++ b/public/editors-picks-old/index.json @@ -19,6 +19,11 @@ "label": "Spring 2026", "date": "2026-04-05" }, + { + "file": "2026-4-5.json", + "label": "Spring 2026", + "date": "2026-04-05" + }, { "file": "2026-4-3.json", "label": "Spring 2026", diff --git a/public/editors-picks.json b/public/editors-picks.json index 0637a08..01bd47c 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -1 +1,367 @@ -[] \ No newline at end of file +[ + { + "type": "album", + "id": 324660713, + "title": "JOECHILLWORLD", + "artist": { + "id": 40978758, + "name": "Devon Hendryx" + }, + "releaseDate": "2010-07-10", + "cover": "25d45544-3e82-4184-b8c2-2c2c6f0f152a", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS", + "HIRES_LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 15427733, + "title": "Mysterious Phonk: The Chronicles of SpaceGhostPurrp", + "artist": { + "id": 4611745, + "name": "Spaceghostpurrp" + }, + "releaseDate": "2012-06-12", + "cover": "c78b7543-1cd8-4921-9155-e81d421353a0", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 464178301, + "title": "Never Forget", + "artist": { + "id": 5516508, + "name": "Chris Travis" + }, + "releaseDate": "2014-05-14", + "cover": "4ab11f0d-0768-4cce-8de5-1894134d5994", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 75115890, + "title": "Blood Shore Season 2", + "artist": { + "id": 6332342, + "name": "Xavier Wulf" + }, + "releaseDate": "2014-10-30", + "cover": "517303e5-d541-4704-b552-026427e05fcb", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 410197513, + "title": "THE PEAK", + "artist": { + "id": 33481052, + "name": "smokedope2016" + }, + "releaseDate": "2025-01-17", + "cover": "ea18084d-36ec-4cea-98a7-fe4684246986", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 418729278, + "title": "I LAY DOWN MY LIFE FOR YOU: DIRECTOR'S CUT", + "artist": { + "id": 7958797, + "name": "JPEGMAFIA" + }, + "releaseDate": "2025-02-03", + "cover": "9c84302b-2584-4c0a-9db7-e648542f459f", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS", + "HIRES_LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 504004321, + "title": "Half Blood (BloodLuxe)", + "artist": { + "id": 50799233, + "name": "slayr" + }, + "releaseDate": "2025-11-05", + "cover": "2767cc63-7e92-4a48-aa4b-806a3ea7ec1c", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS", + "HIRES_LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 510893864, + "title": "BULLY", + "artist": { + "id": 25022, + "name": "Kanye West" + }, + "releaseDate": "2026-03-28", + "cover": "cf2f2c9c-ff67-44f6-83aa-a7622f8c6b64", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS", + "HIRES_LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 325723583, + "title": "Replica", + "artist": { + "id": 3715530, + "name": "Oneohtrix Point Never" + }, + "releaseDate": "2011-11-05", + "cover": "95ceeae9-cac7-42dc-ae37-7c93c223f809", + "explicit": false, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 336178142, + "title": "Pirate This Album", + "artist": { + "id": 8622751, + "name": "Shamana" + }, + "releaseDate": "2023-12-25", + "cover": "a8a647be-0331-4779-9a6e-31645a9abdab", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS", + "HIRES_LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 106369871, + "title": "Organic Thoughts from the Synthetic Mind", + "artist": { + "id": 6436013, + "name": "Shinjuku Mad" + }, + "releaseDate": "2009-07-01", + "cover": "3acc888e-35da-40a8-a4b7-7ffd00576cc9", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 423471869, + "title": "pain", + "artist": { + "id": 44257324, + "name": "bleood" + }, + "releaseDate": "2025-03-11", + "cover": "711b23ba-c473-44e6-a2f0-010fefa9c5b8", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 250986538, + "title": "Revolutionary, Vol. 1 (Bonus Edition)", + "artist": { + "id": 3604583, + "name": "Immortal Technique" + }, + "releaseDate": "2001-09-14", + "cover": "e510dd6d-dcdf-4272-9c68-f4580f2fbd14", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 509761344, + "title": "EMOTIONS", + "artist": { + "id": 49124576, + "name": "Nine Vicious" + }, + "releaseDate": "2026-04-03", + "cover": "f29b18d3-b19f-45b1-968a-0ad360647130", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 15621057, + "title": "Triple F Life: Friends, Fans & Family (Deluxe Version)", + "artist": { + "id": 3654061, + "name": "Waka Flocka Flame" + }, + "releaseDate": "2012-06-12", + "cover": "3199b7de-5e3d-486c-acf1-870ff4c60572", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 103897783, + "title": "Freewave 3", + "artist": { + "id": 7923685, + "name": "Lucki" + }, + "releaseDate": "2019-02-15", + "cover": "1d481a33-8b20-4ee3-b04b-5ac6e0fc5e78", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 151728406, + "title": "Niagara", + "artist": { + "id": 7607680, + "name": "redveil" + }, + "releaseDate": "2020-08-25", + "cover": "14690142-7fc8-4557-8a61-0721b7884822", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 199412873, + "title": "Tha Carter III", + "artist": { + "id": 27518, + "name": "Lil Wayne" + }, + "releaseDate": "2008-06-10", + "cover": "797a90ea-3860-4d02-ac85-39b34ca8ee25", + "explicit": true, + "audioQuality": "LOW", + "mediaMetadata": { + "tags": [ + "DOLBY_ATMOS" + ] + } + }, + { + "type": "album", + "id": 3280432, + "title": "We Are Young Money", + "artist": { + "id": 3654487, + "name": "Young Money" + }, + "releaseDate": "2009-12-21", + "cover": "5b1456e5-1bba-415b-8276-8bc9cd211687", + "explicit": true, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + }, + { + "type": "album", + "id": 37927851, + "title": "The DeAndre Way (Deluxe)", + "artist": { + "id": 3820209, + "name": "Soulja Boy" + }, + "releaseDate": "2010-11-30", + "cover": "6ca0217d-4f74-47d2-b449-30144d91e41f", + "explicit": false, + "audioQuality": "LOSSLESS", + "mediaMetadata": { + "tags": [ + "LOSSLESS" + ] + } + } +] \ No newline at end of file From f0c976206eff21802bac97af79b730d160fea85b Mon Sep 17 00:00:00 2001 From: edideaur Date: Sun, 5 Apr 2026 19:39:41 +0000 Subject: [PATCH 12/76] fix linting workflow and add lighthouse testing --- .github/workflows/editors-picks.yml | 1 + .github/workflows/lighthouse.yml | 43 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .github/workflows/lighthouse.yml diff --git a/.github/workflows/editors-picks.yml b/.github/workflows/editors-picks.yml index d645fb2..680ce1a 100644 --- a/.github/workflows/editors-picks.yml +++ b/.github/workflows/editors-picks.yml @@ -104,6 +104,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git pull --rebase origin main git add public/editors-picks.json public/editors-picks-old/ git diff --staged --quiet && echo "No changes to commit." && exit 0 git commit -m "chore: update editors picks" diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 0000000..f9fd3a2 --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,43 @@ +name: Lighthouse + +on: + workflow_dispatch: + push: + branches: [main] + +jobs: + lighthouse: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Preview build + run: npm run preview & + continue-on-error: true + + - name: Wait for preview server + run: sleep 10 + + - name: Run Lighthouse + run: | + npx lhci autorun --config=.lhci.yml || true + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-results + path: .lighthouseci/ From e8acbc76cccc0997dfcf4891d845c7b2c37c2807 Mon Sep 17 00:00:00 2001 From: edideaur <182119792+edideaur@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:40:28 +0000 Subject: [PATCH 13/76] style: auto-fix linting issues --- public/editors-picks-old/2026-4-5.json | 2 +- public/editors-picks-old/index.json | 2 +- public/editors-picks.json | 87 +++++++------------------- 3 files changed, 23 insertions(+), 68 deletions(-) diff --git a/public/editors-picks-old/2026-4-5.json b/public/editors-picks-old/2026-4-5.json index 0637a08..fe51488 100644 --- a/public/editors-picks-old/2026-4-5.json +++ b/public/editors-picks-old/2026-4-5.json @@ -1 +1 @@ -[] \ No newline at end of file +[] diff --git a/public/editors-picks-old/index.json b/public/editors-picks-old/index.json index 558a061..e56a244 100644 --- a/public/editors-picks-old/index.json +++ b/public/editors-picks-old/index.json @@ -54,4 +54,4 @@ "label": "April Fools '26", "date": "2026-04-01" } -] \ No newline at end of file +] diff --git a/public/editors-picks.json b/public/editors-picks.json index 01bd47c..67beeae 100644 --- a/public/editors-picks.json +++ b/public/editors-picks.json @@ -12,10 +12,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -31,9 +28,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -49,9 +44,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -67,9 +60,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -85,9 +76,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -103,10 +92,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -122,10 +108,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -141,10 +124,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -160,9 +140,7 @@ "explicit": false, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -178,10 +156,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS", - "HIRES_LOSSLESS" - ] + "tags": ["LOSSLESS", "HIRES_LOSSLESS"] } }, { @@ -197,9 +172,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -215,9 +188,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -233,9 +204,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -251,9 +220,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -269,9 +236,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -287,9 +252,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -305,9 +268,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -323,9 +284,7 @@ "explicit": true, "audioQuality": "LOW", "mediaMetadata": { - "tags": [ - "DOLBY_ATMOS" - ] + "tags": ["DOLBY_ATMOS"] } }, { @@ -341,9 +300,7 @@ "explicit": true, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } }, { @@ -359,9 +316,7 @@ "explicit": false, "audioQuality": "LOSSLESS", "mediaMetadata": { - "tags": [ - "LOSSLESS" - ] + "tags": ["LOSSLESS"] } } -] \ No newline at end of file +] From 7df10b0f5e520995ff95817b724b2f115fd2b165 Mon Sep 17 00:00:00 2001 From: edideaur Date: Sun, 5 Apr 2026 19:41:46 +0000 Subject: [PATCH 14/76] oh my god bruh --- .github/workflows/lighthouse.yml | 2 +- bun.lock | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index f9fd3a2..47d6941 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,7 +19,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Build run: npm run build diff --git a/bun.lock b/bun.lock index 6f73fb6..c4873ee 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "stylelint": "^17.6.0", "stylelint-config-standard": "^39.0.1", "stylelint-config-standard-scss": "^16.0.0", + "terser": "^5.46.1", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-bundle-visualizer": "^1.2.1", From 2573870004a3e131ca79fb32056c00231746c0db Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sun, 5 Apr 2026 22:37:10 -0400 Subject: [PATCH 15/76] feat: refine fullscreen apple player --- functions/album/[id].js | 45 +---- functions/artist/[id].js | 43 +---- functions/playlist/[id].js | 44 +---- functions/track/[id].js | 43 +---- index.html | 7 +- js/lyrics.js | 9 + js/storage.js | 177 +----------------- js/ui.js | 260 ++++++++++++++++++++++++-- js/visualizer.js | 12 ++ public/instances.json | 21 +-- styles.css | 363 +++++++++++++++++++++++++++++-------- 11 files changed, 571 insertions(+), 453 deletions(-) diff --git a/functions/album/[id].js b/functions/album/[id].js index b75c0bf..1b002f5 100644 --- a/functions/album/[id].js +++ b/functions/album/[id].js @@ -47,52 +47,11 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', - 'https://tidal-uptime.props-76styles.workers.dev/', - ]; - this.apiInstances = null; + this.apiInstances = ['https://hifi.geeked.wtf']; } async getInstances() { - if (this.apiInstances) return this.apiInstances; - - let data = null; - const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5); - - for (const url of urls) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - data = await response.json(); - break; - } catch (error) { - console.warn(`Failed to fetch from ${url}:`, error); - } - } - - if (data) { - this.apiInstances = (data.api || []) - .map((item) => item.url || item) - .filter((url) => !/\.squid\.wtf/i.test(url)); - return this.apiInstances; - } - - console.error('Failed to load instances from all uptime APIs'); - return [ - 'https://hifi.geeked.wtf', - 'https://eu-central.monochrome.tf', - 'https://us-west.monochrome.tf', - 'https://arran.monochrome.tf', - 'https://api.monochrome.tf', - 'https://monochrome-api.samidy.com', - 'https://maus.qqdl.site', - 'https://vogel.qqdl.site', - 'https://katze.qqdl.site', - 'https://hund.qqdl.site', - 'https://tidal.kinoplus.online', - 'https://wolf.qqdl.site', - ]; + return this.apiInstances; } async fetchWithRetry(relativePath) { diff --git a/functions/artist/[id].js b/functions/artist/[id].js index 1c62591..a924773 100644 --- a/functions/artist/[id].js +++ b/functions/artist/[id].js @@ -47,50 +47,11 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', - 'https://tidal-uptime.props-76styles.workers.dev/', - ]; - this.apiInstances = null; + this.apiInstances = ['https://hifi.geeked.wtf']; } async getInstances() { - if (this.apiInstances) return this.apiInstances; - - let data = null; - const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5); - - for (const url of urls) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - data = await response.json(); - break; - } catch (error) { - console.warn(`Failed to fetch from ${url}:`, error); - } - } - - if (data) { - this.apiInstances = (data.api || []).map((item) => item.url || item); - return this.apiInstances; - } - - console.error('Failed to load instances from all uptime APIs'); - return [ - 'https://eu-central.monochrome.tf', - 'https://us-west.monochrome.tf', - 'https://arran.monochrome.tf', - 'https://triton.squid.wtf', - 'https://api.monochrome.tf', - 'https://monochrome-api.samidy.com', - 'https://maus.qqdl.site', - 'https://vogel.qqdl.site', - 'https://katze.qqdl.site', - 'https://hund.qqdl.site', - 'https://tidal.kinoplus.online', - 'https://wolf.qqdl.site', - ]; + return this.apiInstances; } async fetchWithRetry(relativePath) { diff --git a/functions/playlist/[id].js b/functions/playlist/[id].js index 15fe6f6..4e1dd21 100644 --- a/functions/playlist/[id].js +++ b/functions/playlist/[id].js @@ -47,51 +47,11 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', - 'https://tidal-uptime.props-76styles.workers.dev/', - ]; - this.apiInstances = null; + this.apiInstances = ['https://hifi.geeked.wtf']; } async getInstances() { - if (this.apiInstances) return this.apiInstances; - - let data = null; - const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5); - - for (const url of urls) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - data = await response.json(); - break; - } catch (error) { - console.warn(`Failed to fetch from ${url}:`, error); - } - } - - if (data) { - this.apiInstances = (data.api || []) - .map((item) => item.url || item) - .filter((url) => !/\.squid\.wtf/i.test(url)); - return this.apiInstances; - } - - console.error('Failed to load instances from all uptime APIs'); - return [ - 'https://eu-central.monochrome.tf', - 'https://us-west.monochrome.tf', - 'https://arran.monochrome.tf', - 'https://api.monochrome.tf', - 'https://monochrome-api.samidy.com', - 'https://maus.qqdl.site', - 'https://vogel.qqdl.site', - 'https://katze.qqdl.site', - 'https://hund.qqdl.site', - 'https://tidal.kinoplus.online', - 'https://wolf.qqdl.site', - ]; + return this.apiInstances; } async fetchWithRetry(relativePath) { diff --git a/functions/track/[id].js b/functions/track/[id].js index cf50d44..a274b12 100644 --- a/functions/track/[id].js +++ b/functions/track/[id].js @@ -69,50 +69,11 @@ class TidalAPI { class ServerAPI { constructor() { - this.INSTANCES_URLS = [ - 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', - 'https://tidal-uptime.props-76styles.workers.dev/', - ]; - this.apiInstances = null; + this.apiInstances = ['https://hifi.geeked.wtf']; } async getInstances() { - if (this.apiInstances) return this.apiInstances; - - let data = null; - const urls = [...this.INSTANCES_URLS].sort(() => Math.random() - 0.5); - - for (const url of urls) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - data = await response.json(); - break; - } catch (error) { - console.warn(`Failed to fetch from ${url}:`, error); - } - } - - if (data) { - this.apiInstances = (data.api || []).map((item) => item.url || item); - return this.apiInstances; - } - - console.error('Failed to load instances from all uptime APIs'); - return [ - 'https://eu-central.monochrome.tf', - 'https://us-west.monochrome.tf', - 'https://arran.monochrome.tf', - 'https://triton.squid.wtf', - 'https://api.monochrome.tf', - 'https://monochrome-api.samidy.com', - 'https://maus.qqdl.site', - 'https://vogel.qqdl.site', - 'https://katze.qqdl.site', - 'https://hund.qqdl.site', - 'https://tidal.kinoplus.online', - 'https://wolf.qqdl.site', - ]; + return this.apiInstances; } async fetchWithRetry(relativePath) { diff --git a/index.html b/index.html index 2898360..eb274e0 100644 --- a/index.html +++ b/index.html @@ -138,13 +138,14 @@ z-index: 0; " >
+ -
+ diff --git a/js/lyrics.js b/js/lyrics.js index 62f245e..59307ca 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -992,12 +992,17 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) { } .lyrics-line { + transform-origin: left center; transition: opacity 0.42s ease, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms), filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important; } + .lyrics-line:not(.active):not(.pre-active) { + opacity: 0.44; + } + .lyrics-line-container { transition: transform 0.72s cubic-bezier(0.22, 1, 0.36, 1), @@ -1012,6 +1017,10 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) { background-color 0.22s ease, color 0.22s ease !important; } + + .lyrics-line.active .lyrics-line-container { + transform: scale(1.015); + } `; return true; diff --git a/js/storage.js b/js/storage.js index edce8ba..743c42c 100644 --- a/js/storage.js +++ b/js/storage.js @@ -4,11 +4,11 @@ import { SVG_RIGHT_ARROW } from './icons'; export const apiSettings = { STORAGE_KEY: 'monochrome-api-instances-v9', - INSTANCES_URLS: [ - 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', - 'https://tidal-uptime.props-76styles.workers.dev/', - ], - defaultInstances: { api: [], streaming: [] }, + PINNED_INSTANCE: Object.freeze({ url: 'https://hifi.geeked.wtf', version: '2.7' }), + defaultInstances: { + api: [{ url: 'https://hifi.geeked.wtf', version: '2.7' }], + streaming: [{ url: 'https://hifi.geeked.wtf', version: '2.7' }], + }, userInstances: null, instancesLoaded: false, _loadPromise: null, @@ -29,136 +29,13 @@ export const apiSettings = { }, async loadInstancesFromGitHub() { - if (this.instancesLoaded) { - return this.defaultInstances; - } - - if (this._loadPromise) { - return this._loadPromise; - } - - this._loadPromise = (async () => { - const cachedData = localStorage.getItem(this.STORAGE_KEY); - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - const now = Date.now(); - // Check if cached data is less than 15 minutes old - if (parsed.timestamp && now - parsed.timestamp < 15 * 60 * 1000) { - this.defaultInstances = parsed.data; - this.instancesLoaded = true; - this._loadPromise = null; - return this.defaultInstances; - } - } catch (e) { - console.warn('Failed to parse cached instances:', e); - } - } - - let data = null; - let fetchError = null; - - // Prefer first URL, only try others as fallback - const urls = [...this.INSTANCES_URLS]; - - for (const url of urls) { - try { - const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - data = await response.json(); - break; // Success, exit loop - } catch (error) { - console.warn(`Failed to fetch from ${url}:`, error); - fetchError = error; - } - } - - if (!data) { - console.error('Failed to load instances from all uptime APIs:', fetchError); - this.defaultInstances = { - api: [ - { url: 'https://hifi.geeked.wtf', version: '2.7' }, - { url: 'https://eu-central.monochrome.tf', version: '2.7' }, - { url: 'https://us-west.monochrome.tf', version: '2.7' }, - { url: 'https://api.monochrome.tf', version: '2.5' }, - { url: 'https://monochrome-api.samidy.com', version: '2.3' }, - { url: 'https://maus.qqdl.site', version: '2.6' }, - { url: 'https://vogel.qqdl.site', version: '2.6' }, - { url: 'https://katze.qqdl.site', version: '2.6' }, - { url: 'https://hund.qqdl.site', version: '2.6' }, - { url: 'https://tidal.kinoplus.online', version: '2.2' }, - { url: 'https://wolf.qqdl.site', version: '2.2' }, - ], - streaming: [ - { url: 'https://hifi.geeked.wtf', version: '2.7' }, - { url: 'https://maus.qqdl.site', version: '2.6' }, - { url: 'https://vogel.qqdl.site', version: '2.6' }, - { url: 'https://katze.qqdl.site', version: '2.6' }, - { url: 'https://hund.qqdl.site', version: '2.6' }, - { url: 'https://wolf.qqdl.site', version: '2.6' }, - ], - }; - this.instancesLoaded = true; - this._loadPromise = null; - return this.defaultInstances; - } - - let groupedInstances = { api: [], streaming: [] }; - - const isBlockedInstance = (item) => { - const url = typeof item === 'string' ? item : item.url; - return url && /\.squid\.wtf/i.test(url); - }; - - if (data.api && Array.isArray(data.api)) { - groupedInstances.api = data.api.filter((item) => !isBlockedInstance(item)); - } - - if (data.streaming && Array.isArray(data.streaming)) { - groupedInstances.streaming = data.streaming.filter((item) => !isBlockedInstance(item)); - } else if (groupedInstances.api.length > 0) { - groupedInstances.streaming = [...groupedInstances.api]; - } - - this.defaultInstances = groupedInstances; - this.instancesLoaded = true; - - try { - localStorage.setItem( - this.STORAGE_KEY, - JSON.stringify({ - timestamp: Date.now(), - data: groupedInstances, - }) - ); - } catch (e) { - console.warn('Failed to cache instances:', e); - } - - this._loadPromise = null; - return groupedInstances; - })(); - - return this._loadPromise; + this.instancesLoaded = true; + return this.defaultInstances; }, async getInstances(type = 'api', _sortBySpeed = false) { - let instancesObj; - - instancesObj = await this.loadInstancesFromGitHub(); - const userInst = this._loadUserInstances(); - - const defaultUrls = instancesObj[type] || instancesObj.api || []; - const userUrls = userInst[type] || []; - - const combined = [ - ...userUrls.map((u) => (typeof u === 'string' ? { url: u, isUser: true } : { ...u, isUser: true })), - ...defaultUrls, - ]; - - if (combined.length === 0) return []; - - return combined; + const instancesObj = await this.loadInstancesFromGitHub(); + return instancesObj[type] || instancesObj.api || []; }, addUserInstance(type, url) { @@ -191,42 +68,6 @@ export const apiSettings = { this.instancesLoaded = false; this._loadPromise = null; localStorage.removeItem(this.STORAGE_KEY); - - const instances = await this.loadInstancesFromGitHub(); - - const shuffle = (array) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - const prioritySort = (array) => { - const getUrl = (item) => (typeof item === 'string' ? item : item.url || ''); - const top = []; - const middle = []; - const bottom = []; - for (const item of array) { - const url = getUrl(item); - if (url.includes('hifi.geeked.wtf')) top.push(item); - else if (url.includes('.qqdl.site')) bottom.push(item); - else middle.push(item); - } - return [...top, ...shuffle(middle), ...shuffle(bottom)]; - }; - - if (instances.api && instances.api.length) { - instances.api = prioritySort([...instances.api]); - } - - if (instances.streaming && instances.streaming.length) { - instances.streaming = prioritySort([...instances.streaming]); - } - - this.saveInstances(instances); - - // Return API instances for the UI to render (default view) return this.getInstances('api'); }, saveInstances(instances, type) { diff --git a/js/ui.js b/js/ui.js index 5827d7f..164c30b 100644 --- a/js/ui.js +++ b/js/ui.js @@ -93,6 +93,8 @@ const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => { button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24); }; +const isMobileFullscreenViewport = () => window.matchMedia('(max-width: 768px)').matches; + function sortTracks(tracks, sortType) { if (sortType === 'custom') return [...tracks]; const sorted = [...tracks]; @@ -157,6 +159,8 @@ export class UIRenderer { this.currentArtistId = null; this.fullscreenLyricsVisible = true; this.fullscreenPlaybackStateCleanup = null; + this.fullscreenDismissHandleCleanup = null; + this.fullscreenLyricsToggleCleanup = null; // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { @@ -1046,9 +1050,13 @@ export class UIRenderer { let r = parseInt(hex.substr(0, 2), 16); let g = parseInt(hex.substr(2, 2), 16); let b = parseInt(hex.substr(4, 2), 16); + let fullscreenR = r; + let fullscreenG = g; + let fullscreenB = b; // Calculate perceived brightness let brightness = (r * 299 + g * 587 + b * 114) / 1000; + let fullscreenBrightness = brightness; if (isLightMode) { // In light mode, the background is white. @@ -1075,6 +1083,23 @@ export class UIRenderer { } const adjustedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + while (fullscreenBrightness < 105) { + fullscreenR = Math.min(255, Math.max(fullscreenR + 1, Math.floor(fullscreenR * 1.08))); + fullscreenG = Math.min(255, Math.max(fullscreenG + 1, Math.floor(fullscreenG * 1.08))); + fullscreenB = Math.min(255, Math.max(fullscreenB + 1, Math.floor(fullscreenB * 1.08))); + fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000; + if (fullscreenR >= 255 && fullscreenG >= 255 && fullscreenB >= 255) break; + } + while (fullscreenBrightness > 185) { + fullscreenR = Math.floor(fullscreenR * 0.92); + fullscreenG = Math.floor(fullscreenG * 0.92); + fullscreenB = Math.floor(fullscreenB * 0.92); + fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000; + } + + const fullscreenAdjustedColor = `#${fullscreenR.toString(16).padStart(2, '0')}${fullscreenG + .toString(16) + .padStart(2, '0')}${fullscreenB.toString(16).padStart(2, '0')}`; // Calculate contrast text color for buttons (text on top of the vibrant color) const foreground = brightness > 128 ? '#000000' : '#ffffff'; @@ -1086,6 +1111,8 @@ export class UIRenderer { root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`); root.style.setProperty('--active-highlight', adjustedColor); root.style.setProperty('--ring', adjustedColor); + root.style.setProperty('--fs-accent', fullscreenAdjustedColor); + root.style.setProperty('--fs-accent-rgb', `${fullscreenR}, ${fullscreenG}, ${fullscreenB}`); // Calculate a safe hover color let hoverColor; @@ -1108,6 +1135,8 @@ export class UIRenderer { root.style.removeProperty('--highlight-rgb'); root.style.removeProperty('--active-highlight'); root.style.removeProperty('--ring'); + root.style.removeProperty('--fs-accent'); + root.style.removeProperty('--fs-accent-rgb'); root.style.removeProperty('--track-hover-bg'); } @@ -1221,7 +1250,6 @@ export class UIRenderer { currentImage.src = coverUrl; } } - overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`); await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80')); } @@ -1239,7 +1267,7 @@ export class UIRenderer { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) { if (!track) return; - this.fullscreenVisualizerSuppressed = true; + this.fullscreenVisualizerSuppressed = isMobileFullscreenViewport(); if (window.location.hash !== '#fullscreen') { window.history.pushState({ fullscreen: true }, '', '#fullscreen'); } @@ -1261,18 +1289,21 @@ export class UIRenderer { const canRenderLyrics = Boolean(lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'); if (canRenderLyrics) { - lyricsToggleBtn.style.display = 'none'; + this.fullscreenLyricsVisible = true; + if (lyricsToggleBtn) lyricsToggleBtn.style.removeProperty('display'); overlay.classList.remove('lyrics-unavailable'); clearFullscreenLyricsSync(lyricsContent); await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent); } else { - lyricsToggleBtn.style.display = 'none'; + this.fullscreenLyricsVisible = false; + if (lyricsToggleBtn) lyricsToggleBtn.style.display = 'none'; overlay.classList.add('lyrics-unavailable'); if (lyricsContent) { clearFullscreenLyricsSync(lyricsContent); lyricsContent.innerHTML = '
Lyrics are not available for this track.
'; } } + this.updateFullscreenLyricsVisibility(overlay); const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.display = 'none'; @@ -1303,9 +1334,64 @@ export class UIRenderer { this.setupUIToggleButton(overlay); this.setupControlsAutoHide(overlay); this.setupFullscreenSidePanelSync(overlay); + this.setupFullscreenDismissHandle(overlay); + this.setupFullscreenLyricsToggle(overlay); await this.refreshFullscreenVisualizerState(activeElement); } + updateFullscreenLyricsVisibility(overlay = document.getElementById('fullscreen-cover-overlay')) { + if (!overlay) return; + + const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); + const lyricsUnavailable = overlay.classList.contains('lyrics-unavailable'); + const shouldShowLyrics = this.fullscreenLyricsVisible && !lyricsUnavailable; + + overlay.classList.toggle('lyrics-hidden', !shouldShowLyrics); + + if (lyricsToggleBtn) { + lyricsToggleBtn.classList.toggle('active', shouldShowLyrics); + lyricsToggleBtn.title = shouldShowLyrics ? 'Hide Lyrics' : 'Show Lyrics'; + lyricsToggleBtn.setAttribute('aria-pressed', shouldShowLyrics ? 'true' : 'false'); + if (lyricsUnavailable) { + lyricsToggleBtn.style.display = 'none'; + } else { + lyricsToggleBtn.style.removeProperty('display'); + } + } + } + + async dismissFullscreenCover({ animate = true } = {}) { + const overlay = document.getElementById('fullscreen-cover-overlay'); + if (!overlay || overlay.style.display === 'none') return; + + if (animate) { + await new Promise((resolve) => { + const finish = () => { + overlay.removeEventListener('transitionend', handleTransitionEnd); + overlay.classList.remove('fullscreen-dragging', 'fullscreen-dismissing'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + resolve(); + }; + + const handleTransitionEnd = (event) => { + if (event.target !== overlay.querySelector('.fullscreen-cover-content')) return; + finish(); + }; + + overlay.addEventListener('transitionend', handleTransitionEnd); + overlay.classList.add('fullscreen-dismissing'); + window.setTimeout(finish, 280); + }); + } + + this.closeFullscreenCover(); + + if (window.location.hash === '#fullscreen') { + window.history.back(); + } + } + closeFullscreenCover() { const overlay = document.getElementById('fullscreen-cover-overlay'); const coverImage = document.getElementById('fullscreen-cover-image'); @@ -1318,7 +1404,16 @@ export class UIRenderer { lyricsContent.innerHTML = '
Lyrics appear here.
'; } overlay.style.display = 'none'; - overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round', 'fullscreen-paused'); + overlay.classList.remove( + 'visualizer-active', + 'ui-hidden', + 'fullscreen-cover-no-round', + 'fullscreen-paused', + 'fullscreen-dragging', + 'fullscreen-dismissing' + ); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.removeProperty('display'); @@ -1379,6 +1474,16 @@ export class UIRenderer { this.fullscreenSidePanelSyncCleanup(); this.fullscreenSidePanelSyncCleanup = null; } + + if (this.fullscreenDismissHandleCleanup) { + this.fullscreenDismissHandleCleanup(); + this.fullscreenDismissHandleCleanup = null; + } + + if (this.fullscreenLyricsToggleCleanup) { + this.fullscreenLyricsToggleCleanup(); + this.fullscreenLyricsToggleCleanup = null; + } } async startFullscreenVisualizer(activeElement, overlay) { @@ -1393,6 +1498,7 @@ export class UIRenderer { } if (this.visualizer) { + this.visualizer.applyPresetOverride('kawarp'); await this.visualizer.start(); overlay.classList.add('visualizer-active'); } @@ -1438,7 +1544,8 @@ export class UIRenderer { const visualizerBtn = document.getElementById('fs-visualizer-btn'); const toggleBtn = document.getElementById('toggle-ui-btn'); const isVideoTrack = this.player?.currentTrack?.type === 'video'; - const enabled = visualizerSettings.isEnabled() && !isVideoTrack && !this.fullscreenVisualizerSuppressed; + const enabled = + !isVideoTrack && !this.fullscreenVisualizerSuppressed && !isMobileFullscreenViewport(); if (!overlay) return; @@ -1604,6 +1711,136 @@ export class UIRenderer { }; } + setupFullscreenDismissHandle(overlay) { + if (this.fullscreenDismissHandleCleanup) { + this.fullscreenDismissHandleCleanup(); + this.fullscreenDismissHandleCleanup = null; + } + + const handle = document.getElementById('fullscreen-dismiss-handle'); + if (!handle) return; + + let activePointerId = null; + let startY = 0; + let startX = 0; + let lastY = 0; + let lastTimestamp = 0; + let velocityY = 0; + let hasDragged = false; + + const resetDragState = () => { + activePointerId = null; + hasDragged = false; + overlay.classList.remove('fullscreen-dragging'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + }; + + const onPointerDown = (event) => { + if (!isMobileFullscreenViewport()) return; + + activePointerId = event.pointerId; + startY = event.clientY; + startX = event.clientX; + lastY = event.clientY; + lastTimestamp = event.timeStamp; + velocityY = 0; + hasDragged = false; + overlay.classList.add('fullscreen-dragging'); + handle.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event) => { + if (event.pointerId !== activePointerId) return; + + const deltaY = Math.max(0, event.clientY - startY); + const deltaX = Math.abs(event.clientX - startX); + + if (!hasDragged && deltaX > deltaY) { + resetDragState(); + return; + } + + hasDragged = true; + event.preventDefault(); + + const elapsed = Math.max(1, event.timeStamp - lastTimestamp); + velocityY = (event.clientY - lastY) / elapsed; + lastY = event.clientY; + lastTimestamp = event.timeStamp; + + const progress = Math.min(deltaY / Math.max(window.innerHeight * 0.32, 1), 1); + overlay.style.setProperty('--fullscreen-drag-offset', `${deltaY}px`); + overlay.style.setProperty('--fullscreen-drag-progress', progress.toFixed(3)); + }; + + const onPointerEnd = async (event) => { + if (event.pointerId !== activePointerId) return; + + const deltaY = Math.max(0, event.clientY - startY); + const shouldDismiss = hasDragged && (deltaY > 96 || velocityY > 0.55); + + if (handle.hasPointerCapture(event.pointerId)) { + handle.releasePointerCapture(event.pointerId); + } + + if (shouldDismiss) { + await this.dismissFullscreenCover(); + return; + } + + resetDragState(); + }; + + const onClick = async (event) => { + if (!isMobileFullscreenViewport() || hasDragged) return; + event.preventDefault(); + await this.dismissFullscreenCover(); + }; + + handle.addEventListener('pointerdown', onPointerDown); + handle.addEventListener('pointermove', onPointerMove); + handle.addEventListener('pointerup', onPointerEnd); + handle.addEventListener('pointercancel', onPointerEnd); + handle.addEventListener('click', onClick); + + this.fullscreenDismissHandleCleanup = () => { + handle.removeEventListener('pointerdown', onPointerDown); + handle.removeEventListener('pointermove', onPointerMove); + handle.removeEventListener('pointerup', onPointerEnd); + handle.removeEventListener('pointercancel', onPointerEnd); + handle.removeEventListener('click', onClick); + overlay.classList.remove('fullscreen-dragging'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + }; + } + + setupFullscreenLyricsToggle(overlay) { + if (this.fullscreenLyricsToggleCleanup) { + this.fullscreenLyricsToggleCleanup(); + this.fullscreenLyricsToggleCleanup = null; + } + + const toggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); + if (!toggleBtn) return; + + const handleToggle = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (overlay.classList.contains('lyrics-unavailable')) return; + this.fullscreenLyricsVisible = !this.fullscreenLyricsVisible; + this.updateFullscreenLyricsVisibility(overlay); + }; + + toggleBtn.addEventListener('click', handleToggle); + this.updateFullscreenLyricsVisibility(overlay); + + this.fullscreenLyricsToggleCleanup = () => { + toggleBtn.removeEventListener('click', handleToggle); + }; + } + setupFullscreenControls() { const playBtn = document.getElementById('fs-play-pause-btn'); const prevBtn = document.getElementById('fs-prev-btn'); @@ -1673,16 +1910,7 @@ export class UIRenderer { if (visualizerBtn) { visualizerBtn.onclick = async () => { - if (this.fullscreenVisualizerSuppressed) { - this.fullscreenVisualizerSuppressed = false; - visualizerSettings.setEnabled(true); - } else if (visualizerSettings.isEnabled()) { - visualizerSettings.setEnabled(false); - this.fullscreenVisualizerSuppressed = false; - } else { - this.fullscreenVisualizerSuppressed = false; - visualizerSettings.setEnabled(true); - } + this.fullscreenVisualizerSuppressed = !this.fullscreenVisualizerSuppressed; await this.refreshFullscreenVisualizerState(this.player.activeElement); }; } diff --git a/js/visualizer.js b/js/visualizer.js index 49017d2..ff8a988 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -318,4 +318,16 @@ export class Visualizer { }); } } + + applyPresetOverride(key) { + if (!this.presets?.[key] || this.activePresetKey === key) return; + + if (this.activePreset?.destroy) { + this.activePreset.destroy(); + } + + this._currentContextType = undefined; + this.ctx = null; + this.activePresetKey = key; + } } diff --git a/public/instances.json b/public/instances.json index d5a8bb9..b9fce85 100644 --- a/public/instances.json +++ b/public/instances.json @@ -1,25 +1,8 @@ { "api": [ - "https://eu-central.monochrome.tf", - "https://us-west.monochrome.tf", - "https://arran.monochrome.tf", - "https://api.monochrome.tf/", - "https://monochrome-api.samidy.com", - "https://triton.squid.wtf", - "https://wolf.qqdl.site", - "https://maus.qqdl.site", - "https://vogel.qqdl.site", - "https://hund.qqdl.site", - "https://tidal.kinoplus.online" + "https://hifi.geeked.wtf" ], "streaming": [ - "https://arran.monochrome.tf", - "https://triton.squid.wtf", - "https://wolf.qqdl.site", - "https://maus.qqdl.site", - "https://vogel.qqdl.site", - "https://katze.qqdl.site", - "https://hund.qqdl.site", - "https://hifi.p1nkhamster.xyz/" + "https://hifi.geeked.wtf" ] } diff --git a/styles.css b/styles.css index 57d66e9..7b32f2e 100644 --- a/styles.css +++ b/styles.css @@ -3920,29 +3920,25 @@ input:checked + .slider::before { justify-content: center; animation: fade-in 0.3s ease; overflow: hidden; - background-color: var(--background); - - /* Use a CSS variable for the image so we can set it in JS */ - --bg-image: none; + background-color: rgb(11 13 17); /* Reserve space above taskbar / system UI so volume controls stay visible (fixes #322) */ padding-bottom: max(env(safe-area-inset-bottom), 1.5rem); + --fullscreen-drag-progress: 0; + --fs-accent-rgb: var(--highlight-rgb); } #fullscreen-cover-overlay::before { content: ''; position: absolute; - inset: -20px; - background-size: cover; - background-position: center; - background-repeat: no-repeat; - filter: var(--cover-filter); + inset: 0; + background: + radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.035), transparent 58%), + linear-gradient(180deg, rgb(6 8 12 / 0.12), rgb(6 8 12 / 0.34)); z-index: -1; - background-image: var(--bg-image); transition: - background-image var(--transition), - filter 0.65s ease, opacity 0.65s ease; + opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.32)); } #fullscreen-cover-overlay::after { @@ -3950,10 +3946,10 @@ input:checked + .slider::before { position: absolute; inset: 0; background: - radial-gradient(circle at 20% 22%, rgb(var(--highlight-rgb) / 0.28), transparent 36%), + radial-gradient(circle at 20% 22%, rgb(var(--fs-accent-rgb) / 0.28), transparent 36%), radial-gradient(circle at 82% 18%, rgb(255 255 255 / 0.09), transparent 28%), - linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--highlight-rgb) / 0.12) 100%); - opacity: 0.36; + linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--fs-accent-rgb) / 0.12) 100%); + opacity: calc(0.36 - (var(--fullscreen-drag-progress, 0) * 0.26)); pointer-events: none; z-index: 0; transition: @@ -3968,9 +3964,9 @@ input:checked + .slider::before { height: 100%; z-index: 0; pointer-events: none; - filter: blur(14px) saturate(0.84) brightness(0.8); - transform: scale(1.04); - opacity: 0.82; + filter: blur(8px) saturate(0.9) brightness(0.8); + transform: scale(1.03); + opacity: 0.8; transition: opacity 0.65s ease, filter 0.65s ease, @@ -3994,6 +3990,51 @@ input:checked + .slider::before { position: relative; padding: 1rem; overflow: hidden; + transform: translateY(var(--fullscreen-drag-offset, 0px)); + opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.16)); + transition: + transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.22s ease; + will-change: transform, opacity; +} + +#fullscreen-dismiss-handle { + position: absolute; + top: calc(0.75rem + env(safe-area-inset-top)); + left: 50%; + width: 3.25rem; + height: 1rem; + border: 0; + padding: 0; + margin: 0; + background: transparent; + transform: translateX(-50%); + z-index: 14; + display: none; + cursor: grab; + touch-action: none; +} + +#fullscreen-dismiss-handle::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 3rem; + height: 0.3rem; + border-radius: 999px; + transform: translate(-50%, -50%); + background: rgb(255 255 255 / 0.28); + box-shadow: 0 2px 12px rgb(0 0 0 / 0.25); +} + +#fullscreen-cover-overlay.fullscreen-dragging .fullscreen-cover-content { + transition: none; +} + +#fullscreen-cover-overlay.fullscreen-dismissing .fullscreen-cover-content { + transform: translateY(calc(100% + 3rem)); + opacity: 0; } /* UI Toggle Button for Visualizer Mode - Rightmost position */ @@ -10049,19 +10090,25 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-main-view { - width: min(1240px, 100%); + --fs-media-column-size: minmax(340px, 430px); + --fs-lyrics-column-size: minmax(520px, 760px); + width: min(1480px, 100%); height: 100%; flex: 1; display: grid; - grid-template-columns: minmax(360px, 430px) minmax(420px, 1fr); - gap: clamp(1.5rem, 3vw, 3rem); + grid-template-columns: var(--fs-media-column-size) var(--fs-lyrics-column-size); + gap: clamp(2rem, 4vw, 4.5rem); align-items: center; justify-content: center; - padding: clamp(4rem, 7vh, 5rem) clamp(2rem, 4vw, 3rem) clamp(3rem, 6vh, 4rem) clamp(4rem, 7vw, 6.25rem); + padding: clamp(4rem, 7vh, 5rem) clamp(3rem, 6vw, 5rem) clamp(3rem, 6vh, 4rem); position: relative; z-index: 1; min-height: 0; overflow: hidden; + transition: + grid-template-columns 0.34s cubic-bezier(0.22, 1, 0.36, 1), + width 0.34s cubic-bezier(0.22, 1, 0.36, 1), + gap 0.34s cubic-bezier(0.22, 1, 0.36, 1); } #fullscreen-cover-overlay .fullscreen-media-column, @@ -10075,7 +10122,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { flex-direction: column; gap: 0.95rem; justify-self: center; - transform: translateX(clamp(0.75rem, 1.2vw, 1.4rem)); + transform: none; + transition: + width 0.34s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.34s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.24s ease; } #fullscreen-cover-overlay .fullscreen-artwork-card { @@ -10128,7 +10179,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay #toggle-fullscreen-lyrics-btn, #fullscreen-cover-overlay .fullscreen-lyrics-toggle { - display: none !important; + display: flex; } #fullscreen-cover-overlay .fullscreen-actions { @@ -10206,9 +10257,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn { - order: 2; + order: 3; } +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn, #fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn, #fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { position: static; @@ -10220,6 +10272,15 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 1; } +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn { + order: 2; +} + +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn.active { + color: rgb(255 255 255 / 0.96); + background: rgb(255 255 255 / 0.12); +} + #fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { order: 1; } @@ -10240,7 +10301,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay #toggle-ui-btn { top: 1.25rem; - left: calc(80px + 2.3rem + env(safe-area-inset-left)); + left: calc(9.9rem + env(safe-area-inset-left)); right: auto; width: 40px; height: 40px; @@ -10285,7 +10346,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-buttons button.active { - color: rgb(var(--highlight-rgb) / 0.98); + color: rgb(var(--fs-accent-rgb) / 0.98); } #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { @@ -10326,7 +10387,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fs-visualizer-btn.active { - color: rgb(var(--highlight-rgb) / 0.96); + color: rgb(var(--fs-accent-rgb) / 0.96); } #fullscreen-cover-overlay .fs-volume-btn { @@ -10372,7 +10433,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill, #fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill { - background: rgb(var(--highlight-rgb) / 0.94); + background: rgb(var(--fs-accent-rgb) / 0.94); } #fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill::after, @@ -10389,6 +10450,13 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { align-items: stretch; justify-content: flex-start; overflow: hidden; + min-width: 0; + opacity: 1; + transform: translateX(0); + transition: + opacity 0.24s ease, + transform 0.34s cubic-bezier(0.22, 1, 0.36, 1), + visibility 0s linear 0s; } #fullscreen-cover-overlay .fullscreen-lyrics-shell, @@ -10404,14 +10472,14 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-lyrics-shell { width: min(860px, 100%); min-height: 0; - margin-left: clamp(4rem, 8vw, 8rem); + margin-left: 0; } #fullscreen-cover-overlay .fullscreen-lyrics-content { min-height: 0; height: 100%; position: relative; - padding-left: clamp(2.5rem, 4vw, 4rem); + padding-left: clamp(0.5rem, 1.6vw, 1.5rem); mask-image: none; overflow: visible; scrollbar-width: none; @@ -10459,6 +10527,34 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 0.55; } +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { + --fs-media-column-size: minmax(420px, 760px); + --fs-lyrics-column-size: minmax(0, 0fr); + width: min(760px, 100%); + gap: 0; +} + +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-media-column { + justify-self: center; + width: min(520px, 100%); + transform: translateX(clamp(2rem, 4vw, 3.5rem)); +} + +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-lyrics-pane { + opacity: 0; + transform: translateX(2rem); + visibility: hidden; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + #fullscreen-cover-overlay .fullscreen-main-view, + #fullscreen-cover-overlay .fullscreen-media-column, + #fullscreen-cover-overlay .fullscreen-lyrics-pane { + transition: none !important; + } +} + #fullscreen-cover-overlay.queue-panel-active .fullscreen-main-view { grid-template-columns: 1fr; width: min(760px, 100%); @@ -10475,110 +10571,217 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { @media (max-width: 980px) { #fullscreen-cover-overlay .fullscreen-main-view { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; width: min(760px, 100%); - gap: 1rem; - align-items: start; + gap: 1.25rem; + align-items: stretch; padding: - calc(4.5rem + env(safe-area-inset-top)) - clamp(1rem, 4vw, 1.5rem) + calc(5rem + env(safe-area-inset-top)) + clamp(1rem, 4vw, 1.75rem) calc(1.5rem + env(safe-area-inset-bottom)) - clamp(1rem, 4vw, 1.5rem); + clamp(1rem, 4vw, 1.75rem); } #fullscreen-cover-overlay .fullscreen-media-column { justify-self: center; transform: none; + width: min(100%, 620px); } #fullscreen-cover-overlay .fullscreen-lyrics-pane { - display: none; + display: flex; + width: min(100%, 620px); + justify-self: center; + min-height: min(48vh, 440px); + } + + #fullscreen-cover-overlay .fullscreen-lyrics-shell { + width: 100%; + margin-left: 0; + } + + #fullscreen-cover-overlay .fullscreen-lyrics-content { + padding-left: 0; } } @media (max-width: 768px) { #fullscreen-cover-overlay { - --fs-mobile-top-btn-size: 44px; + --fs-mobile-top-btn-size: 42px; --fs-mobile-top-btn-gap: 0.6rem; --fs-mobile-top-btn-left: calc(1rem + env(safe-area-inset-left)); } #fullscreen-cover-overlay .fullscreen-cover-content { - padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom)); + padding: 0 calc(0.9rem + env(safe-area-inset-right)) calc(0.9rem + env(safe-area-inset-bottom)) + calc(0.9rem + env(safe-area-inset-left)); + } + + #fullscreen-dismiss-handle { + display: block; } #fullscreen-cover-overlay .fullscreen-top-actions { - top: calc(0.75rem + env(safe-area-inset-top)); - left: var(--fs-mobile-top-btn-left); - gap: var(--fs-mobile-top-btn-gap); + display: none; } - #fullscreen-cover-overlay .fullscreen-top-actions button, + #fullscreen-cover-overlay .fullscreen-lyrics-toggle, #fullscreen-cover-overlay #toggle-ui-btn { - width: var(--fs-mobile-top-btn-size); - height: var(--fs-mobile-top-btn-size); - background: rgb(9 12 18 / 0.5); - } - - #fullscreen-cover-overlay #toggle-ui-btn { - top: calc(0.75rem + env(safe-area-inset-top)); - left: calc( - var(--fs-mobile-top-btn-left) + - (var(--fs-mobile-top-btn-size) * 2) + - (var(--fs-mobile-top-btn-gap) * 2) - ); + display: none !important; } #fullscreen-cover-overlay .fullscreen-main-view { width: 100%; - gap: 0.85rem; + height: 100%; + grid-template-columns: minmax(78px, 92px) minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + 'art info' + 'lyrics lyrics' + 'controls controls'; + gap: 1rem 0.9rem; padding: - calc(7.25rem + env(safe-area-inset-top)) - 0.75rem - calc(1.5rem + env(safe-area-inset-bottom)) - 0.75rem; + calc(4.45rem + env(safe-area-inset-top)) + 0 + calc(0.8rem + env(safe-area-inset-bottom)) + 0; } - #fullscreen-cover-overlay .fullscreen-track-info, - #fullscreen-cover-overlay .fullscreen-controls, #fullscreen-cover-overlay .fullscreen-media-column { - width: min(100%, 460px); + display: contents; + width: auto; + } + + #fullscreen-cover-overlay .fullscreen-artwork-card { + grid-area: art; + width: 100%; + max-width: 92px; + border-radius: 12px; + align-self: start; + margin-left: 0.95rem; + box-shadow: 0 20px 48px rgb(0 0 0 / 0.34); + } + + #fullscreen-cover-overlay #fullscreen-cover-image { + border-radius: 12px; + } + + #fullscreen-cover-overlay .fullscreen-track-info { + grid-area: info; + width: 100%; + min-width: 0; + align-self: center; + display: grid; + gap: 0.3rem; + padding-top: 0.2rem; + padding-left: 0.95rem; + } + + #fullscreen-cover-overlay .fullscreen-track-text { + display: grid; + gap: 0.14rem; + } + + #fullscreen-cover-overlay #fullscreen-track-title { + font-size: clamp(1.1rem, 4.7vw, 1.34rem); + line-height: 1.04; + } + + #fullscreen-cover-overlay #fullscreen-track-artist { + margin-top: 0; + font-size: 0.92rem; + color: rgb(255 255 255 / 0.7); } #fullscreen-cover-overlay .fullscreen-actions { + display: none !important; + } + + #fullscreen-cover-overlay #fullscreen-next-track { + display: none !important; + } + + #fullscreen-cover-overlay .fullscreen-lyrics-pane { + grid-area: lyrics; width: 100%; - flex-wrap: wrap; - gap: 0.45rem; + min-height: 0; + justify-self: stretch; } - #fullscreen-cover-overlay .fullscreen-actions .btn-icon { - background: rgb(255 255 255 / 0.06); + #fullscreen-cover-overlay .fullscreen-lyrics-shell { + min-height: 0; + position: relative; + background: transparent !important; + box-shadow: none !important; + border-radius: 0; + overflow: visible; } - #fullscreen-cover-overlay .fullscreen-progress-container { - gap: 0.65rem; + #fullscreen-cover-overlay .fullscreen-lyrics-content { + height: 100%; + padding: 0 0 0.2rem; + overflow: hidden; + mask-image: linear-gradient(180deg, transparent 0%, black 10%, black 88%, transparent 100%); } - #fullscreen-cover-overlay .fullscreen-buttons { - gap: 0.2rem; + #fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics { + --lyrics-scroll-padding-top: 18%; + --lyplus-font-size-base: clamp(1.75rem, 7vw, 2.35rem); + --lyplus-padding-line: 6px; + --lyplus-text-color: rgba(246, 244, 239, 0.16); + --lyplus-blur-amount: 0.16em; + --lyplus-blur-amount-near: 0.08em; + line-height: 1.2; } - #fullscreen-cover-overlay .fullscreen-buttons button { - width: 38px; - height: 38px; + #fullscreen-cover-overlay .fullscreen-lyrics-empty, + #fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-loading, + #fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-error { + padding: 2.5rem 1.2rem 0; } - #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { - width: 52px; - height: 52px; + #fullscreen-cover-overlay .fullscreen-controls { + grid-area: controls; + width: 100%; + max-width: none; + margin-top: 0; + padding: 0.15rem 0 0; + gap: 0.9rem; } #fullscreen-cover-overlay .fullscreen-volume-container { - width: min(220px, calc(100% - 2.75rem)); + display: none; + } + + #fullscreen-cover-overlay .fullscreen-progress-container { + gap: 0.55rem; + font-size: 0.72rem; + } + + #fullscreen-cover-overlay .fullscreen-buttons { + gap: 0.1rem; + justify-content: space-between; + } + + #fullscreen-cover-overlay .fullscreen-buttons button { + width: 42px; + height: 42px; + } + + #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { + width: 62px; + height: 62px; + box-shadow: 0 14px 28px rgb(0 0 0 / 0.3); + } + + #fullscreen-cover-overlay .fullscreen-volume-container { + width: min(280px, calc(100% - 3rem)); + margin-top: 0; } #fullscreen-cover-overlay .fs-volume-btn { - left: -2.25rem; + left: -2.5rem; } #fullscreen-cover-overlay .fs-volume-bar { From 4dd3ec1a0c64bfe1562dcc5f562f263eaf80102b Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sun, 5 Apr 2026 23:03:59 -0400 Subject: [PATCH 16/76] fix: add lyrics toggle for mobile --- index.html | 3 ++ js/ui.js | 20 ++++++++---- styles.css | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 4125d3c..4415024 100644 --- a/index.html +++ b/index.html @@ -206,6 +206,9 @@ " >
+ diff --git a/js/ui.js b/js/ui.js index b7cb8e8..1df95f6 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1392,13 +1392,16 @@ export class UIRenderer { updateFullscreenLyricsVisibility(overlay = document.getElementById('fullscreen-cover-overlay')) { if (!overlay) return; - const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); + const lyricsToggleButtons = [ + document.getElementById('toggle-fullscreen-lyrics-btn'), + document.getElementById('toggle-fullscreen-lyrics-mobile-btn'), + ].filter(Boolean); const lyricsUnavailable = overlay.classList.contains('lyrics-unavailable'); const shouldShowLyrics = this.fullscreenLyricsVisible && !lyricsUnavailable; overlay.classList.toggle('lyrics-hidden', !shouldShowLyrics); - if (lyricsToggleBtn) { + lyricsToggleButtons.forEach((lyricsToggleBtn) => { lyricsToggleBtn.classList.toggle('active', shouldShowLyrics); lyricsToggleBtn.title = shouldShowLyrics ? 'Hide Lyrics' : 'Show Lyrics'; lyricsToggleBtn.setAttribute('aria-pressed', shouldShowLyrics ? 'true' : 'false'); @@ -1407,7 +1410,7 @@ export class UIRenderer { } else { lyricsToggleBtn.style.removeProperty('display'); } - } + }); } async dismissFullscreenCover({ animate = true } = {}) { @@ -1870,8 +1873,11 @@ export class UIRenderer { this.fullscreenLyricsToggleCleanup = null; } - const toggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); - if (!toggleBtn) return; + const toggleButtons = [ + document.getElementById('toggle-fullscreen-lyrics-btn'), + document.getElementById('toggle-fullscreen-lyrics-mobile-btn'), + ].filter(Boolean); + if (toggleButtons.length === 0) return; const handleToggle = (event) => { event.preventDefault(); @@ -1881,11 +1887,11 @@ export class UIRenderer { this.updateFullscreenLyricsVisibility(overlay); }; - toggleBtn.addEventListener('click', handleToggle); + toggleButtons.forEach((toggleBtn) => toggleBtn.addEventListener('click', handleToggle)); this.updateFullscreenLyricsVisibility(overlay); this.fullscreenLyricsToggleCleanup = () => { - toggleBtn.removeEventListener('click', handleToggle); + toggleButtons.forEach((toggleBtn) => toggleBtn.removeEventListener('click', handleToggle)); }; } setupFullscreenControls() { diff --git a/styles.css b/styles.css index 32f6a52..67ef486 100644 --- a/styles.css +++ b/styles.css @@ -4112,6 +4112,38 @@ input:checked + .slider::before { background: var(--primary); } +#toggle-fullscreen-lyrics-mobile-btn { + display: none; + position: absolute; + top: calc(0.85rem + env(safe-area-inset-top)); + right: calc(0.9rem + env(safe-area-inset-right)); + width: 38px; + height: 38px; + border: none; + border-radius: 999px; + padding: 0; + align-items: center; + justify-content: center; + background: rgb(9 12 18 / 0.32); + color: rgb(255 255 255 / 0.76); + backdrop-filter: blur(10px); + z-index: 14; + transition: + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease; +} + +#toggle-fullscreen-lyrics-mobile-btn.active { + background: rgb(255 255 255 / 0.12); + color: rgb(255 255 255 / 0.96); +} + +#toggle-fullscreen-lyrics-mobile-btn:hover { + transform: scale(1.04); +} + /* Close Button - Leftmost position */ #close-fullscreen-cover-btn { position: absolute; @@ -10626,6 +10658,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { display: none !important; } + #toggle-fullscreen-lyrics-mobile-btn { + display: flex; + } + #fullscreen-cover-overlay .fullscreen-main-view { width: 100%; height: 100%; @@ -10770,6 +10806,64 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { box-shadow: 0 14px 28px rgb(0 0 0 / 0.3); } + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto auto auto; + grid-template-areas: + '.' + 'art' + 'info' + 'controls'; + align-content: stretch; + justify-items: center; + gap: 0.95rem; + padding: + calc(4.45rem + env(safe-area-inset-top)) + 0 + calc(0.8rem + env(safe-area-inset-bottom)) + 0; + height: 100%; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-lyrics-pane { + display: none; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-artwork-card { + max-width: min(88vw, 320px); + width: min(88vw, 320px); + margin-left: 0; + justify-self: center; + border-radius: 14px; + box-shadow: 0 24px 56px rgb(0 0 0 / 0.28); + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-cover-image { + border-radius: 14px; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-track-info { + align-self: start; + justify-self: center; + width: min(88vw, 320px); + padding: 0; + gap: 0.3rem; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-track-text { + gap: 0.2rem; + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-title { + font-size: clamp(1.15rem, 5vw, 1.5rem); + line-height: 1.05; + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-artist { + font-size: 0.95rem; + color: rgb(255 255 255 / 0.66); + } + #fullscreen-cover-overlay .fullscreen-volume-container { width: min(280px, calc(100% - 3rem)); margin-top: 0; From daa553488f84e865ac1468ac53cc9695cfa1e73e Mon Sep 17 00:00:00 2001 From: Alan Brooks Date: Sun, 5 Apr 2026 23:41:34 -0400 Subject: [PATCH 17/76] add lyrics toggle for mobile --- index.html | 2 +- js/ui.js | 20 ++++++++++-- styles.css | 96 +++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 100 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index 4415024..605a632 100644 --- a/index.html +++ b/index.html @@ -261,6 +261,7 @@
+
0:00
@@ -284,7 +285,6 @@ -
+ + -
+ @@ -257,6 +261,7 @@
+
0:00
@@ -280,12 +285,7 @@ - diff --git a/js/lyrics.js b/js/lyrics.js index 8ee4dcf..96a7dee 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -1013,12 +1013,16 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) { } .lyrics-line { + transform-origin: left center; transition: opacity 0.42s ease, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms), filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important; } + .lyrics-line:not(.active):not(.pre-active) { + opacity: 0.44; + } .lyrics-line-container { transition: transform 0.72s cubic-bezier(0.22, 1, 0.36, 1), @@ -1033,6 +1037,10 @@ function applyFullscreenLyricsShadowTweaks(amLyrics, container) { background-color 0.22s ease, color 0.22s ease !important; } + + .lyrics-line.active .lyrics-line-container { + transform: scale(1.015); + } `; return true; diff --git a/js/ui.js b/js/ui.js index 763f4f6..da78079 100644 --- a/js/ui.js +++ b/js/ui.js @@ -93,6 +93,7 @@ const setFullscreenUIToggleIcon = (button, visualizerOnlyMode) => { button.innerHTML = visualizerOnlyMode ? SVG_EYE(24) : SVG_EYE_OFF(24); }; +const isMobileFullscreenViewport = () => window.matchMedia('(max-width: 768px)').matches; function sortTracks(tracks, sortType) { if (sortType === 'custom') return [...tracks]; const sorted = [...tracks]; @@ -155,6 +156,10 @@ export class UIRenderer { this.renderLock = false; this.lastRecommendedTracks = []; this.currentArtistId = null; + this.fullscreenLyricsVisible = true; + this.fullscreenPlaybackStateCleanup = null; + this.fullscreenDismissHandleCleanup = null; + this.fullscreenLyricsToggleCleanup = null; // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { @@ -1095,9 +1100,13 @@ export class UIRenderer { let r = parseInt(hex.substr(0, 2), 16); let g = parseInt(hex.substr(2, 2), 16); let b = parseInt(hex.substr(4, 2), 16); + let fullscreenR = r; + let fullscreenG = g; + let fullscreenB = b; // Calculate perceived brightness let brightness = (r * 299 + g * 587 + b * 114) / 1000; + let fullscreenBrightness = brightness; if (isLightMode) { // In light mode, the background is white. @@ -1124,6 +1133,23 @@ export class UIRenderer { } const adjustedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + while (fullscreenBrightness < 105) { + fullscreenR = Math.min(255, Math.max(fullscreenR + 1, Math.floor(fullscreenR * 1.08))); + fullscreenG = Math.min(255, Math.max(fullscreenG + 1, Math.floor(fullscreenG * 1.08))); + fullscreenB = Math.min(255, Math.max(fullscreenB + 1, Math.floor(fullscreenB * 1.08))); + fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000; + if (fullscreenR >= 255 && fullscreenG >= 255 && fullscreenB >= 255) break; + } + while (fullscreenBrightness > 185) { + fullscreenR = Math.floor(fullscreenR * 0.92); + fullscreenG = Math.floor(fullscreenG * 0.92); + fullscreenB = Math.floor(fullscreenB * 0.92); + fullscreenBrightness = (fullscreenR * 299 + fullscreenG * 587 + fullscreenB * 114) / 1000; + } + + const fullscreenAdjustedColor = `#${fullscreenR.toString(16).padStart(2, '0')}${fullscreenG + .toString(16) + .padStart(2, '0')}${fullscreenB.toString(16).padStart(2, '0')}`; // Calculate contrast text color for buttons (text on top of the vibrant color) const foreground = brightness > 128 ? '#000000' : '#ffffff'; @@ -1135,6 +1161,8 @@ export class UIRenderer { root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`); root.style.setProperty('--active-highlight', adjustedColor); root.style.setProperty('--ring', adjustedColor); + root.style.setProperty('--fs-accent', fullscreenAdjustedColor); + root.style.setProperty('--fs-accent-rgb', `${fullscreenR}, ${fullscreenG}, ${fullscreenB}`); // Calculate a safe hover color let hoverColor; @@ -1157,6 +1185,8 @@ export class UIRenderer { root.style.removeProperty('--highlight-rgb'); root.style.removeProperty('--active-highlight'); root.style.removeProperty('--ring'); + root.style.removeProperty('--fs-accent'); + root.style.removeProperty('--fs-accent-rgb'); root.style.removeProperty('--track-hover-bg'); } @@ -1270,12 +1300,10 @@ export class UIRenderer { currentImage.src = coverUrl; } } - overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`); await this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80')); } - const qualityBadge = this.getFullscreenQualityBadgeHTML(track); - title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`; + this.updateFullscreenQualityBadgePlacement(track, overlay); artist.textContent = getTrackArtists(track); if (nextTrack) { @@ -1288,7 +1316,7 @@ export class UIRenderer { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) { if (!track) return; - this.fullscreenVisualizerSuppressed = true; + this.fullscreenVisualizerSuppressed = isMobileFullscreenViewport(); if (window.location.hash !== '#fullscreen') { window.history.pushState({ fullscreen: true }, '', '#fullscreen'); } @@ -1308,23 +1336,23 @@ export class UIRenderer { nextTrackEl.classList.remove('animate-in'); } - const canRenderLyrics = Boolean( - lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video' - ); + const canRenderLyrics = Boolean(lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'); if (canRenderLyrics) { - lyricsToggleBtn.style.display = 'none'; + this.fullscreenLyricsVisible = true; + if (lyricsToggleBtn) lyricsToggleBtn.style.removeProperty('display'); overlay.classList.remove('lyrics-unavailable'); clearFullscreenLyricsSync(lyricsContent); await renderLyricsInFullscreen(track, activeElement, lyricsManager, lyricsContent); } else { - lyricsToggleBtn.style.display = 'none'; + this.fullscreenLyricsVisible = false; + if (lyricsToggleBtn) lyricsToggleBtn.style.display = 'none'; overlay.classList.add('lyrics-unavailable'); if (lyricsContent) { clearFullscreenLyricsSync(lyricsContent); - lyricsContent.innerHTML = - '
Lyrics are not available for this track.
'; + lyricsContent.innerHTML = '
Lyrics are not available for this track.
'; } } + this.updateFullscreenLyricsVisibility(overlay); const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.display = 'none'; @@ -1355,9 +1383,84 @@ export class UIRenderer { this.setupUIToggleButton(overlay); this.setupControlsAutoHide(overlay); this.setupFullscreenSidePanelSync(overlay); + this.setupFullscreenDismissHandle(overlay); + this.setupFullscreenLyricsToggle(overlay); await this.refreshFullscreenVisualizerState(activeElement); } + updateFullscreenLyricsVisibility(overlay = document.getElementById('fullscreen-cover-overlay')) { + if (!overlay) return; + + const lyricsToggleButtons = [ + document.getElementById('toggle-fullscreen-lyrics-btn'), + document.getElementById('toggle-fullscreen-lyrics-mobile-btn'), + ].filter(Boolean); + const lyricsUnavailable = overlay.classList.contains('lyrics-unavailable'); + const shouldShowLyrics = this.fullscreenLyricsVisible && !lyricsUnavailable; + + overlay.classList.toggle('lyrics-hidden', !shouldShowLyrics); + this.updateFullscreenQualityBadgePlacement(this.player?.currentTrack, overlay); + + lyricsToggleButtons.forEach((lyricsToggleBtn) => { + lyricsToggleBtn.classList.toggle('active', shouldShowLyrics); + lyricsToggleBtn.title = shouldShowLyrics ? 'Hide Lyrics' : 'Show Lyrics'; + lyricsToggleBtn.setAttribute('aria-pressed', shouldShowLyrics ? 'true' : 'false'); + if (lyricsUnavailable) { + lyricsToggleBtn.style.display = 'none'; + } else { + lyricsToggleBtn.style.removeProperty('display'); + } + }); + } + + updateFullscreenQualityBadgePlacement(track, overlay = document.getElementById('fullscreen-cover-overlay')) { + if (!track || !overlay) return; + + const title = document.getElementById('fullscreen-track-title'); + const mobileQuality = document.getElementById('fullscreen-mobile-quality'); + if (!title) return; + + const qualityBadge = this.getFullscreenQualityBadgeHTML(track); + const useMobileBadgeOnly = window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden'); + + title.innerHTML = useMobileBadgeOnly ? escapeHtml(track.title) : `${escapeHtml(track.title)} ${qualityBadge}`; + if (mobileQuality) { + mobileQuality.innerHTML = useMobileBadgeOnly ? qualityBadge : ''; + } + } + + async dismissFullscreenCover({ animate = true } = {}) { + const overlay = document.getElementById('fullscreen-cover-overlay'); + if (!overlay || overlay.style.display === 'none') return; + + if (animate) { + await new Promise((resolve) => { + const finish = () => { + overlay.removeEventListener('transitionend', handleTransitionEnd); + overlay.classList.remove('fullscreen-dragging', 'fullscreen-dismissing'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + resolve(); + }; + + const handleTransitionEnd = (event) => { + if (event.target !== overlay.querySelector('.fullscreen-cover-content')) return; + finish(); + }; + + overlay.addEventListener('transitionend', handleTransitionEnd); + overlay.classList.add('fullscreen-dismissing'); + window.setTimeout(finish, 280); + }); + } + + this.closeFullscreenCover(); + + if (window.location.hash === '#fullscreen') { + window.history.back(); + } + } + closeFullscreenCover() { const overlay = document.getElementById('fullscreen-cover-overlay'); const coverImage = document.getElementById('fullscreen-cover-image'); @@ -1370,16 +1473,22 @@ export class UIRenderer { lyricsContent.innerHTML = '
Lyrics appear here.
'; } overlay.style.display = 'none'; - overlay.classList.remove('visualizer-active', 'ui-hidden', 'fullscreen-cover-no-round', 'fullscreen-paused'); + overlay.classList.remove( + 'visualizer-active', + 'ui-hidden', + 'fullscreen-cover-no-round', + 'fullscreen-paused', + 'fullscreen-dragging', + 'fullscreen-dismissing' + ); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); const playerBar = document.querySelector('.now-playing-bar'); if (playerBar) playerBar.style.removeProperty('display'); const mainContent = document.querySelector('.main-content'); if (mainContent instanceof HTMLElement) { - if ( - typeof this.fullscreenMainContentOverflow === 'string' && - this.fullscreenMainContentOverflow.length > 0 - ) { + if (typeof this.fullscreenMainContentOverflow === 'string' && this.fullscreenMainContentOverflow.length > 0) { mainContent.style.overflowY = this.fullscreenMainContentOverflow; } else { mainContent.style.removeProperty('overflow-y'); @@ -1434,6 +1543,16 @@ export class UIRenderer { this.fullscreenSidePanelSyncCleanup(); this.fullscreenSidePanelSyncCleanup = null; } + + if (this.fullscreenDismissHandleCleanup) { + this.fullscreenDismissHandleCleanup(); + this.fullscreenDismissHandleCleanup = null; + } + + if (this.fullscreenLyricsToggleCleanup) { + this.fullscreenLyricsToggleCleanup(); + this.fullscreenLyricsToggleCleanup = null; + } } async startFullscreenVisualizer(activeElement, overlay) { @@ -1448,6 +1567,7 @@ export class UIRenderer { } if (this.visualizer) { + this.visualizer.applyPresetOverride('kawarp'); await this.visualizer.start(); overlay.classList.add('visualizer-active'); } @@ -1493,7 +1613,7 @@ export class UIRenderer { const visualizerBtn = document.getElementById('fs-visualizer-btn'); const toggleBtn = document.getElementById('toggle-ui-btn'); const isVideoTrack = this.player?.currentTrack?.type === 'video'; - const enabled = visualizerSettings.isEnabled() && !isVideoTrack && !this.fullscreenVisualizerSuppressed; + const enabled = !isVideoTrack && !this.fullscreenVisualizerSuppressed && !isMobileFullscreenViewport(); if (!overlay) return; @@ -1578,7 +1698,6 @@ export class UIRenderer { } this.fullscreenVisualizerSuppressed = false; - visualizerSettings.setEnabled(true); await this.refreshFullscreenVisualizerState(this.player?.activeElement); if (!overlay.classList.contains('visualizer-active')) { @@ -1659,6 +1778,138 @@ export class UIRenderer { }; } + setupFullscreenDismissHandle(overlay) { + if (this.fullscreenDismissHandleCleanup) { + this.fullscreenDismissHandleCleanup(); + this.fullscreenDismissHandleCleanup = null; + } + + const handle = document.getElementById('fullscreen-dismiss-handle'); + if (!handle) return; + + let activePointerId = null; + let startY = 0; + let startX = 0; + let lastY = 0; + let lastTimestamp = 0; + let velocityY = 0; + let hasDragged = false; + + const resetDragState = () => { + activePointerId = null; + hasDragged = false; + overlay.classList.remove('fullscreen-dragging'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + }; + + const onPointerDown = (event) => { + if (!isMobileFullscreenViewport()) return; + + activePointerId = event.pointerId; + startY = event.clientY; + startX = event.clientX; + lastY = event.clientY; + lastTimestamp = event.timeStamp; + velocityY = 0; + hasDragged = false; + overlay.classList.add('fullscreen-dragging'); + handle.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event) => { + if (event.pointerId !== activePointerId) return; + + const deltaY = Math.max(0, event.clientY - startY); + const deltaX = Math.abs(event.clientX - startX); + + if (!hasDragged && deltaX > deltaY) { + resetDragState(); + return; + } + + hasDragged = true; + event.preventDefault(); + + const elapsed = Math.max(1, event.timeStamp - lastTimestamp); + velocityY = (event.clientY - lastY) / elapsed; + lastY = event.clientY; + lastTimestamp = event.timeStamp; + + const progress = Math.min(deltaY / Math.max(window.innerHeight * 0.32, 1), 1); + overlay.style.setProperty('--fullscreen-drag-offset', `${deltaY}px`); + overlay.style.setProperty('--fullscreen-drag-progress', progress.toFixed(3)); + }; + + const onPointerEnd = async (event) => { + if (event.pointerId !== activePointerId) return; + + const deltaY = Math.max(0, event.clientY - startY); + const shouldDismiss = hasDragged && (deltaY > 96 || velocityY > 0.55); + + if (handle.hasPointerCapture(event.pointerId)) { + handle.releasePointerCapture(event.pointerId); + } + + if (shouldDismiss) { + await this.dismissFullscreenCover(); + return; + } + + resetDragState(); + }; + + const onClick = async (event) => { + if (!isMobileFullscreenViewport() || hasDragged) return; + event.preventDefault(); + await this.dismissFullscreenCover(); + }; + + handle.addEventListener('pointerdown', onPointerDown); + handle.addEventListener('pointermove', onPointerMove); + handle.addEventListener('pointerup', onPointerEnd); + handle.addEventListener('pointercancel', onPointerEnd); + handle.addEventListener('click', onClick); + + this.fullscreenDismissHandleCleanup = () => { + handle.removeEventListener('pointerdown', onPointerDown); + handle.removeEventListener('pointermove', onPointerMove); + handle.removeEventListener('pointerup', onPointerEnd); + handle.removeEventListener('pointercancel', onPointerEnd); + handle.removeEventListener('click', onClick); + overlay.classList.remove('fullscreen-dragging'); + overlay.style.removeProperty('--fullscreen-drag-offset'); + overlay.style.removeProperty('--fullscreen-drag-progress'); + }; + } + + setupFullscreenLyricsToggle(overlay) { + if (this.fullscreenLyricsToggleCleanup) { + this.fullscreenLyricsToggleCleanup(); + this.fullscreenLyricsToggleCleanup = null; + } + + const toggleButtons = [ + document.getElementById('toggle-fullscreen-lyrics-btn'), + document.getElementById('toggle-fullscreen-lyrics-mobile-btn'), + ].filter(Boolean); + if (toggleButtons.length === 0) return; + + const handleToggle = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (overlay.classList.contains('lyrics-unavailable')) return; + this.fullscreenLyricsVisible = !this.fullscreenLyricsVisible; + this.updateFullscreenLyricsVisibility(overlay); + }; + + toggleButtons.forEach((toggleBtn) => toggleBtn.addEventListener('click', handleToggle)); + this.updateFullscreenLyricsVisibility(overlay); + + this.fullscreenLyricsToggleCleanup = () => { + toggleButtons.forEach((toggleBtn) => toggleBtn.removeEventListener('click', handleToggle)); + }; + } setupFullscreenControls() { const playBtn = document.getElementById('fs-play-pause-btn'); const prevBtn = document.getElementById('fs-prev-btn'); @@ -1728,16 +1979,7 @@ export class UIRenderer { if (visualizerBtn) { visualizerBtn.onclick = async () => { - if (this.fullscreenVisualizerSuppressed) { - this.fullscreenVisualizerSuppressed = false; - visualizerSettings.setEnabled(true); - } else if (visualizerSettings.isEnabled()) { - visualizerSettings.setEnabled(false); - this.fullscreenVisualizerSuppressed = false; - } else { - this.fullscreenVisualizerSuppressed = false; - visualizerSettings.setEnabled(true); - } + this.fullscreenVisualizerSuppressed = !this.fullscreenVisualizerSuppressed; await this.refreshFullscreenVisualizerState(this.player.activeElement); }; } diff --git a/js/visualizer.js b/js/visualizer.js index d1b637f..b8c802f 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -337,4 +337,16 @@ export class Visualizer { }); } } + + applyPresetOverride(key) { + if (!this.presets?.[key] || this.activePresetKey === key) return; + + if (this.activePreset?.destroy) { + this.activePreset.destroy(); + } + + this._currentContextType = undefined; + this.ctx = null; + this.activePresetKey = key; + } } diff --git a/styles.css b/styles.css index 835d834..04e9b75 100644 --- a/styles.css +++ b/styles.css @@ -3575,7 +3575,7 @@ input:checked + .slider::before { } .player-controls .progress-container span { - min-width: 45px; + min-width: 40px; font-variant-numeric: tabular-nums; flex-shrink: 0; } @@ -3920,29 +3920,25 @@ input:checked + .slider::before { justify-content: center; animation: fade-in 0.3s ease; overflow: hidden; - background-color: var(--background); - - /* Use a CSS variable for the image so we can set it in JS */ - --bg-image: none; + background-color: rgb(11 13 17); /* Reserve space above taskbar / system UI so volume controls stay visible (fixes #322) */ padding-bottom: max(env(safe-area-inset-bottom), 1.5rem); + --fullscreen-drag-progress: 0; + --fs-accent-rgb: var(--highlight-rgb); } #fullscreen-cover-overlay::before { content: ''; position: absolute; - inset: -20px; - background-size: cover; - background-position: center; - background-repeat: no-repeat; - filter: var(--cover-filter); + inset: 0; + background: + radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.035), transparent 58%), + linear-gradient(180deg, rgb(6 8 12 / 0.12), rgb(6 8 12 / 0.34)); z-index: -1; - background-image: var(--bg-image); transition: - background-image var(--transition), - filter 0.65s ease, opacity 0.65s ease; + opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.32)); } #fullscreen-cover-overlay::after { @@ -3950,10 +3946,10 @@ input:checked + .slider::before { position: absolute; inset: 0; background: - radial-gradient(circle at 20% 22%, rgb(var(--highlight-rgb) / 0.28), transparent 36%), + radial-gradient(circle at 20% 22%, rgb(var(--fs-accent-rgb) / 0.28), transparent 36%), radial-gradient(circle at 82% 18%, rgb(255 255 255 / 0.09), transparent 28%), - linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--highlight-rgb) / 0.12) 100%); - opacity: 0.36; + linear-gradient(135deg, rgb(10 13 18 / 0.48), rgb(10 13 18 / 0.2) 38%, rgb(var(--fs-accent-rgb) / 0.12) 100%); + opacity: calc(0.36 - (var(--fullscreen-drag-progress, 0) * 0.26)); pointer-events: none; z-index: 0; transition: @@ -3968,9 +3964,9 @@ input:checked + .slider::before { height: 100%; z-index: 0; pointer-events: none; - filter: blur(14px) saturate(0.84) brightness(0.8); - transform: scale(1.04); - opacity: 0.82; + filter: blur(8px) saturate(0.9) brightness(0.8); + transform: scale(1.03); + opacity: 0.8; transition: opacity 0.65s ease, filter 0.65s ease, @@ -3991,9 +3987,57 @@ input:checked + .slider::before { justify-content: center; width: 100%; height: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; position: relative; padding: 1rem; overflow: hidden; + transform: translateY(var(--fullscreen-drag-offset, 0px)); + opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.16)); + transition: + transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.22s ease; + will-change: transform, opacity; +} + +#fullscreen-dismiss-handle { + position: absolute; + top: calc(0.75rem + env(safe-area-inset-top)); + left: 50%; + width: 3.25rem; + height: 1rem; + border: 0; + padding: 0; + margin: 0; + background: transparent; + transform: translateX(-50%); + z-index: 14; + display: none; + cursor: grab; + touch-action: none; +} + +#fullscreen-dismiss-handle::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 3rem; + height: 0.3rem; + border-radius: 999px; + transform: translate(-50%, -50%); + background: rgb(255 255 255 / 0.28); + box-shadow: 0 2px 12px rgb(0 0 0 / 0.25); +} + +#fullscreen-cover-overlay.fullscreen-dragging .fullscreen-cover-content { + transition: none; +} + +#fullscreen-cover-overlay.fullscreen-dismissing .fullscreen-cover-content { + transform: translateY(calc(100% + 3rem)); + opacity: 0; } /* UI Toggle Button for Visualizer Mode - Rightmost position */ @@ -4071,6 +4115,38 @@ input:checked + .slider::before { background: var(--primary); } +#toggle-fullscreen-lyrics-mobile-btn { + display: none; + position: absolute; + top: calc(0.85rem + env(safe-area-inset-top)); + right: calc(0.9rem + env(safe-area-inset-right)); + width: 38px; + height: 38px; + border: none; + border-radius: 999px; + padding: 0; + align-items: center; + justify-content: center; + background: rgb(9 12 18 / 0.32); + color: rgb(255 255 255 / 0.76); + backdrop-filter: blur(10px); + z-index: 14; + transition: + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease; +} + +#toggle-fullscreen-lyrics-mobile-btn.active { + background: rgb(255 255 255 / 0.12); + color: rgb(255 255 255 / 0.96); +} + +#toggle-fullscreen-lyrics-mobile-btn:hover { + transform: scale(1.04); +} + /* Close Button - Leftmost position */ #close-fullscreen-cover-btn { position: absolute; @@ -4187,7 +4263,6 @@ input:checked + .slider::before { #fullscreen-cover-overlay.controls-idle { cursor: none; } - #fullscreen-cover-image { max-width: 55vw; max-height: 45vh; @@ -7817,148 +7892,6 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { transform: scale(0.97); } -/* ======================================== - 16-Band Graphic Equalizer (Legacy EQ) - ======================================== */ -.graphic-eq-section { - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.graphic-eq-preset-row { - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.graphic-eq-preset-label { - font-size: 0.8rem; - font-weight: 600; - color: var(--foreground); - white-space: nowrap; -} - -.graphic-eq-preset-select { - flex: 1; - padding: 8px 12px; - background: var(--input); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--foreground); - font-size: 0.85rem; -} - -.graphic-eq-bands { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 2px; - padding: var(--spacing-md) var(--spacing-sm); - background: rgb(0, 0, 0, 0.15); - border-radius: var(--radius); - min-height: 240px; -} - -.graphic-eq-band { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; -} - -.graphic-eq-band-value { - font-size: 0.65rem; - color: var(--foreground); - font-variant-numeric: tabular-nums; - white-space: nowrap; - min-height: 14px; - opacity: 0.7; -} - -.graphic-eq-band-slider-wrap { - position: relative; - height: 160px; - width: 28px; - display: flex; - align-items: center; - justify-content: center; -} - -.graphic-eq-band-slider-wrap input[type='range'] { - writing-mode: vertical-lr; - direction: rtl; - width: 28px; - height: 100%; - accent-color: var(--foreground); - cursor: pointer; - margin: 0; - padding: 0; -} - -.graphic-eq-band-label { - font-size: 0.6rem; - color: var(--muted-foreground); - white-space: nowrap; - text-align: center; - letter-spacing: -0.02em; -} - -.graphic-eq-bottom-row { - display: flex; - align-items: center; - gap: var(--spacing-md); -} - -.graphic-eq-preamp { - display: flex; - align-items: center; - gap: var(--spacing-sm); - flex: 1; -} - -.graphic-eq-preamp-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--muted-foreground); - white-space: nowrap; -} - -.graphic-eq-preamp-slider { - flex: 1; - height: 4px; - accent-color: var(--highlight); -} - -.graphic-eq-preamp-value { - font-size: 0.75rem; - color: var(--muted-foreground); - min-width: 45px; - text-align: right; - font-variant-numeric: tabular-nums; -} - -@media (max-width: 600px) { - .graphic-eq-bands { - min-height: 180px; - } - - .graphic-eq-band-slider-wrap { - height: 130px; - width: 22px; - } - - .graphic-eq-band-label { - font-size: 0.5rem; - } - - .graphic-eq-band-value { - font-size: 0.5rem; - } -} - /* ======================================== Precision AutoEQ - Redesigned Equalizer ======================================== */ @@ -8579,19 +8512,6 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { align-items: center; justify-content: space-between; padding: var(--spacing-md); - cursor: pointer; - user-select: none; - transition: background var(--transition-fast); -} - -.autoeq-database-header:hover { - background: rgb(var(--highlight-rgb), 0.08); -} - -.autoeq-database-header-right { - display: flex; - align-items: center; - gap: var(--spacing-sm); } .autoeq-database-title { @@ -9311,69 +9231,6 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { flex: 1; width: auto; } - - /* Mobile parametric EQ band layout */ - .autoeq-band-control { - padding: 0.5rem; - } - - .autoeq-band-header { - flex-wrap: wrap; - gap: 0.3rem; - } - - .autoeq-band-number { - min-width: 1.2rem; - } - - .autoeq-band-param { - min-width: 0; - flex: 0 0 auto; - } - - .autoeq-band-sliders { - flex-direction: column; - gap: 0.5rem; - } - - .autoeq-band-slider { - width: 100%; - height: 6px; - } - - .autoeq-band-slider::-webkit-slider-thumb { - width: 18px; - height: 18px; - } - - .autoeq-band-slider::-moz-range-thumb { - width: 18px; - height: 18px; - } - - .autoeq-filters-actions { - flex-wrap: wrap; - gap: 0.3rem; - } - - .autoeq-filters-actions button { - font-size: 0.7rem; - padding: 0.3rem 0.5rem; - } -} - -@media (max-width: 600px) { - /* Rearrange band header into 2 rows on small screens */ - .autoeq-band-header { - display: grid; - grid-template-columns: auto auto 1fr 1fr 1fr; - gap: 0.25rem 0.4rem; - align-items: center; - } - - .autoeq-band-param { - justify-content: flex-start; - } } /* Track List Search */ @@ -10229,35 +10086,12 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } .about-contributors div { - flex: 1 1 calc(25% - 10px); - min-width: 150px; - max-width: calc(50% - 10px); + width: calc(20% - 8px); border: 1px solid var(--border); border-radius: 14px; - padding: 20px; + padding: 30px; text-align: center; overflow: hidden; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -@media (max-width: 768px) { - .about-contributors div { - flex: 1 1 calc(50% - 10px); - max-width: 100%; - padding: 15px; - } -} - -.about-contributors a { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - text-decoration: none; - color: inherit; } .about-contributors img { @@ -10281,24 +10115,33 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { display: flex; align-items: stretch; justify-content: center; + max-width: 100%; + min-width: 0; + box-sizing: border-box; min-height: 0; overflow: hidden; } #fullscreen-cover-overlay .fullscreen-main-view { - width: min(1240px, 100%); + --fs-media-column-size: minmax(340px, 430px); + --fs-lyrics-column-size: minmax(520px, 760px); + width: min(1480px, 100%); height: 100%; flex: 1; display: grid; - grid-template-columns: minmax(360px, 430px) minmax(420px, 1fr); - gap: clamp(1.5rem, 3vw, 3rem); + grid-template-columns: var(--fs-media-column-size) var(--fs-lyrics-column-size); + gap: clamp(2rem, 4vw, 4.5rem); align-items: center; justify-content: center; - padding: clamp(4rem, 7vh, 5rem) clamp(2rem, 4vw, 3rem) clamp(3rem, 6vh, 4rem) clamp(4rem, 7vw, 6.25rem); + padding: clamp(4rem, 7vh, 5rem) clamp(3rem, 6vw, 5rem) clamp(3rem, 6vh, 4rem); position: relative; z-index: 1; min-height: 0; overflow: hidden; + transition: + grid-template-columns 0.34s cubic-bezier(0.22, 1, 0.36, 1), + width 0.34s cubic-bezier(0.22, 1, 0.36, 1), + gap 0.34s cubic-bezier(0.22, 1, 0.36, 1); } #fullscreen-cover-overlay .fullscreen-media-column, @@ -10312,7 +10155,11 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { flex-direction: column; gap: 0.95rem; justify-self: center; - transform: translateX(clamp(0.75rem, 1.2vw, 1.4rem)); + transform: none; + transition: + width 0.34s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.34s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.24s ease; } #fullscreen-cover-overlay .fullscreen-artwork-card { @@ -10320,7 +10167,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { aspect-ratio: 1 / 1; border-radius: 18px; overflow: hidden; - box-shadow: 0 28px 80px rgb(0, 0, 0, 0.26); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.26); } #fullscreen-cover-overlay #fullscreen-cover-image { @@ -10365,7 +10212,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay #toggle-fullscreen-lyrics-btn, #fullscreen-cover-overlay .fullscreen-lyrics-toggle { - display: none !important; + display: flex; } #fullscreen-cover-overlay .fullscreen-actions { @@ -10443,9 +10290,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn { - order: 2; + order: 3; } +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn, #fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn, #fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { position: static; @@ -10457,6 +10305,15 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 1; } +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn { + order: 2; +} + +#fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn.active { + color: rgb(255 255 255 / 0.96); + background: rgb(255 255 255 / 0.12); +} + #fullscreen-cover-overlay .fullscreen-top-actions #close-fullscreen-cover-btn { order: 1; } @@ -10477,7 +10334,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay #toggle-ui-btn { top: 1.25rem; - left: calc(80px + 2.3rem + env(safe-area-inset-left)); + left: calc(9.9rem + env(safe-area-inset-left)); right: auto; width: 40px; height: 40px; @@ -10522,7 +10379,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-buttons button.active { - color: rgb(var(--highlight-rgb) / 0.98); + color: rgb(var(--fs-accent-rgb) / 0.98); } #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { @@ -10563,7 +10420,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fs-visualizer-btn.active { - color: rgb(var(--highlight-rgb) / 0.96); + color: rgb(var(--fs-accent-rgb) / 0.96); } #fullscreen-cover-overlay .fs-volume-btn { @@ -10609,7 +10466,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill, #fullscreen-cover-overlay .fs-volume-bar:hover .fs-volume-fill { - background: rgb(var(--highlight-rgb) / 0.94); + background: rgb(var(--fs-accent-rgb) / 0.94); } #fullscreen-cover-overlay .fullscreen-progress-container .progress-bar:hover .progress-fill::after, @@ -10626,6 +10483,13 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { align-items: stretch; justify-content: flex-start; overflow: hidden; + min-width: 0; + opacity: 1; + transform: translateX(0); + transition: + opacity 0.24s ease, + transform 0.34s cubic-bezier(0.22, 1, 0.36, 1), + visibility 0s linear 0s; } #fullscreen-cover-overlay .fullscreen-lyrics-shell, @@ -10641,14 +10505,14 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-lyrics-shell { width: min(860px, 100%); min-height: 0; - margin-left: clamp(4rem, 8vw, 8rem); + margin-left: 0; } #fullscreen-cover-overlay .fullscreen-lyrics-content { min-height: 0; height: 100%; position: relative; - padding-left: clamp(2.5rem, 4vw, 4rem); + padding-left: clamp(0.5rem, 1.6vw, 1.5rem); mask-image: none; overflow: visible; scrollbar-width: none; @@ -10664,16 +10528,16 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { --lyrics-scroll-padding-top: 18%; --lyplus-blur-amount: 0.16em; --lyplus-blur-amount-near: 0.085em; - height: 100%; width: 100%; - font-family: 'SF Pro Display', Inter, sans-serif; - + font-family: + 'SF Pro Display', + Inter, + sans-serif; --lyplus-font-size-base: clamp(34px, 3vw, 52px); --lyplus-padding-line: 8px; - --lyplus-text-color: rgb(246, 244, 239, 0.08); + --lyplus-text-color: rgba(246, 244, 239, 0.08); --lyplus-active-color: #f6f4ef; - line-height: 1.32; letter-spacing: -0.04em; font-weight: 600; @@ -10696,6 +10560,34 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 0.55; } +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { + --fs-media-column-size: minmax(420px, 760px); + --fs-lyrics-column-size: minmax(0, 0fr); + width: min(760px, 100%); + gap: 0; +} + +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-media-column { + justify-self: center; + width: min(520px, 100%); + transform: translateX(clamp(2rem, 4vw, 3.5rem)); +} + +#fullscreen-cover-overlay.lyrics-hidden .fullscreen-lyrics-pane { + opacity: 0; + transform: translateX(2rem); + visibility: hidden; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + #fullscreen-cover-overlay .fullscreen-main-view, + #fullscreen-cover-overlay .fullscreen-media-column, + #fullscreen-cover-overlay .fullscreen-lyrics-pane { + transition: none !important; + } +} + #fullscreen-cover-overlay.queue-panel-active .fullscreen-main-view { grid-template-columns: 1fr; width: min(760px, 100%); @@ -10712,101 +10604,339 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { @media (max-width: 980px) { #fullscreen-cover-overlay .fullscreen-main-view { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; width: min(760px, 100%); - gap: 1rem; - align-items: start; - padding: calc(4.5rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.5rem) - calc(1.5rem + env(safe-area-inset-bottom)); + gap: 1.25rem; + align-items: stretch; + padding: + calc(5rem + env(safe-area-inset-top)) + clamp(1rem, 4vw, 1.75rem) + calc(1.5rem + env(safe-area-inset-bottom)) + clamp(1rem, 4vw, 1.75rem); } #fullscreen-cover-overlay .fullscreen-media-column { justify-self: center; transform: none; + width: min(100%, 620px); } #fullscreen-cover-overlay .fullscreen-lyrics-pane { - display: none; + display: flex; + width: min(100%, 620px); + justify-self: center; + min-height: min(48vh, 440px); + } + + #fullscreen-cover-overlay .fullscreen-lyrics-shell { + width: 100%; + margin-left: 0; + } + + #fullscreen-cover-overlay .fullscreen-lyrics-content { + padding-left: 0; } } @media (max-width: 768px) { #fullscreen-cover-overlay { - --fs-mobile-top-btn-size: 44px; + --fs-mobile-top-btn-size: 42px; --fs-mobile-top-btn-gap: 0.6rem; --fs-mobile-top-btn-left: calc(1rem + env(safe-area-inset-left)); } #fullscreen-cover-overlay .fullscreen-cover-content { - padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom)); + padding: 0 calc(0.9rem + env(safe-area-inset-right)) calc(0.9rem + env(safe-area-inset-bottom)) + calc(0.9rem + env(safe-area-inset-left)); + } + + #fullscreen-dismiss-handle { + display: block; } #fullscreen-cover-overlay .fullscreen-top-actions { - top: calc(0.75rem + env(safe-area-inset-top)); - left: var(--fs-mobile-top-btn-left); - gap: var(--fs-mobile-top-btn-gap); + display: none; } - #fullscreen-cover-overlay .fullscreen-top-actions button, + #fullscreen-cover-overlay .fullscreen-lyrics-toggle, #fullscreen-cover-overlay #toggle-ui-btn { - width: var(--fs-mobile-top-btn-size); - height: var(--fs-mobile-top-btn-size); - background: rgb(9 12 18 / 0.5); + display: none !important; } - #fullscreen-cover-overlay #toggle-ui-btn { - top: calc(0.75rem + env(safe-area-inset-top)); - left: calc( - var(--fs-mobile-top-btn-left) + (var(--fs-mobile-top-btn-size) * 2) + (var(--fs-mobile-top-btn-gap) * 2) - ); + #toggle-fullscreen-lyrics-mobile-btn { + display: flex; } #fullscreen-cover-overlay .fullscreen-main-view { width: 100%; - gap: 0.85rem; - padding: calc(7.25rem + env(safe-area-inset-top)) 0.75rem calc(1.5rem + env(safe-area-inset-bottom)); + height: 100%; + max-width: 100%; + min-width: 0; + min-height: 0; + box-sizing: border-box; + grid-template-columns: minmax(78px, 92px) minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: + 'art info' + 'lyrics lyrics' + 'controls controls'; + gap: 1rem 0.9rem; + padding: + calc(4.45rem + env(safe-area-inset-top)) + 0 + calc(0.8rem + env(safe-area-inset-bottom)) + 0; } - #fullscreen-cover-overlay .fullscreen-track-info, - #fullscreen-cover-overlay .fullscreen-controls, #fullscreen-cover-overlay .fullscreen-media-column { - width: min(100%, 460px); + display: contents; + width: auto; + min-width: 0; + } + + #fullscreen-cover-overlay .fullscreen-artwork-card { + grid-area: art; + width: 100%; + max-width: 92px; + box-sizing: border-box; + border-radius: 12px; + align-self: start; + margin-left: 0.95rem; + box-shadow: 0 20px 48px rgb(0 0 0 / 0.34); + } + + #fullscreen-cover-overlay #fullscreen-cover-image { + border-radius: 12px; + } + + #fullscreen-cover-overlay .fullscreen-track-info { + grid-area: info; + width: 100%; + min-width: 0; + align-self: center; + display: grid; + gap: 0.3rem; + padding-top: 0.2rem; + padding-left: 0.95rem; + } + + #fullscreen-cover-overlay .fullscreen-track-text { + display: grid; + gap: 0.14rem; + } + + #fullscreen-cover-overlay #fullscreen-track-title { + font-size: clamp(1.1rem, 4.7vw, 1.34rem); + line-height: 1.04; + } + + #fullscreen-cover-overlay #fullscreen-track-artist { + margin-top: 0; + font-size: 0.92rem; + color: rgb(255 255 255 / 0.7); } #fullscreen-cover-overlay .fullscreen-actions { + display: none !important; + } + + #fullscreen-cover-overlay #fullscreen-next-track { + display: none !important; + } + + #fullscreen-cover-overlay .fullscreen-lyrics-pane { + grid-area: lyrics; width: 100%; - flex-wrap: wrap; - gap: 0.45rem; + max-width: 100%; + min-height: 0; + min-width: 0; + box-sizing: border-box; + justify-self: stretch; } - #fullscreen-cover-overlay .fullscreen-actions .btn-icon { - background: rgb(255 255 255 / 0.06); + #fullscreen-cover-overlay .fullscreen-lyrics-shell { + min-height: 0; + position: relative; + background: transparent !important; + box-shadow: none !important; + border-radius: 0; + overflow: visible; } - #fullscreen-cover-overlay .fullscreen-progress-container { - gap: 0.65rem; + #fullscreen-cover-overlay .fullscreen-lyrics-content { + height: 100%; + padding: 0 0 0.2rem; + overflow: hidden; + mask-image: linear-gradient(180deg, transparent 0%, black 10%, black 88%, transparent 100%); } - #fullscreen-cover-overlay .fullscreen-buttons { - gap: 0.2rem; + #fullscreen-cover-overlay .fullscreen-lyrics-content am-lyrics { + --lyrics-scroll-padding-top: 18%; + --lyplus-font-size-base: clamp(1.75rem, 7vw, 2.35rem); + --lyplus-padding-line: 6px; + --lyplus-text-color: rgba(246, 244, 239, 0.16); + --lyplus-blur-amount: 0.16em; + --lyplus-blur-amount-near: 0.08em; + line-height: 1.2; } - #fullscreen-cover-overlay .fullscreen-buttons button { - width: 38px; - height: 38px; + #fullscreen-cover-overlay .fullscreen-lyrics-empty, + #fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-loading, + #fullscreen-cover-overlay .fullscreen-lyrics-content .lyrics-error { + padding: 2.5rem 1.2rem 0; } - #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { - width: 52px; - height: 52px; + #fullscreen-cover-overlay .fullscreen-controls { + grid-area: controls; + width: 100%; + max-width: none; + min-width: 0; + box-sizing: border-box; + margin-top: 0; + padding: 0.15rem 0 0; + gap: 0.9rem; + } + + #fullscreen-cover-overlay .fullscreen-mobile-quality { + display: none; } #fullscreen-cover-overlay .fullscreen-volume-container { - width: min(220px, calc(100% - 2.75rem)); + display: none; + } + + #fullscreen-cover-overlay .fullscreen-progress-container { + gap: 0.55rem; + font-size: 0.72rem; + } + + #fullscreen-cover-overlay .fullscreen-buttons { + gap: 0.1rem; + justify-content: space-between; + } + + #fullscreen-cover-overlay .fullscreen-buttons button { + width: 42px; + height: 42px; + } + + #fullscreen-cover-overlay .fullscreen-buttons #fs-play-pause-btn { + width: 62px; + height: 62px; + box-shadow: 0 14px 28px rgb(0 0 0 / 0.3); + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr) auto; + align-items: center; + justify-items: center; + gap: 0; + padding: + calc(4.45rem + env(safe-area-inset-top)) + clamp(1rem, 4vw, 1.4rem) + calc(0.8rem + env(safe-area-inset-bottom)) + clamp(1rem, 4vw, 1.4rem); + height: 100%; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-lyrics-pane { + display: none; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-media-column { + display: flex; + flex-direction: column; + grid-row: 2; + justify-content: center; + align-items: center; + align-self: center; + width: min(100%, 320px); + max-width: min(88vw, 320px); + min-height: 0; + min-width: 0; + margin: 0 auto; + transform: none; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-artwork-card { + max-width: min(88vw, 320px); + width: min(100%, 320px); + margin: 0 auto; + align-self: center; + justify-self: center; + border-radius: 14px; + box-shadow: 0 24px 56px rgb(0 0 0 / 0.28); + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-cover-image { + border-radius: 14px; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-track-info { + align-self: center; + width: min(100%, 320px); + padding: 0; + gap: 0.3rem; + margin-top: 0.9rem; + text-align: center; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-track-text { + gap: 0.2rem; + justify-items: center; + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-title { + font-size: clamp(1.15rem, 5vw, 1.5rem); + line-height: 1.05; + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-artist { + font-size: 0.95rem; + color: rgb(255 255 255 / 0.66); + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-controls { + grid-row: 4; + width: min(100%, 320px); + max-width: min(88vw, 320px); + justify-self: center; + align-self: end; + margin-top: 0; + } + + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-title .quality-badge, + #fullscreen-cover-overlay.lyrics-hidden #fullscreen-track-title .shaka-quality-badge { + display: none !important; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-mobile-quality { + display: flex; + justify-content: center; + align-items: center; + min-height: 1.25rem; + margin: 0 auto -0.1rem; + } + + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-mobile-quality .quality-badge, + #fullscreen-cover-overlay.lyrics-hidden .fullscreen-mobile-quality .shaka-quality-badge { + display: inline-flex !important; + } + + #fullscreen-cover-overlay .fullscreen-volume-container { + width: min(280px, calc(100% - 3rem)); + margin-top: 0; } #fullscreen-cover-overlay .fs-volume-btn { - left: -2.25rem; + left: -2.5rem; } #fullscreen-cover-overlay .fs-volume-bar { From d031c44a4060484b4fa3c47f15b3d1b292a77b41 Mon Sep 17 00:00:00 2001 From: ap5z <252674497+ap5z@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:47:25 +0000 Subject: [PATCH 20/76] style: auto-fix linting issues --- index.html | 19 ++++++++++++++++--- js/ui.js | 15 +++++++++++---- styles.css | 44 +++++++++++++++++++------------------------- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/index.html b/index.html index 605a632..6d91859 100644 --- a/index.html +++ b/index.html @@ -206,7 +206,11 @@ " >
-
- +
0:00
@@ -285,7 +293,12 @@ - diff --git a/js/ui.js b/js/ui.js index da78079..b5ac06c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1336,7 +1336,9 @@ export class UIRenderer { nextTrackEl.classList.remove('animate-in'); } - const canRenderLyrics = Boolean(lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'); + const canRenderLyrics = Boolean( + lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video' + ); if (canRenderLyrics) { this.fullscreenLyricsVisible = true; if (lyricsToggleBtn) lyricsToggleBtn.style.removeProperty('display'); @@ -1349,7 +1351,8 @@ export class UIRenderer { overlay.classList.add('lyrics-unavailable'); if (lyricsContent) { clearFullscreenLyricsSync(lyricsContent); - lyricsContent.innerHTML = '
Lyrics are not available for this track.
'; + lyricsContent.innerHTML = + '
Lyrics are not available for this track.
'; } } this.updateFullscreenLyricsVisibility(overlay); @@ -1421,7 +1424,8 @@ export class UIRenderer { if (!title) return; const qualityBadge = this.getFullscreenQualityBadgeHTML(track); - const useMobileBadgeOnly = window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden'); + const useMobileBadgeOnly = + window.matchMedia('(max-width: 768px)').matches && overlay.classList.contains('lyrics-hidden'); title.innerHTML = useMobileBadgeOnly ? escapeHtml(track.title) : `${escapeHtml(track.title)} ${qualityBadge}`; if (mobileQuality) { @@ -1488,7 +1492,10 @@ export class UIRenderer { if (playerBar) playerBar.style.removeProperty('display'); const mainContent = document.querySelector('.main-content'); if (mainContent instanceof HTMLElement) { - if (typeof this.fullscreenMainContentOverflow === 'string' && this.fullscreenMainContentOverflow.length > 0) { + if ( + typeof this.fullscreenMainContentOverflow === 'string' && + this.fullscreenMainContentOverflow.length > 0 + ) { mainContent.style.overflowY = this.fullscreenMainContentOverflow; } else { mainContent.style.removeProperty('overflow-y'); diff --git a/styles.css b/styles.css index 04e9b75..5f78852 100644 --- a/styles.css +++ b/styles.css @@ -3924,6 +3924,7 @@ input:checked + .slider::before { /* Reserve space above taskbar / system UI so volume controls stay visible (fixes #322) */ padding-bottom: max(env(safe-area-inset-bottom), 1.5rem); + --fullscreen-drag-progress: 0; --fs-accent-rgb: var(--highlight-rgb); } @@ -3936,8 +3937,7 @@ input:checked + .slider::before { radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.035), transparent 58%), linear-gradient(180deg, rgb(6 8 12 / 0.12), rgb(6 8 12 / 0.34)); z-index: -1; - transition: - opacity 0.65s ease; + transition: opacity 0.65s ease; opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.32)); } @@ -3993,7 +3993,7 @@ input:checked + .slider::before { position: relative; padding: 1rem; overflow: hidden; - transform: translateY(var(--fullscreen-drag-offset, 0px)); + transform: translateY(var(--fullscreen-drag-offset, 0)); opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.16)); transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), @@ -4263,6 +4263,7 @@ input:checked + .slider::before { #fullscreen-cover-overlay.controls-idle { cursor: none; } + #fullscreen-cover-image { max-width: 55vw; max-height: 45vh; @@ -10125,6 +10126,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-main-view { --fs-media-column-size: minmax(340px, 430px); --fs-lyrics-column-size: minmax(520px, 760px); + width: min(1480px, 100%); height: 100%; flex: 1; @@ -10167,7 +10169,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { aspect-ratio: 1 / 1; border-radius: 18px; overflow: hidden; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.26); + box-shadow: 0 28px 80px rgb(0, 0, 0, 0.26); } #fullscreen-cover-overlay #fullscreen-cover-image { @@ -10528,16 +10530,16 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { --lyrics-scroll-padding-top: 18%; --lyplus-blur-amount: 0.16em; --lyplus-blur-amount-near: 0.085em; + height: 100%; width: 100%; - font-family: - 'SF Pro Display', - Inter, - sans-serif; + font-family: 'SF Pro Display', Inter, sans-serif; + --lyplus-font-size-base: clamp(34px, 3vw, 52px); --lyplus-padding-line: 8px; - --lyplus-text-color: rgba(246, 244, 239, 0.08); + --lyplus-text-color: rgb(246, 244, 239, 0.08); --lyplus-active-color: #f6f4ef; + line-height: 1.32; letter-spacing: -0.04em; font-weight: 600; @@ -10563,6 +10565,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { --fs-media-column-size: minmax(420px, 760px); --fs-lyrics-column-size: minmax(0, 0fr); + width: min(760px, 100%); gap: 0; } @@ -10609,11 +10612,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { width: min(760px, 100%); gap: 1.25rem; align-items: stretch; - padding: - calc(5rem + env(safe-area-inset-top)) - clamp(1rem, 4vw, 1.75rem) - calc(1.5rem + env(safe-area-inset-bottom)) - clamp(1rem, 4vw, 1.75rem); + padding: calc(5rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.75rem) + calc(1.5rem + env(safe-area-inset-bottom)); } #fullscreen-cover-overlay .fullscreen-media-column { @@ -10682,11 +10682,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { 'lyrics lyrics' 'controls controls'; gap: 1rem 0.9rem; - padding: - calc(4.45rem + env(safe-area-inset-top)) - 0 - calc(0.8rem + env(safe-area-inset-bottom)) - 0; + padding: calc(4.45rem + env(safe-area-inset-top)) 0 calc(0.8rem + env(safe-area-inset-bottom)); } #fullscreen-cover-overlay .fullscreen-media-column { @@ -10775,9 +10771,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { --lyrics-scroll-padding-top: 18%; --lyplus-font-size-base: clamp(1.75rem, 7vw, 2.35rem); --lyplus-padding-line: 6px; - --lyplus-text-color: rgba(246, 244, 239, 0.16); + --lyplus-text-color: rgb(246, 244, 239, 0.16); --lyplus-blur-amount: 0.16em; --lyplus-blur-amount-near: 0.08em; + line-height: 1.2; } @@ -10834,11 +10831,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { align-items: center; justify-items: center; gap: 0; - padding: - calc(4.45rem + env(safe-area-inset-top)) - clamp(1rem, 4vw, 1.4rem) - calc(0.8rem + env(safe-area-inset-bottom)) - clamp(1rem, 4vw, 1.4rem); + padding: calc(4.45rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.4rem) + calc(0.8rem + env(safe-area-inset-bottom)); height: 100%; width: 100%; max-width: 100%; From e59e14fdb67d4e07d3f0c22903865ea0677e1129 Mon Sep 17 00:00:00 2001 From: Samidy Date: Mon, 6 Apr 2026 14:49:53 +0300 Subject: [PATCH 21/76] Gracefully handle no Critic Reviews yet --- js/ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/ui.js b/js/ui.js index b5ac06c..4c38690 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3854,6 +3854,10 @@ export class UIRenderer { const data = await response.json(); rateCriticsEl.innerHTML = `Critic Score: ${data.critic.score}, Based on ${data.critic.count} reviews`; + + if (data.critic.score == "NR") { + rateCriticsEl.innerHTML = `Critic Score Not Available Yet`; + } rateUsersEl.innerHTML = `User Score: ${data.user.score}, Based on ${data.user.count} reviews`; } catch (e) { rateCriticsEl.innerHTML = `Unable to Fetch Critic Score`; From b3145c5c3ec29a9774a1735c41e76507689615a9 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:50:34 +0000 Subject: [PATCH 22/76] style: auto-fix linting issues --- js/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/ui.js b/js/ui.js index 4c38690..bd9db3f 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3855,7 +3855,7 @@ export class UIRenderer { rateCriticsEl.innerHTML = `Critic Score: ${data.critic.score}, Based on ${data.critic.count} reviews`; - if (data.critic.score == "NR") { + if (data.critic.score == 'NR') { rateCriticsEl.innerHTML = `Critic Score Not Available Yet`; } rateUsersEl.innerHTML = `User Score: ${data.user.score}, Based on ${data.user.count} reviews`; From d7642ff78e82ea22904b5ce07db49382db311d40 Mon Sep 17 00:00:00 2001 From: Samidy Date: Mon, 6 Apr 2026 15:55:30 +0300 Subject: [PATCH 23/76] test: Add first row of tests for the site --- js/db.js | 39 +++++--- js/player.js | 3 +- js/storage.js | 3 +- js/tests/db.test.js | 107 ++++++++++++++++++++ js/tests/player.test.js | 195 ++++++++++++++++++++++++++++++++++++ js/tests/storage.test.js | 125 +++++++++++++++++++++++ js/tests/utils.test.js | 211 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 665 insertions(+), 18 deletions(-) create mode 100644 js/tests/db.test.js create mode 100644 js/tests/player.test.js create mode 100644 js/tests/storage.test.js create mode 100644 js/tests/utils.test.js diff --git a/js/db.js b/js/db.js index 95c306b..acd548e 100644 --- a/js/db.js +++ b/js/db.js @@ -102,8 +102,6 @@ export class MusicDatabase { async addToHistory(track) { const storeName = 'history_tracks'; const minified = this._minifyItem(track.type || 'track', track); - const timestamp = Date.now(); - const entry = { ...minified, timestamp }; const db = await this.open(); @@ -112,25 +110,34 @@ export class MusicDatabase { const store = transaction.objectStore(storeName); const index = store.index('timestamp'); - const cursorReq = index.openCursor(null, 'prev'); + const lastReq = index.openCursor(null, 'prev'); + let lastTimestamp = 0; - cursorReq.onsuccess = (e) => { + lastReq.onsuccess = (e) => { const cursor = e.target.result; - if (cursor) { - const lastTrack = cursor.value; - if (lastTrack.id === track.id) { - store.delete(cursor.primaryKey); - } + if (cursor && lastTimestamp === 0) { + lastTimestamp = cursor.value.timestamp; } - store.put(entry); + + const timestamp = Math.max(Date.now(), lastTimestamp + 1); + const entry = { ...minified, timestamp }; + + const dedupeReq = index.openCursor(null, 'prev'); + dedupeReq.onsuccess = (e2) => { + const dedupeCursor = e2.target.result; + if (dedupeCursor) { + const trackInHistory = dedupeCursor.value; + if (trackInHistory.id === track.id) { + store.delete(dedupeCursor.primaryKey); + } + dedupeCursor.continue(); + } else { + store.put(entry); + resolve(entry); + } + }; }; - cursorReq.onerror = (_e) => { - // If cursor fails, just try to put (fallback) - store.put(entry); - }; - - transaction.oncomplete = () => resolve(entry); transaction.onerror = (e) => reject(e.target.error); }); } diff --git a/js/player.js b/js/player.js index 81f57fe..508e9d5 100644 --- a/js/player.js +++ b/js/player.js @@ -291,7 +291,8 @@ export class Player { } setPlaybackSpeed(speed) { - const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); + const parsed = parseFloat(speed); + const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed)); audioEffectsSettings.setSpeed(validSpeed); this.applyAudioEffects(); } diff --git a/js/storage.js b/js/storage.js index 222fa10..f702eac 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1830,7 +1830,8 @@ export const audioEffectsSettings = { }, setSpeed(speed) { - const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); + const parsed = parseFloat(speed); + const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsed) ? 1.0 : parsed)); localStorage.setItem(this.SPEED_KEY, validSpeed.toString()); }, diff --git a/js/tests/db.test.js b/js/tests/db.test.js new file mode 100644 index 0000000..8f24d57 --- /dev/null +++ b/js/tests/db.test.js @@ -0,0 +1,107 @@ +import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest'; +import { MusicDatabase } from '../db.js'; + +describe('MusicDatabase', () => { + let db; + const TEST_DB_NAME = 'TestMonochromeDB'; + + beforeEach(async () => { + db = new MusicDatabase(); + db.dbName = TEST_DB_NAME; + const req = indexedDB.deleteDatabase(TEST_DB_NAME); + await new Promise((resolve) => { + req.onsuccess = resolve; + req.onerror = resolve; + }); + }); + + afterEach(async () => { + if (db.db) { + db.db.close(); + } + const req = indexedDB.deleteDatabase(TEST_DB_NAME); + await new Promise((resolve) => { + req.onsuccess = resolve; + req.onerror = resolve; + }); + }); + + test('opens database and creates stores', async () => { + const openedDb = await db.open(); + expect(openedDb.name).toBe(TEST_DB_NAME); + expect(openedDb.objectStoreNames.contains('favorites_tracks')).toBe(true); + expect(openedDb.objectStoreNames.contains('history_tracks')).toBe(true); + expect(openedDb.objectStoreNames.contains('user_playlists')).toBe(true); + }); + + test('toggleFavorite adds and removes items', async () => { + const track = { id: 'track1', title: 'Test Track', artist: { name: 'Artist' } }; + + const added = await db.toggleFavorite('track', track); + expect(added).toBe(true); + const favorites = await db.getFavorites('track'); + expect(favorites.length).toBe(1); + expect(favorites[0].id).toBe('track1'); + + const removed = await db.toggleFavorite('track', track); + expect(removed).toBe(false); + const favoritesAfter = await db.getFavorites('track'); + expect(favoritesAfter.length).toBe(0); + }); + + test('addToHistory manages recent tracks and avoids duplicates', async () => { + const track1 = { id: 't1', title: 'Track 1' }; + const track2 = { id: 't2', title: 'Track 2' }; + + await db.addToHistory(track1); + await db.addToHistory(track2); + await db.addToHistory(track1); + + const history = await db.getHistory(); + expect(history.length).toBe(2); + expect(history[0].id).toBe('t1'); + expect(history[1].id).toBe('t2'); + }); + + test('playlist operations: create, add, remove, delete', async () => { + const track = { id: 'track1', title: 'Test Track' }; + + const playlist = await db.createPlaylist('My Playlist', [track]); + expect(playlist.name).toBe('My Playlist'); + expect(playlist.tracks.length).toBe(1); + + const track2 = { id: 'track2', title: 'Track 2' }; + await db.addTrackToPlaylist(playlist.id, track2); + + const updated = await db.getPlaylist(playlist.id); + expect(updated.tracks.length).toBe(2); + expect(updated.tracks[1].id).toBe('track2'); + + await db.removeTrackFromPlaylist(playlist.id, 'track1'); + const afterRemove = await db.getPlaylist(playlist.id); + expect(afterRemove.tracks.length).toBe(1); + expect(afterRemove.tracks[0].id).toBe('track2'); + + await db.deletePlaylist(playlist.id); + const deleted = await db.getPlaylist(playlist.id); + expect(deleted).toBeUndefined(); + }); + + test('pinned items management', async () => { + const album = { id: 'album1', title: 'Album 1', type: 'album' }; + + await db.togglePinned(album, 'album'); + let pinned = await db.getPinned(); + expect(pinned.length).toBe(1); + expect(pinned[0].id).toBe('album1'); + + await db.togglePinned({ id: 'a2', title: 'A2' }, 'album'); + await db.togglePinned({ id: 'a3', title: 'A3' }, 'album'); + await db.togglePinned({ id: 'a4', title: 'A4' }, 'album'); + + pinned = await db.getPinned(); + expect(pinned.length).toBe(3); + expect(pinned.some((p) => p.id === 'a4')).toBe(true); + expect(pinned.some((p) => p.id === 'album1')).toBe(false); + }); +}); diff --git a/js/tests/player.test.js b/js/tests/player.test.js new file mode 100644 index 0000000..07821a9 --- /dev/null +++ b/js/tests/player.test.js @@ -0,0 +1,195 @@ +import { expect, test, describe, beforeEach, vi, afterEach } from 'vitest'; +import { Player } from '../player.js'; +import { REPEAT_MODE } from '../utils.js'; +import { audioEffectsSettings } from '../storage.js'; + +vi.mock('../audio-context.js', () => ({ + audioContextManager: { + init: vi.fn(), + resume: vi.fn(() => Promise.resolve()), + isReady: vi.fn(() => false), + setVolume: vi.fn(), + changeSource: vi.fn(), + }, +})); + +vi.mock('../storage.js', () => ({ + queueManager: { + getQueue: vi.fn(() => null), + saveQueue: vi.fn(), + }, + replayGainSettings: { getMode: vi.fn(() => 'off'), getPreamp: vi.fn(() => 0) }, + trackDateSettings: { useAlbumYear: vi.fn(() => true) }, + exponentialVolumeSettings: { applyCurve: vi.fn((v) => v) }, + audioEffectsSettings: { + getSpeed: vi.fn(() => 1.0), + setSpeed: vi.fn(), + isPreservePitchEnabled: vi.fn(() => true), + setPreservePitch: vi.fn(), + }, + radioSettings: { isEnabled: vi.fn(() => false) }, + contentBlockingSettings: { + shouldHideTrack: vi.fn(() => false), + shouldHideAlbum: vi.fn(() => false), + shouldHideArtist: vi.fn(() => false), + }, + qualityBadgeSettings: { isEnabled: vi.fn(() => true) }, + coverArtSizeSettings: { getSize: vi.fn(() => '1280') }, + apiSettings: { + loadInstancesFromGitHub: vi.fn(() => Promise.resolve([])), + getInstances: vi.fn(() => Promise.resolve([])), + }, + recentActivityManager: { addArtist: vi.fn(), addAlbum: vi.fn() }, + themeManager: { getTheme: vi.fn(() => 'dark'), setTheme: vi.fn() }, + lastFMStorage: { isEnabled: vi.fn(() => false) }, + nowPlayingSettings: { getMode: vi.fn(() => 'cover') }, + gaplessPlaybackSettings: { isEnabled: vi.fn(() => true) }, +})); + +vi.mock('../db.js', () => ({ + db: { + get: vi.fn(), + put: vi.fn(), + }, +})); + +vi.mock('../ui.js', () => ({ + UIRenderer: { + renderQueue: vi.fn(), + }, +})); + +vi.mock('shaka-player', () => ({ + default: { + polyfill: { installAll: vi.fn() }, + Player: { + isBrowserSupported: vi.fn(() => true), + prototype: { + configure: vi.fn(), + addEventListener: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + }, + }, + }, + polyfill: { installAll: vi.fn() }, + Player: class { + static isBrowserSupported() { + return true; + } + configure() {} + addEventListener() {} + load() { + return Promise.resolve(); + } + unload() { + return Promise.resolve(); + } + destroy() { + return Promise.resolve(); + } + }, +})); + +describe('Player', () => { + let audioElement; + let api; + let player; + + beforeEach(async () => { + document.body.innerHTML = ` + + +
+ +
+
+
+
+
+ `; + + audioElement = document.getElementById('audio-player'); + api = { + getCoverUrl: vi.fn((id) => `url-${id}`), + getCoverSrcset: vi.fn(), + getStreamUrl: vi.fn(), + }; + + Player._instance = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('initialization sets up initial state', async () => { + player = new Player(audioElement, api); + expect(player.audio).toBe(audioElement); + expect(player.api).toBe(api); + expect(player.queue).toEqual([]); + expect(player.shuffleActive).toBe(false); + }); + + test('setVolume updates userVolume and localStorage', () => { + player = new Player(audioElement, api); + player.setVolume(0.5); + expect(player.userVolume).toBe(0.5); + expect(localStorage.getItem('volume')).toBe('0.5'); + }); + + test('shuffle toggles correctly', () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + player.toggleShuffle(); + expect(player.shuffleActive).toBe(true); + expect(player.shuffledQueue.length).toBe(3); + + player.toggleShuffle(); + expect(player.shuffleActive).toBe(false); + }); + + test('repeat mode cycles correctly', () => { + player = new Player(audioElement, api); + expect(player.repeatMode).toBe(REPEAT_MODE.OFF); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.ALL); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.ONE); + + player.toggleRepeat(); + expect(player.repeatMode).toBe(REPEAT_MODE.OFF); + }); + + test('addToQueue adds tracks to the end', async () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }]; + + await player.addToQueue([{ id: 2 }, { id: 3 }]); + expect(player.queue.length).toBe(3); + expect(player.queue[2].id).toBe(3); + }); + + test('clearQueue resets queue state', async () => { + player = new Player(audioElement, api); + player.queue = [{ id: 1 }]; + player.currentQueueIndex = 0; + + await player.clearQueue(); + expect(player.queue).toEqual([]); + expect(player.currentQueueIndex).toBe(-1); + }); + + test('setPlaybackSpeed clamps values', () => { + player = new Player(audioElement, api); + + player.setPlaybackSpeed(2.0); + expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(2.0); + + player.setPlaybackSpeed(0); + expect(audioEffectsSettings.setSpeed).toHaveBeenCalledWith(0.01); + }); +}); diff --git a/js/tests/storage.test.js b/js/tests/storage.test.js new file mode 100644 index 0000000..3162623 --- /dev/null +++ b/js/tests/storage.test.js @@ -0,0 +1,125 @@ +import { expect, test, describe, beforeEach, vi } from 'vitest'; +import { + recentActivityManager, + themeManager, + lastFMStorage, + nowPlayingSettings, + gaplessPlaybackSettings, + exponentialVolumeSettings, + audioEffectsSettings, +} from '../storage.js'; + +describe('storage.js', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('recentActivityManager', () => { + test('initializes with empty arrays', () => { + const recents = recentActivityManager.getRecents(); + expect(recents.artists).toEqual([]); + expect(recents.albums).toEqual([]); + }); + + test('adds artist and maintains limit', () => { + for (let i = 0; i < 15; i++) { + recentActivityManager.addArtist({ id: i, name: `Artist ${i}` }); + } + const recents = recentActivityManager.getRecents(); + expect(recents.artists.length).toBe(10); + expect(recents.artists[0].id).toBe(14); + }); + + test('clears recents', () => { + recentActivityManager.addArtist({ id: 1, name: 'Artist' }); + recentActivityManager.clear(); + const recents = recentActivityManager.getRecents(); + expect(recents.artists).toEqual([]); + }); + }); + + describe('themeManager', () => { + test('gets and sets theme', () => { + themeManager.setTheme('dark'); + expect(themeManager.getTheme()).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + test('handles custom theme', () => { + const colors = { primary: '#ff0000', background: '#000000' }; + themeManager.setCustomTheme(colors); + expect(themeManager.getTheme()).toBe('custom'); + expect(themeManager.getCustomTheme()).toEqual(colors); + expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#ff0000'); + }); + }); + + describe('lastFMStorage', () => { + test('handles enabled state', () => { + lastFMStorage.setEnabled(true); + expect(lastFMStorage.isEnabled()).toBe(true); + lastFMStorage.setEnabled(false); + expect(lastFMStorage.isEnabled()).toBe(false); + }); + + test('obfuscates sensitive data', () => { + const key = 'test-api-key'; + lastFMStorage.setCustomApiKey(key); + expect(localStorage.getItem(lastFMStorage.CUSTOM_API_KEY)).not.toBe(key); + expect(lastFMStorage.getCustomApiKey()).toBe(key); + }); + }); + + describe('nowPlayingSettings', () => { + test('gets and sets mode', () => { + expect(nowPlayingSettings.getMode()).toBe('cover'); + nowPlayingSettings.setMode('visualizer'); + expect(nowPlayingSettings.getMode()).toBe('visualizer'); + }); + }); + + describe('gaplessPlaybackSettings', () => { + test('defaults to true', () => { + expect(gaplessPlaybackSettings.isEnabled()).toBe(true); + }); + + test('sets enabled state', () => { + gaplessPlaybackSettings.setEnabled(false); + expect(gaplessPlaybackSettings.isEnabled()).toBe(false); + }); + }); + + describe('exponentialVolumeSettings', () => { + test('applies curve when enabled', () => { + exponentialVolumeSettings.setEnabled(true); + expect(exponentialVolumeSettings.applyCurve(0.5)).toBeCloseTo(0.125); + expect(exponentialVolumeSettings.inverseCurve(0.125)).toBeCloseTo(0.5); + }); + + test('does not apply curve when disabled', () => { + exponentialVolumeSettings.setEnabled(false); + expect(exponentialVolumeSettings.applyCurve(0.5)).toBe(0.5); + expect(exponentialVolumeSettings.inverseCurve(0.5)).toBe(0.5); + }); + }); + + describe('audioEffectsSettings', () => { + test('gets and sets speed within bounds', () => { + audioEffectsSettings.setSpeed(2.0); + expect(audioEffectsSettings.getSpeed()).toBe(2.0); + + audioEffectsSettings.setSpeed(200); + expect(audioEffectsSettings.getSpeed()).toBe(100); + + audioEffectsSettings.setSpeed(0); + expect(audioEffectsSettings.getSpeed()).toBe(0.01); + }); + + test('resets speed', () => { + audioEffectsSettings.setSpeed(2.0); + audioEffectsSettings.resetSpeed(); + expect(audioEffectsSettings.getSpeed()).toBe(1.0); + }); + }); +}); diff --git a/js/tests/utils.test.js b/js/tests/utils.test.js new file mode 100644 index 0000000..edacfbc --- /dev/null +++ b/js/tests/utils.test.js @@ -0,0 +1,211 @@ +import { expect, test, describe, vi } from 'vitest'; +import * as utils from '../utils.js'; + +vi.mock('../ModernSettings.js', () => ({ + modernSettings: { + filenameTemplate: '{artist} - {album} - {trackNumber} - {title}', + }, +})); + +vi.mock('../icons.js', () => ({ + SVG_ATMOS: () => 'atmos', +})); + +vi.mock('../storage.js', () => ({ + qualityBadgeSettings: { isEnabled: vi.fn(() => true) }, + coverArtSizeSettings: { getSize: vi.fn(() => '1280') }, + trackDateSettings: { useAlbumYear: vi.fn(() => false) }, +})); + +describe('utils.js', () => { + describe('formatTime', () => { + test('formats seconds into M:SS', () => { + expect(utils.formatTime(0)).toBe('0:00'); + expect(utils.formatTime(5)).toBe('0:05'); + expect(utils.formatTime(60)).toBe('1:00'); + expect(utils.formatTime(65)).toBe('1:05'); + }); + + test('formats seconds into H:MM:SS', () => { + expect(utils.formatTime(3600)).toBe('1:00:00'); + expect(utils.formatTime(3665)).toBe('1:01:05'); + }); + + test('handles NaN', () => { + expect(utils.formatTime(NaN)).toBe('0:00'); + }); + }); + + describe('sanitizeForFilename', () => { + test('replaces invalid characters with underscores', () => { + expect(utils.sanitizeForFilename('a/b:c*d?e"fh|i')).toBe('a_b_c_d_e_f_g_h_i'); + }); + + test('collapses multiple spaces and trims', () => { + expect(utils.sanitizeForFilename(' hello world ')).toBe('hello world'); + }); + + test('returns "Unknown" for empty input', () => { + expect(utils.sanitizeForFilename('')).toBe('Unknown'); + expect(utils.sanitizeForFilename(null)).toBe('Unknown'); + }); + }); + + describe('replaceTokens', () => { + test('replaces tokens in template', () => { + const template = '{artist} - {title}'; + const tokens = { artist: 'Artist', title: 'Title' }; + expect(utils.replaceTokens(template, tokens)).toBe('Artist - Title'); + }); + + test('leaves unknown tokens as is', () => { + const template = '{artist} - {unknown}'; + const tokens = { artist: 'Artist' }; + expect(utils.replaceTokens(template, tokens)).toBe('Artist - {unknown}'); + }); + }); + + describe('formatPathTemplate', () => { + test('formats path correctly', () => { + const data = { + artist: 'Artist', + album: 'Album', + trackNumber: 1, + title: 'Title', + discNumber: 1, + }; + const template = '{artist}/{album}/{trackNumber} - {title}'; + expect(utils.formatPathTemplate(template, data)).toBe('Artist/Album/01 - Title'); + }); + + test('strips . and .. segments', () => { + const data = { artist: '..', title: '.' }; + const template = '{artist}/{title}/song'; + expect(utils.formatPathTemplate(template, data)).toBe('song'); + }); + }); + + describe('detectAudioFormat', () => { + test('detects flac', () => { + const view = new DataView(new Uint8Array([0x66, 0x4c, 0x61, 0x43]).buffer); + expect(utils.detectAudioFormat(view)).toBe('flac'); + }); + + test('detects mp4', () => { + const view = new DataView(new Uint8Array([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70]).buffer); + expect(utils.detectAudioFormat(view)).toBe('mp4'); + }); + + test('detects mp3 (ID3)', () => { + const view = new DataView(new Uint8Array([0x49, 0x44, 0x33]).buffer); + expect(utils.detectAudioFormat(view)).toBe('mp3'); + }); + + test('detects ogg', () => { + const view = new DataView(new Uint8Array([0x4f, 0x67, 0x67, 0x53]).buffer); + expect(utils.detectAudioFormat(view)).toBe('ogg'); + }); + + test('returns null for unknown format', () => { + const view = new DataView(new Uint8Array([0, 0, 0, 0]).buffer); + expect(utils.detectAudioFormat(view)).toBeNull(); + }); + }); + + describe('normalizeQualityToken', () => { + test('normalizes various quality strings', () => { + expect(utils.normalizeQualityToken('HI_RES_LOSSLESS')).toBe('HI_RES_LOSSLESS'); + expect(utils.normalizeQualityToken('MASTER')).toBe('HI_RES_LOSSLESS'); + expect(utils.normalizeQualityToken('HIFI')).toBe('LOSSLESS'); + expect(utils.normalizeQualityToken('ATMOS')).toBe('DOLBY_ATMOS'); + }); + + test('returns null for unknown quality', () => { + expect(utils.normalizeQualityToken('UNKNOWN')).toBeNull(); + }); + }); + + describe('pickBestQuality', () => { + test('picks the highest quality from list', () => { + expect(utils.pickBestQuality(['LOSSLESS', 'HI_RES_LOSSLESS', 'HIGH'])).toBe('HI_RES_LOSSLESS'); + expect(utils.pickBestQuality(['LOW', 'HIGH'])).toBe('HIGH'); + expect(utils.pickBestQuality(['DOLBY_ATMOS', 'HI_RES_LOSSLESS'])).toBe('DOLBY_ATMOS'); + }); + }); + + describe('getTrackTitle', () => { + test('returns title with version if present', () => { + expect(utils.getTrackTitle({ title: 'Song', version: 'Remix' })).toBe('Song (Remix)'); + }); + + test('returns just title if no version', () => { + expect(utils.getTrackTitle({ title: 'Song' })).toBe('Song'); + }); + + test('returns fallback if no title', () => { + expect(utils.getTrackTitle({}, { fallback: 'No Title' })).toBe('No Title'); + }); + }); + + describe('getTrackArtists', () => { + test('joins multiple artists', () => { + const track = { artists: [{ name: 'A' }, { name: 'B' }] }; + expect(utils.getTrackArtists(track)).toBe('A, B'); + }); + + test('returns fallback if no artists', () => { + expect(utils.getTrackArtists({})).toBe('Unknown Artist'); + }); + }); + + describe('getTrackDiscNumber', () => { + test('extracts disc number from various properties', () => { + expect(utils.getTrackDiscNumber({ discNumber: 2 })).toBe(2); + expect(utils.getTrackDiscNumber({ volumeNumber: 3 })).toBe(3); + expect(utils.getTrackDiscNumber({ mediaNumber: 4 })).toBe(4); + }); + + test('returns null for invalid values', () => { + expect(utils.getTrackDiscNumber({ discNumber: 0 })).toBeNull(); + expect(utils.getTrackDiscNumber({ discNumber: 'abc' })).toBeNull(); + }); + }); + + describe('tryCatch', () => { + test('executes sync function', () => { + const fn = vi.fn(() => 'success'); + const onError = vi.fn(); + expect(utils.tryCatch(fn, onError)).toBe('success'); + expect(onError).not.toHaveBeenCalled(); + }); + + test('handles sync error', () => { + const error = new Error('fail'); + const fn = vi.fn(() => { + throw error; + }); + const onError = vi.fn((err) => err.message); + expect(utils.tryCatch(fn, onError)).toBe('fail'); + expect(onError).toHaveBeenCalledWith(error); + }); + + test('executes async function', async () => { + const fn = vi.fn(async () => 'success'); + const onError = vi.fn(); + const result = await utils.tryCatch(fn, onError); + expect(result).toBe('success'); + expect(onError).not.toHaveBeenCalled(); + }); + + test('handles async error', async () => { + const error = new Error('fail'); + const fn = vi.fn(async () => { + throw error; + }); + const onError = vi.fn(async (err) => err.message); + const result = await utils.tryCatch(fn, onError); + expect(result).toBe('fail'); + expect(onError).toHaveBeenCalledWith(error); + }); + }); +}); From 11f66c1a28d9d13b54545b5224344c621d3fa8c9 Mon Sep 17 00:00:00 2001 From: Samidy Date: Mon, 6 Apr 2026 17:35:43 +0300 Subject: [PATCH 24/76] le "debloat root" thingy --- fix-gen.py => .github/scripts/fix-gen.py | 0 .../scripts/gen-editors-picks.py | 0 .github/workflows/editors-picks.yml | 2 +- Dockerfile => docker/Dockerfile | 0 Dockerfile.dev => docker/Dockerfile.dev | 0 docker-compose.yml => docker/docker-compose.yml | 0 legacy.html | 16 ---------------- .../editors-picks-input.txt | 0 8 files changed, 1 insertion(+), 17 deletions(-) rename fix-gen.py => .github/scripts/fix-gen.py (100%) rename gen-editors-picks.py => .github/scripts/gen-editors-picks.py (100%) rename Dockerfile => docker/Dockerfile (100%) rename Dockerfile.dev => docker/Dockerfile.dev (100%) rename docker-compose.yml => docker/docker-compose.yml (100%) delete mode 100644 legacy.html rename editors-picks-input.txt => public/editors-picks-input.txt (100%) diff --git a/fix-gen.py b/.github/scripts/fix-gen.py similarity index 100% rename from fix-gen.py rename to .github/scripts/fix-gen.py diff --git a/gen-editors-picks.py b/.github/scripts/gen-editors-picks.py similarity index 100% rename from gen-editors-picks.py rename to .github/scripts/gen-editors-picks.py diff --git a/.github/workflows/editors-picks.yml b/.github/workflows/editors-picks.yml index 680ce1a..933fee4 100644 --- a/.github/workflows/editors-picks.yml +++ b/.github/workflows/editors-picks.yml @@ -97,7 +97,7 @@ jobs: - name: Generate new editors picks if: steps.backoff.outputs.skip == 'false' - run: python3 gen-editors-picks.py + run: python3 .github/scripts/gen-editors-picks.py - name: Commit and push if: steps.backoff.outputs.skip == 'false' diff --git a/Dockerfile b/docker/Dockerfile similarity index 100% rename from Dockerfile rename to docker/Dockerfile diff --git a/Dockerfile.dev b/docker/Dockerfile.dev similarity index 100% rename from Dockerfile.dev rename to docker/Dockerfile.dev diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to docker/docker-compose.yml diff --git a/legacy.html b/legacy.html deleted file mode 100644 index d2d75f9..0000000 --- a/legacy.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - Redirecting... - - - - -

If you are not redirected, click here.

- - diff --git a/editors-picks-input.txt b/public/editors-picks-input.txt similarity index 100% rename from editors-picks-input.txt rename to public/editors-picks-input.txt From 77ffff3a85d72b6096150fb649b6069018e3c6b7 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:54:39 +0000 Subject: [PATCH 25/76] style: auto-fix linting issues --- index.html | 7 ++++++- js/ui.js | 7 +++++-- styles.css | 41 ++++++++++++++++++----------------------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/index.html b/index.html index 67b59d8..6d91859 100644 --- a/index.html +++ b/index.html @@ -293,7 +293,12 @@ - diff --git a/js/ui.js b/js/ui.js index 225b908..914cad2 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1336,7 +1336,9 @@ export class UIRenderer { nextTrackEl.classList.remove('animate-in'); } - const canRenderLyrics = Boolean(lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video'); + const canRenderLyrics = Boolean( + lyricsManager && activeElement && lyricsPane && lyricsContent && track.type !== 'video' + ); if (canRenderLyrics) { this.fullscreenLyricsVisible = true; if (lyricsToggleBtn) lyricsToggleBtn.style.removeProperty('display'); @@ -1349,7 +1351,8 @@ export class UIRenderer { overlay.classList.add('lyrics-unavailable'); if (lyricsContent) { clearFullscreenLyricsSync(lyricsContent); - lyricsContent.innerHTML = '
Lyrics are not available for this track.
'; + lyricsContent.innerHTML = + '
Lyrics are not available for this track.
'; } } this.updateFullscreenLyricsVisibility(overlay); diff --git a/styles.css b/styles.css index 9f66f1d..803f242 100644 --- a/styles.css +++ b/styles.css @@ -3924,6 +3924,7 @@ input:checked + .slider::before { /* Reserve space above taskbar / system UI so volume controls stay visible (fixes #322) */ padding-bottom: max(env(safe-area-inset-bottom), 1.5rem); + --fullscreen-drag-progress: 0; --fs-accent-rgb: var(--highlight-rgb); } @@ -3992,7 +3993,7 @@ input:checked + .slider::before { position: relative; padding: 1rem; overflow: hidden; - transform: translateY(var(--fullscreen-drag-offset, 0px)); + transform: translateY(var(--fullscreen-drag-offset, 0)); opacity: calc(1 - (var(--fullscreen-drag-progress, 0) * 0.16)); transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), @@ -4262,6 +4263,7 @@ input:checked + .slider::before { #fullscreen-cover-overlay.controls-idle { cursor: none; } + #fullscreen-cover-image { max-width: 55vw; max-height: 45vh; @@ -10124,6 +10126,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay .fullscreen-main-view { --fs-media-column-size: minmax(340px, 430px); --fs-lyrics-column-size: minmax(520px, 760px); + width: min(1480px, 100%); height: 100%; flex: 1; @@ -10166,7 +10169,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { aspect-ratio: 1 / 1; border-radius: 18px; overflow: hidden; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.26); + box-shadow: 0 28px 80px rgb(0, 0, 0, 0.26); } #fullscreen-cover-overlay #fullscreen-cover-image { @@ -10527,16 +10530,16 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { --lyrics-scroll-padding-top: 18%; --lyplus-blur-amount: 0.16em; --lyplus-blur-amount-near: 0.085em; + height: 100%; width: 100%; - font-family: - 'SF Pro Display', - Inter, - sans-serif; + font-family: 'SF Pro Display', Inter, sans-serif; + --lyplus-font-size-base: clamp(34px, 3vw, 52px); --lyplus-padding-line: 8px; - --lyplus-text-color: rgba(246, 244, 239, 0.08); + --lyplus-text-color: rgb(246, 244, 239, 0.08); --lyplus-active-color: #f6f4ef; + line-height: 1.32; letter-spacing: -0.04em; font-weight: 600; @@ -10562,6 +10565,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { #fullscreen-cover-overlay.lyrics-hidden .fullscreen-main-view { --fs-media-column-size: minmax(420px, 760px); --fs-lyrics-column-size: minmax(0, 0fr); + width: min(760px, 100%); gap: 0; } @@ -10608,11 +10612,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { width: min(760px, 100%); gap: 1.25rem; align-items: stretch; - padding: - calc(5rem + env(safe-area-inset-top)) - clamp(1rem, 4vw, 1.75rem) - calc(1.5rem + env(safe-area-inset-bottom)) - clamp(1rem, 4vw, 1.75rem); + padding: calc(5rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.75rem) + calc(1.5rem + env(safe-area-inset-bottom)); } #fullscreen-cover-overlay .fullscreen-media-column { @@ -10681,11 +10682,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { 'lyrics lyrics' 'controls controls'; gap: 1rem 0.9rem; - padding: - calc(4.45rem + env(safe-area-inset-top)) - 0 - calc(0.8rem + env(safe-area-inset-bottom)) - 0; + padding: calc(4.45rem + env(safe-area-inset-top)) 0 calc(0.8rem + env(safe-area-inset-bottom)); } #fullscreen-cover-overlay .fullscreen-media-column { @@ -10774,9 +10771,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { --lyrics-scroll-padding-top: 18%; --lyplus-font-size-base: clamp(1.75rem, 7vw, 2.35rem); --lyplus-padding-line: 6px; - --lyplus-text-color: rgba(246, 244, 239, 0.16); + --lyplus-text-color: rgb(246, 244, 239, 0.16); --lyplus-blur-amount: 0.16em; --lyplus-blur-amount-near: 0.08em; + line-height: 1.2; } @@ -10833,11 +10831,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { align-items: center; justify-items: center; gap: 0; - padding: - calc(4.45rem + env(safe-area-inset-top)) - clamp(1rem, 4vw, 1.4rem) - calc(0.8rem + env(safe-area-inset-bottom)) - clamp(1rem, 4vw, 1.4rem); + padding: calc(4.45rem + env(safe-area-inset-top)) clamp(1rem, 4vw, 1.4rem) + calc(0.8rem + env(safe-area-inset-bottom)); height: 100%; width: 100%; max-width: 100%; From 2fac78354252c0d757052340ff81801e258c01d0 Mon Sep 17 00:00:00 2001 From: Nohan-V2 Date: Mon, 6 Apr 2026 18:34:04 +0200 Subject: [PATCH 26/76] Handle NaN in numeric settings parsing Replace usages of parseFloat(... ) || default and parseInt(... ) || default with explicit parse + isNaN checks to avoid treating valid numeric zeros as falsy. Changes in js/settings.js and js/storage.js update parsing for replayGain preamp, playback speed, scrobble percentage, equalizer band count, and font size so only NaN triggers the fallback value and valid 0 values are preserved. --- js/settings.js | 6 ++++-- js/storage.js | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/js/settings.js b/js/settings.js index 2b8b187..ac17f46 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1137,7 +1137,8 @@ export async function initializeSettings(scrobbler, player, api, ui) { if (replayGainPreamp) { replayGainPreamp.value = replayGainSettings.getPreamp(); replayGainPreamp.addEventListener('change', (e) => { - replayGainSettings.setPreamp(parseFloat(e.target.value) || 3); + const val = parseFloat(e.target.value); + replayGainSettings.setPreamp(isNaN(val) ? 3 : val); player.applyReplayGain(); }); } @@ -1174,7 +1175,8 @@ export async function initializeSettings(scrobbler, player, api, ui) { if (playbackSpeedSlider && playbackSpeedInput) { // Helper function to update both controls const updatePlaybackSpeedControls = (speed) => { - const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0)); + const parsedSpeed = parseFloat(speed); + const validSpeed = Math.max(0.01, Math.min(100, isNaN(parsedSpeed) ? 1.0 : parsedSpeed)); playbackSpeedInput.value = validSpeed; // Only update slider if value is within slider range if (validSpeed >= 0.25 && validSpeed <= 4.0) { diff --git a/js/storage.js b/js/storage.js index f702eac..dce47da 100644 --- a/js/storage.js +++ b/js/storage.js @@ -442,7 +442,8 @@ export const lastFMStorage = { }, setScrobblePercentage(percentage) { - const validPercentage = Math.max(1, Math.min(100, parseInt(percentage, 10) || 75)); + const parsed = parseInt(percentage, 10); + const validPercentage = Math.max(1, Math.min(100, isNaN(parsed) ? 75 : parsed)); localStorage.setItem(this.SCROBBLE_PERCENTAGE_KEY, validPercentage.toString()); }, @@ -1123,9 +1124,10 @@ export const equalizerSettings = { }, setBandCount(count) { + const parsedCount = parseInt(count, 10); const validCount = Math.max( this.MIN_BANDS, - Math.min(this.MAX_BANDS, parseInt(count, 10) || this.DEFAULT_BAND_COUNT) + Math.min(this.MAX_BANDS, isNaN(parsedCount) ? this.DEFAULT_BAND_COUNT : parsedCount) ); localStorage.setItem(this.BAND_COUNT_KEY, validCount.toString()); }, @@ -2451,7 +2453,8 @@ export const fontSettings = { }, setFontSize(size) { - const validSize = Math.max(50, Math.min(200, parseInt(size, 10) || 100)); + const parsed = parseInt(size, 10); + const validSize = Math.max(50, Math.min(200, isNaN(parsed) ? 100 : parsed)); localStorage.setItem(this.FONT_SIZE_KEY, validSize.toString()); this.applyFontSize(); return validSize; From db20643a61d57a3c8209c6798e94217177ffa70a Mon Sep 17 00:00:00 2001 From: Samidy Date: Mon, 6 Apr 2026 19:56:01 +0300 Subject: [PATCH 27/76] feat(reviews): detailed critics reviews --- js/ui.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++- js/utils.js | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/js/ui.js b/js/ui.js index bd9db3f..9238ea9 100644 --- a/js/ui.js +++ b/js/ui.js @@ -13,7 +13,9 @@ import { calculateTotalDuration, formatDuration, escapeHtml, + decodeHtml, getShareUrl, + createModal, } from './utils.js'; import { openLyricsPanel, renderLyricsInFullscreen, clearFullscreenLyricsSync } from './lyrics.js'; import { @@ -3853,11 +3855,69 @@ export class UIRenderer { ); const data = await response.json(); - rateCriticsEl.innerHTML = `Critic Score: ${data.critic.score}, Based on ${data.critic.count} reviews`; + const critviews = data.critic.reviews || []; + + rateCriticsEl.innerHTML = `Critic Score: ${data.critic.score}, Based on ${data.critic.count} reviews`; if (data.critic.score == 'NR') { rateCriticsEl.innerHTML = `Critic Score Not Available Yet`; + } else { + rateCriticsEl.querySelector('a').onclick = () => { + const con = document.createElement('div'); + con.style.display = 'flex'; + con.style.flexDirection = 'column'; + con.style.gap = '1.5rem'; + + critviews.forEach((review) => { + const reviewdiv = document.createElement('div'); + reviewdiv.style.display = 'flex'; + reviewdiv.style.gap = '1rem'; + reviewdiv.style.paddingBottom = '1rem'; + reviewdiv.style.borderBottom = '1px solid var(--border)'; + + const publication = decodeHtml(review.publication || review.source || 'Unknown Publication'); + const author = decodeHtml(review.author || ''); + const quote = decodeHtml(review.text || review.quote || 'No review text available.'); + + reviewdiv.innerHTML = ` + +
+
+
+
${review.score}
+
+
+
+
+ `; + + reviewdiv.querySelector('.pub-name').textContent = publication; + if (author) { + reviewdiv.querySelector('.author-name').textContent = `By ${author}`; + } else { + reviewdiv.querySelector('.author-name').remove(); + } + reviewdiv.querySelector('.quote-text').textContent = `"${quote}"`; + + con.appendChild(reviewdiv); + }); + + if (critviews.length === 0) { + con.innerHTML = + '
No reviews found.
'; + } + + createModal({ + title: 'Critics Reviews', + content: con, + className: 'extra-wide', + }); + }; } + rateUsersEl.innerHTML = `User Score: ${data.user.score}, Based on ${data.user.count} reviews`; } catch (e) { rateCriticsEl.innerHTML = `Unable to Fetch Critic Score`; diff --git a/js/utils.js b/js/utils.js index 240b264..25bceba 100644 --- a/js/utils.js +++ b/js/utils.js @@ -381,6 +381,13 @@ export const escapeHtml = (unsafe) => { .replace(/'/g, '''); }; +export const decodeHtml = (html) => { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent; +}; + export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => { if (!track?.title) return fallback; return track?.version ? `${track.title} (${track.version})` : track.title; @@ -778,3 +785,39 @@ export function replaceTokens(template, tokens) { return key in tokens ? tokens[key] : match; }); } + +export function createModal({ title, content, className = '', onClose }) { + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.style.zIndex = '10000'; + + modal.innerHTML = ` + + + `; + + const body = modal.querySelector('.modal-body'); + if (typeof content === 'string') { + body.innerHTML = content; + } else if (content instanceof HTMLElement) { + body.appendChild(content); + } + + document.body.appendChild(modal); + + const close = () => { + modal.remove(); + if (onClose) onClose(); + }; + + modal.querySelector('.modal-overlay').onclick = close; + modal.querySelector('.btn-close').onclick = close; + + return { modal, close }; +} From 9d1e957f54dca5b899e58b333e663cdef88911e0 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:56:49 +0000 Subject: [PATCH 28/76] style: auto-fix linting issues --- js/ui.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/ui.js b/js/ui.js index 3e30b3b..20fbdd0 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3907,7 +3907,9 @@ export class UIRenderer { reviewdiv.style.paddingBottom = '1rem'; reviewdiv.style.borderBottom = '1px solid var(--border)'; - const publication = decodeHtml(review.publication || review.source || 'Unknown Publication'); + const publication = decodeHtml( + review.publication || review.source || 'Unknown Publication' + ); const author = decodeHtml(review.author || ''); const quote = decodeHtml(review.text || review.quote || 'No review text available.'); From 1edbcc454e19c0e208c9204f33f2c76c0de87677 Mon Sep 17 00:00:00 2001 From: a <252674497+ap5z@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:15:20 -0400 Subject: [PATCH 29/76] fix: enable mobile fullscreen visualizer (#517) --- js/events.js | 2 - js/storage.js | 4 +- js/ui.js | 44 +++++++++++----- js/visualizer.js | 130 +++++++++++++++++++++++++---------------------- styles.css | 17 ++++++- 5 files changed, 116 insertions(+), 81 deletions(-) diff --git a/js/events.js b/js/events.js index 54186cf..2e399b6 100644 --- a/js/events.js +++ b/js/events.js @@ -545,8 +545,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { if (player.isFallbackInProgress || canFallback) { return; } - console.warn('Skipping to next track due to playback error'); - setTimeout(() => player.playNext(), 1000); } }); diff --git a/js/storage.js b/js/storage.js index dce47da..0e58be6 100644 --- a/js/storage.js +++ b/js/storage.js @@ -949,9 +949,9 @@ export const visualizerSettings = { getPreset() { try { - return localStorage.getItem(this.PRESET_KEY) || 'butterchurn'; + return localStorage.getItem(this.PRESET_KEY) || 'kawarp'; } catch { - return 'butterchurn'; + return 'kawarp'; } }, diff --git a/js/ui.js b/js/ui.js index 20fbdd0..0ee258c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -36,6 +36,7 @@ import { syncManager } from './accounts/pocketbase.js'; import { authManager } from './accounts/auth.js'; import { partyManager } from './listening-party.js'; import { Visualizer } from './visualizer.js'; +import { audioContextManager } from './audio-context.js'; import { navigate } from './router.js'; import { sidePanelManager } from './side-panel.js'; import { @@ -1318,7 +1319,7 @@ export class UIRenderer { async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) { if (!track) return; - this.fullscreenVisualizerSuppressed = isMobileFullscreenViewport(); + this.fullscreenVisualizerSuppressed = false; if (window.location.hash !== '#fullscreen') { window.history.pushState({ fullscreen: true }, '', '#fullscreen'); } @@ -1600,7 +1601,17 @@ export class UIRenderer { } async startFullscreenVisualizer(activeElement, overlay) { - if (!activeElement) return; + if (!activeElement || !overlay) return false; + + if (audioContextManager.isReady()) { + audioContextManager.changeSource(activeElement); + await audioContextManager.resume(); + } else { + audioContextManager.init(activeElement); + if (audioContextManager.isReady()) { + await audioContextManager.resume(); + } + } if (!this.visualizer) { const canvas = document.getElementById('visualizer-canvas'); @@ -1608,24 +1619,28 @@ export class UIRenderer { this.visualizer = new Visualizer(canvas, activeElement); await this.visualizer.initPresets(); } + } else { + this.visualizer.audio = activeElement; } if (this.visualizer) { - await this.visualizer.start(); - overlay.classList.add('visualizer-active'); + const started = await this.visualizer.start(); + overlay.classList.toggle('visualizer-active', started); + return started; } + + overlay.classList.remove('visualizer-active'); + return false; } async ensureVisualizerPermission(activeElement, overlay, { closeOnCancel = false } = {}) { if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') { - await this.startFullscreenVisualizer(activeElement, overlay); - return true; + return await this.startFullscreenVisualizer(activeElement, overlay); } const modal = document.getElementById('epilepsy-warning-modal'); if (!modal) { - await this.startFullscreenVisualizer(activeElement, overlay); - return true; + return await this.startFullscreenVisualizer(activeElement, overlay); } return await new Promise((resolve) => { @@ -1637,8 +1652,7 @@ export class UIRenderer { acceptBtn.onclick = async () => { modal.classList.remove('active'); localStorage.setItem('epilepsy-warning-dismissed', 'true'); - await this.startFullscreenVisualizer(activeElement, overlay); - resolve(true); + resolve(await this.startFullscreenVisualizer(activeElement, overlay)); }; cancelBtn.onclick = () => { @@ -1646,7 +1660,7 @@ export class UIRenderer { if (closeOnCancel) { this.closeFullscreenCover(); } - resolve(false); + resolve(null); }; }); } @@ -1656,7 +1670,7 @@ export class UIRenderer { const visualizerBtn = document.getElementById('fs-visualizer-btn'); const toggleBtn = document.getElementById('toggle-ui-btn'); const isVideoTrack = this.player?.currentTrack?.type === 'video'; - const enabled = !isVideoTrack && !this.fullscreenVisualizerSuppressed && !isMobileFullscreenViewport(); + const enabled = !isVideoTrack && visualizerSettings.isEnabled() && !this.fullscreenVisualizerSuppressed; if (!overlay) return; @@ -1681,8 +1695,10 @@ export class UIRenderer { } const allowed = await this.ensureVisualizerPermission(activeElement, overlay, { closeOnCancel }); - if (!allowed) { - this.fullscreenVisualizerSuppressed = true; + if (allowed !== true) { + if (allowed === null) { + this.fullscreenVisualizerSuppressed = true; + } overlay.classList.remove('visualizer-active'); if (this.visualizer) { this.visualizer.stop(); diff --git a/js/visualizer.js b/js/visualizer.js index b8c802f..2fb557c 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -86,8 +86,8 @@ export class Visualizer { this.audioContext = audioContextManager.getAudioContext(); this.analyser = audioContextManager.getAnalyser(); - if (this.analyser) { - this.bufferLength = this.analyser.frequencyBinCount; + this.bufferLength = this.analyser?.frequencyBinCount || 512; + if (!this.dataArray || this.dataArray.length !== this.bufferLength) { this.dataArray = new Uint8Array(this.bufferLength); } } @@ -153,23 +153,24 @@ export class Visualizer { } async start() { - if (this.isActive) return; + if (this.isActive) return true; if (!this.ctx) { this.initContext(); } - if (!this.audioContext) { + if (!this.audioContext && !this.analyser) { await this.init(); } - if (!this.analyser) { - return; + const canRunWithoutAnalyser = !!this.activePreset?.managesOwnContext; + if (!this.analyser && !canRunWithoutAnalyser) { + return false; } this.isActive = true; - if (this.audioContext.state === 'suspended') { - this.audioContext.resume(); + if (this.audioContext?.state === 'suspended') { + await this.audioContext.resume(); } this.updateDimming(); @@ -182,12 +183,19 @@ export class Visualizer { // Initialize presets that need lazy init (Butterchurn, Kawarp) if (this.activePreset.lazyInit) { const sourceNode = audioContextManager.getSourceNode(); - this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode).then(() => { - this.resize(); - }); + await this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode); + this.resize(); + } + + if (this.activePreset.managesOwnContext && this.activePreset.isInitialized === false) { + this.isActive = false; + this.canvas.style.display = 'none'; + window.removeEventListener('resize', this._resizeBound); + return false; } this.animate(); + return true; } stop() { @@ -223,57 +231,48 @@ export class Visualizer { if (!this.isActive) return; this.animationId = requestAnimationFrame(this.animate); - // ===== AUDIO ANALYSIS ===== - this.analyser.getByteFrequencyData(this.dataArray); - - // Bass (dynamic bins based on sample rate) - const volume = 10 * Math.max(this.audio.volume, 0.1); - - // Robust bass detection: sum bins up to ~250Hz - const binSize = this.audioContext.sampleRate / this.analyser.fftSize; - const startBin = 1; // Skip DC offset - // Calculate how many bins cover the bass range (up to 250Hz) - let numBins = Math.floor(250 / binSize); - if (numBins < 1) numBins = 1; // Ensure at least one bin is checked - - let maxVal = 0; - for (let i = 0; i < numBins && startBin + i < this.dataArray.length; i++) { - const val = this.dataArray[startBin + i]; - if (val > maxVal) maxVal = val; - } - - // Normalize: (Max / 255) / Volume - let bass = maxVal / 255 / volume; - - const intensity = bass * bass * 10; const stats = this.stats; - stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01; - stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08; + if (this.analyser && this.dataArray && this.audioContext) { + this.analyser.getByteFrequencyData(this.dataArray); - // ===== SENSITIVITY ===== - let sensitivity = visualizerSettings.getSensitivity(); - if (visualizerSettings.isSmartIntensityEnabled()) { - if (stats.energyAverage > 0.4) { - sensitivity = 0.7; - } else if (stats.energyAverage > 0.2) { - sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6; - } else { - sensitivity = 0.1; + const volume = 10 * Math.max(this.audio.volume, 0.1); + const binSize = this.audioContext.sampleRate / this.analyser.fftSize; + const startBin = 1; + let numBins = Math.floor(250 / binSize); + if (numBins < 1) numBins = 1; + + let maxVal = 0; + for (let i = 0; i < numBins && startBin + i < this.dataArray.length; i++) { + const val = this.dataArray[startBin + i]; + if (val > maxVal) maxVal = val; } - } - // ===== KICK DETECTION ===== - const now = performance.now(); - let threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5; + const bass = maxVal / 255 / volume; + const intensity = bass * bass * 10; - // Lower threshold for more responsive kick - if (intensity > threshold * 0.7) { - if (intensity > stats.lastIntensity + 0.03 && now - stats.lastBeatTime > 50) { - stats.kick = 1.0; - stats.lastBeatTime = now; - } else { - if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) { + stats.energyAverage = stats.energyAverage * 0.99 + intensity * 0.01; + stats.upbeatSmoother = stats.upbeatSmoother * 0.92 + intensity * 0.08; + + let sensitivity = visualizerSettings.getSensitivity(); + if (visualizerSettings.isSmartIntensityEnabled()) { + if (stats.energyAverage > 0.4) { + sensitivity = 0.7; + } else if (stats.energyAverage > 0.2) { + sensitivity = 0.1 + ((stats.energyAverage - 0.2) / 0.2) * 0.6; + } else { + sensitivity = 0.1; + } + } + + const now = performance.now(); + const threshold = stats.energyAverage < 0.3 ? 0.5 + (0.3 - stats.energyAverage) * 2 : 0.5; + + if (intensity > threshold * 0.7) { + if (intensity > stats.lastIntensity + 0.03 && now - stats.lastBeatTime > 50) { + stats.kick = 1.0; + stats.lastBeatTime = now; + } else if (stats.upbeatSmoother > 0.6 && stats.energyAverage > 0.4) { const upbeatLevel = (stats.upbeatSmoother - 0.6) / 0.4; if (stats.kick < upbeatLevel) { stats.kick = upbeatLevel; @@ -283,14 +282,21 @@ export class Visualizer { } else { stats.kick *= 0.9; } + } else { + stats.kick *= 0.95; } - } else { - stats.kick *= 0.95; - } - stats.lastIntensity = intensity; - stats.intensity = intensity; - stats.sensitivity = sensitivity; + stats.lastIntensity = intensity; + stats.intensity = intensity; + stats.sensitivity = sensitivity; + } else { + stats.kick *= 0.92; + stats.intensity *= 0.92; + stats.energyAverage *= 0.98; + stats.upbeatSmoother *= 0.95; + stats.sensitivity = visualizerSettings.getSensitivity(); + this.dataArray?.fill(0); + } // ===== COLORS (CACHED) ===== const color = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#ffffff'; diff --git a/styles.css b/styles.css index 803f242..7881de7 100644 --- a/styles.css +++ b/styles.css @@ -10656,10 +10656,15 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { } #fullscreen-cover-overlay .fullscreen-top-actions { - display: none; + display: flex; + top: calc(0.85rem + env(safe-area-inset-top)); + left: auto; + right: calc(3.85rem + env(safe-area-inset-right)); + gap: 0; } #fullscreen-cover-overlay .fullscreen-lyrics-toggle, + #fullscreen-cover-overlay #close-fullscreen-cover-btn, #fullscreen-cover-overlay #toggle-ui-btn { display: none !important; } @@ -10668,6 +10673,16 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { display: flex; } + #fullscreen-cover-overlay .fullscreen-top-actions #toggle-fullscreen-lyrics-btn { + display: none !important; + } + + #fullscreen-cover-overlay .fullscreen-top-actions #fs-visualizer-btn { + display: flex !important; + width: 38px; + height: 38px; + } + #fullscreen-cover-overlay .fullscreen-main-view { width: 100%; height: 100%; From 6e3ae8a04e8d031b8196f2b02f1a6f610767e48e Mon Sep 17 00:00:00 2001 From: Samidy Date: Mon, 6 Apr 2026 22:49:15 +0300 Subject: [PATCH 30/76] update workers link so no more limits --- js/ui.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/ui.js b/js/ui.js index 3e30b3b..bc7f482 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3883,7 +3883,7 @@ export class UIRenderer { async function fetchAotyWorker(album, artist) { try { const response = await fetch( - `https://aoty-critics.samidy.workers.dev/?artist=${artist}&album=${album}` + `https://aoty-api.hnh65483.workers.dev/?artist=${artist}&album=${album}` ); const data = await response.json(); @@ -3907,7 +3907,9 @@ export class UIRenderer { reviewdiv.style.paddingBottom = '1rem'; reviewdiv.style.borderBottom = '1px solid var(--border)'; - const publication = decodeHtml(review.publication || review.source || 'Unknown Publication'); + const publication = decodeHtml( + review.publication || review.source || 'Unknown Publication' + ); const author = decodeHtml(review.author || ''); const quote = decodeHtml(review.text || review.quote || 'No review text available.'); From 893138e15b73dd2ac708d4efbfa836a93683f59d Mon Sep 17 00:00:00 2001 From: tezvii <116644703+tezvii@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:40:08 +0300 Subject: [PATCH 31/76] Add async and await to repeatBtn --- js/ui.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/ui.js b/js/ui.js index e293196..2dcbebc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2024,8 +2024,8 @@ export class UIRenderer { shuffleBtn.classList.toggle('active', this.player.shuffleActive); }; - repeatBtn.onclick = () => { - const mode = this.player.toggleRepeat(); + repeatBtn.onclick = async () => { + const mode = await this.player.toggleRepeat(); repeatBtn.classList.toggle('active', mode !== 0); if (mode === 2) { repeatBtn.innerHTML = SVG_REPEAT_ONE(24); From b40fc4617e4c208ac7207663bf2df1d6f35be052 Mon Sep 17 00:00:00 2001 From: tezvii <116644703+tezvii@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:41:15 +0300 Subject: [PATCH 32/76] Added await to player.toggleRepeat --- js/events.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/events.js b/js/events.js index 2e399b6..cb9e5b7 100644 --- a/js/events.js +++ b/js/events.js @@ -585,7 +585,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { repeatBtn.addEventListener('click', async () => { await hapticLight(); - const mode = player.toggleRepeat(); + const mode = await player.toggleRepeat(); trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one'); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE); From fdfe1a54d029019c76cae4ca9759fbb2e835a478 Mon Sep 17 00:00:00 2001 From: binimum Date: Tue, 7 Apr 2026 21:33:13 +0100 Subject: [PATCH 33/76] deps: update @uimaxbai/am-lyrics to version 1.1.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9c15a32..9b370ea 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.4", + "@uimaxbai/am-lyrics": "^1.1.5", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", From 080b268e29b261d37e45d24ab97abe8e73a32dfe Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:41:47 +0100 Subject: [PATCH 34/76] deps: bump am-lyrics again --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b370ea..a07c65e 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.5", + "@uimaxbai/am-lyrics": "^1.1.6", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", From e9131c7e9a6553b13c6021afbea71cb789fffea0 Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:49:58 +0100 Subject: [PATCH 35/76] deps: bump am-lyrics again again --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a07c65e..cf5edc8 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.6", + "@uimaxbai/am-lyrics": "^1.1.7", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", From 96f670affb4744358d5b7a4b32d3c22ed708fe85 Mon Sep 17 00:00:00 2001 From: a <252674497+ap5z@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:41:44 -0400 Subject: [PATCH 36/76] fix scrolling (#533) --- bun.lock | 4 ++-- js/ui.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index c4873ee..8494616 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.4", + "@uimaxbai/am-lyrics": "^1.1.7", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", @@ -675,7 +675,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-LEwvbfgz6o71kYTq1vMlfou/powr8q4CJQWuyL2H48Dwo1/vH59SKiB3nz/WOEQ1S69uaSmfqf8Prtx6+ZNIrQ=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.7", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-hEwPl4dFmJ08sJf4VBaR7k7yxA3BNaoINS89j0KrkSFJYpCkohHDy24AIfzEMonPloJ3H6HBA55nCFMnAzm50w=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], diff --git a/js/ui.js b/js/ui.js index 2dcbebc..8f42aa1 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1324,6 +1324,7 @@ export class UIRenderer { window.history.pushState({ fullscreen: true }, '', '#fullscreen'); } const overlay = document.getElementById('fullscreen-cover-overlay'); + const isAlreadyOpen = overlay && window.getComputedStyle(overlay).display !== 'none'; const nextTrackEl = document.getElementById('fullscreen-next-track'); const lyricsPane = document.getElementById('fullscreen-lyrics-pane'); const lyricsContent = document.getElementById('fullscreen-lyrics-content'); @@ -1366,7 +1367,7 @@ export class UIRenderer { sidePanelManager.close(); } const mainContent = document.querySelector('.main-content'); - if (mainContent instanceof HTMLElement) { + if (mainContent instanceof HTMLElement && !isAlreadyOpen) { const computedStyles = window.getComputedStyle(mainContent); this.fullscreenMainContentOverflow = { overflow: mainContent.style.overflow, From efbd16c1f05781d2e192d4e1d6b4391df8110ba9 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 8 Apr 2026 20:16:46 +0300 Subject: [PATCH 37/76] let genius annotation feature work in .tf --- js/lyrics.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/lyrics.js b/js/lyrics.js index 96a7dee..6cde9f3 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -74,8 +74,13 @@ class GeniusManager { this.loading = false; } + // idgaf anymore im js hardcoding this lmaooo getToken() { - return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z'; // idgaf anymore im js hardcoding this lmaooo + const hostname = window.location.hostname; + if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') { + return 'OpITG-h86oehKYuJJ5QVY5F-HxUWXb31EwGKarx2Tle3W9rBUVnMaUL9qo_Oh9Q7'; + } + return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z'; } async searchTrack(title, artist) { From 07efefff1cfd59f26c4218ad3bbcc12e908e24d9 Mon Sep 17 00:00:00 2001 From: windbus Date: Wed, 8 Apr 2026 20:53:42 +0200 Subject: [PATCH 38/76] Refactor status rendering to fix XSS --- js/profile.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/js/profile.js b/js/profile.js index b9e8f42..1ca0012 100644 --- a/js/profile.js +++ b/js/profile.js @@ -188,12 +188,27 @@ export async function loadProfile(username) { const statusEl = document.getElementById('profile-status'); try { const statusObj = JSON.parse(profile.status); - statusEl.innerHTML = ` - Listening to: - - ${statusObj.text} - `; - statusEl.querySelector('.status-link').onclick = (e) => { + + statusEl.replaceChildren(); + + const label = document.createElement('span'); + label.style.cssText = 'opacity: 0.7; margin-right: 0.25rem;'; + label.textContent = 'Listening to:'; + + const img = document.createElement('img'); + img.src = statusObj.image; + img.style.cssText = 'width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;'; + + const link = document.createElement('a'); + if (statusObj.link.startsWith("/")) { + link.href = statusObj.link; + } + link.className = 'status-link'; + link.style.cssText = 'color: inherit; text-decoration: none; font-weight: 500;'; + link.textContent = statusObj.text; + + statusEl.append(label, img, link); + link.onclick = (e) => { e.preventDefault(); navigate(statusObj.link); }; From 08a85cf8a032f8e36911c30b6e736d0fee3d0700 Mon Sep 17 00:00:00 2001 From: windbus Date: Wed, 8 Apr 2026 20:54:17 +0200 Subject: [PATCH 39/76] bump version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf5edc8..b60e3f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "monochrome", "type": "module", - "version": "2.5.0", + "version": "2.5.1", "description": "[\"Monochrome](https://monochrome.tf)", "scripts": { "dev": "vite", From 558df133e1d1112c0b3f733b3cd073154228d1de Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:57:24 +0000 Subject: [PATCH 40/76] style: auto-fix linting issues --- js/profile.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/profile.js b/js/profile.js index 1ca0012..3574a8d 100644 --- a/js/profile.js +++ b/js/profile.js @@ -197,10 +197,11 @@ export async function loadProfile(username) { const img = document.createElement('img'); img.src = statusObj.image; - img.style.cssText = 'width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;'; + img.style.cssText = + 'width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;'; const link = document.createElement('a'); - if (statusObj.link.startsWith("/")) { + if (statusObj.link.startsWith('/')) { link.href = statusObj.link; } link.className = 'status-link'; From b8818e73c9a80035035ccbbef43784371e3708a0 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 8 Apr 2026 22:12:58 +0300 Subject: [PATCH 41/76] im such a fat chud Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- js/themeStore.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/themeStore.js b/js/themeStore.js index 27d2bb3..9ddeb80 100644 --- a/js/themeStore.js +++ b/js/themeStore.js @@ -416,7 +416,15 @@ export class ThemeStore { const customUrl = urlMatch[1].trim().replace(/['"]/g, ''); console.log(`Applying custom font URL: ${customUrl}`); - if (customUrl.match(/\.(css)$/i) || customUrl.includes('fonts.googleapis.com')) { + let isGoogleFontsHost = false; + try { + const parsedUrl = new URL(customUrl, window.location.href); + isGoogleFontsHost = parsedUrl.hostname === 'fonts.googleapis.com'; + } catch (_e) { + isGoogleFontsHost = false; + } + + if (customUrl.match(/\.(css)$/i) || isGoogleFontsHost) { if (!link) { link = document.createElement('link'); link.id = FONT_LINK_ID; From d1aee9dc2cd72f476c8bcf022460e8884499f782 Mon Sep 17 00:00:00 2001 From: Samidy Date: Wed, 8 Apr 2026 23:19:42 +0300 Subject: [PATCH 42/76] Remove Mossad tracking software (why THE FUCK were we tracking everything) --- js/analytics.js | 712 ------------------------------------------ js/app.js | 78 +---- js/events.js | 74 ----- js/side-panel.js | 10 +- js/ui-interactions.js | 5 - js/ui.js | 3 - 6 files changed, 2 insertions(+), 880 deletions(-) diff --git a/js/analytics.js b/js/analytics.js index 43a5ace..d262fe3 100644 --- a/js/analytics.js +++ b/js/analytics.js @@ -34,722 +34,10 @@ export function trackPageView(path) { trackEvent('pageview', { path }); } -// Playback Events -export function trackPlayTrack(track) { - trackEvent('Play Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - album: track?.album?.title || 'Unknown', - duration: track?.duration || 0, - quality: track?.audioQuality || track?.quality || 'Unknown', - is_local: track?.isLocal || false, - is_explicit: track?.explicit || false, - track_number: track?.trackNumber || 0, - year: track?.album?.releaseYear || track?.album?.releaseDate || 'unknown', - }); -} - -export function trackPauseTrack(track) { - trackEvent('Pause Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - album: track?.album?.title || 'Unknown', - }); -} - -export function trackSkipTrack(track, direction) { - trackEvent('Skip Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - album: track?.album?.title || 'Unknown', - direction: direction, - }); -} - -export function trackToggleShuffle(enabled) { - trackEvent('Toggle Shuffle', { enabled }); -} - -export function trackToggleRepeat(mode) { - trackEvent('Toggle Repeat', { mode }); -} - -export function trackTrackComplete(track, completionPercent) { - trackEvent('Track Complete', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - album: track?.album?.title || 'Unknown', - duration: track?.duration || 0, - completion_percent: completionPercent || 100, - }); -} - -export function trackSetVolume(level) { - // Only track volume changes at coarse intervals to avoid spam - const roundedLevel = Math.round(level * 10) / 10; - trackEvent('Set Volume', { level: roundedLevel }); -} - -export function trackToggleMute(muted) { - trackEvent('Toggle Mute', { muted }); -} - -// Track listening progress milestones (10%, 50%, 90%, 100%) -export function trackListeningProgress(track, percent) { - trackEvent('Listening Progress', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - percent: percent, - }); -} - -// Search Events -export function trackSearch(query, resultsCount) { - trackEvent('Search', { - query_length: query?.length || 0, - has_results: resultsCount > 0, - results_count: resultsCount, - }); -} - -export function trackSearchTabChange(tab) { - trackEvent('Search Tab Change', { tab }); -} - -// Navigation Events -export function trackNavigate(path, pageType) { - trackEvent('Navigate', { - path, - page_type: pageType, - }); -} - -export function trackSidebarNavigation(item) { - trackEvent('Sidebar Navigation', { item }); -} - -// Library Events -export function trackLikeTrack(track) { - trackEvent('Like Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - album: track?.album?.title || 'Unknown', - }); -} - -export function trackUnlikeTrack(track) { - trackEvent('Unlike Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - }); -} - -export function trackLikeAlbum(album) { - trackEvent('Like Album', { - album_title: album?.title || 'Unknown', - artist: album?.artist?.name || 'Unknown', - }); -} - -export function trackUnlikeAlbum(album) { - trackEvent('Unlike Album', { - album_title: album?.title || 'Unknown', - }); -} - -export function trackLikeArtist(artist) { - trackEvent('Like Artist', { - artist_name: artist?.name || 'Unknown', - }); -} - -export function trackUnlikeArtist(artist) { - trackEvent('Unlike Artist', { - artist_name: artist?.name || 'Unknown', - }); -} - -export function trackLikePlaylist(playlist) { - trackEvent('Like Playlist', { - playlist_name: playlist?.title || playlist?.name || 'Unknown', - }); -} - -export function trackUnlikePlaylist(playlist) { - trackEvent('Unlike Playlist', { - playlist_name: playlist?.title || playlist?.name || 'Unknown', - }); -} - -// Playlist Management Events -export function trackCreatePlaylist(playlist, source) { - trackEvent('Create Playlist', { - playlist_name: playlist?.name || 'Unknown', - track_count: playlist?.tracks?.length || 0, - is_public: playlist?.isPublic || false, - source: source || 'manual', - }); -} - -export function trackEditPlaylist(playlist) { - trackEvent('Edit Playlist', { - playlist_name: playlist?.name || 'Unknown', - }); -} - -export function trackDeletePlaylist(playlistName) { - trackEvent('Delete Playlist', { playlist_name: playlistName }); -} - -export function trackAddToPlaylist(track, playlist) { - trackEvent('Add to Playlist', { - track_title: track?.title || 'Unknown', - playlist_name: playlist?.name || 'Unknown', - }); -} - -export function trackRemoveFromPlaylist(track, playlist) { - trackEvent('Remove from Playlist', { - track_title: track?.title || 'Unknown', - playlist_name: playlist?.name || 'Unknown', - }); -} - -export function trackCreateFolder(folder) { - trackEvent('Create Folder', { - folder_name: folder?.name || 'Unknown', - }); -} - -export function trackDeleteFolder(folderName) { - trackEvent('Delete Folder', { folder_name: folderName }); -} - -// Playback Actions -export function trackPlayAlbum(album, shuffle) { - trackEvent('Play Album', { - album_id: album?.id || 'unknown', - album_title: album?.title || 'Unknown', - artist_id: album?.artist?.id || 'unknown', - artist: album?.artist?.name || 'Unknown', - shuffle: shuffle || false, - track_count: album?.numberOfTracks || album?.tracks?.length || 0, - year: album?.releaseYear || album?.releaseDate || 'unknown', - }); -} - -export function trackPlayPlaylist(playlist, shuffle) { - trackEvent('Play Playlist', { - playlist_id: playlist?.id || 'unknown', - playlist_name: playlist?.title || playlist?.name || 'Unknown', - shuffle: shuffle || false, - track_count: playlist?.tracks?.length || 0, - is_public: playlist?.isPublic || false, - }); -} - -export function trackPlayArtistRadio(artist) { - trackEvent('Play Artist Radio', { - artist_id: artist?.id || 'unknown', - artist_name: artist?.name || 'Unknown', - }); -} - -export function trackShuffleLikedTracks(count) { - trackEvent('Shuffle Liked Tracks', { track_count: count }); -} - -// Download Events -export function trackDownloadTrack(track, quality) { - trackEvent('Download Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - quality: quality || 'Unknown', - }); -} - -export function trackDownloadAlbum(album, quality) { - trackEvent('Download Album', { - album_id: album?.id || 'unknown', - album_title: album?.title || 'Unknown', - artist_id: album?.artist?.id || 'unknown', - artist: album?.artist?.name || 'Unknown', - track_count: album?.numberOfTracks || album?.tracks?.length || 0, - quality: quality || 'Unknown', - }); -} - -export function trackDownloadPlaylist(playlist, quality) { - trackEvent('Download Playlist', { - playlist_id: playlist?.id || 'unknown', - playlist_name: playlist?.title || playlist?.name || 'Unknown', - track_count: playlist?.tracks?.length || 0, - quality: quality || 'Unknown', - }); -} - -export function trackDownloadLikedTracks(count, quality) { - trackEvent('Download Liked Tracks', { - track_count: count, - quality: quality || 'Unknown', - }); -} - -export function trackDownloadDiscography(artist, selection) { - trackEvent('Download Discography', { - artist_id: artist?.id || 'unknown', - artist_name: artist?.name || 'Unknown', - include_albums: selection?.includeAlbums || false, - include_eps: selection?.includeEPs || false, - include_singles: selection?.includeSingles || false, - }); -} - -// Queue Management -export function trackAddToQueue(track, position) { - trackEvent('Add to Queue', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - position: position || 'end', - }); -} - -export function trackPlayNext(track) { - trackEvent('Play Next', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - }); -} - -export function trackClearQueue() { - trackEvent('Clear Queue'); -} - -export function trackShuffleQueue() { - trackEvent('Shuffle Queue'); -} - -// Context Menu Actions -export function trackContextMenuAction(action, itemType, item) { - trackEvent('Context Menu Action', { - action, - item_type: itemType, - item_name: item?.title || item?.name || 'Unknown', - }); -} - -export function trackBlockTrack(track) { - trackEvent('Block Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - artist_id: track?.artist?.id || track?.artists?.[0]?.id || 'unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - album_id: track?.album?.id || 'unknown', - }); -} - -export function trackUnblockTrack(track) { - trackEvent('Unblock Track', { - track_id: track?.id || 'unknown', - track_title: track?.title || 'Unknown', - }); -} - -export function trackBlockAlbum(album) { - trackEvent('Block Album', { - album_id: album?.id || 'unknown', - album_title: album?.title || 'Unknown', - artist_id: album?.artist?.id || 'unknown', - }); -} - -export function trackUnblockAlbum(album) { - trackEvent('Unblock Album', { - album_id: album?.id || 'unknown', - album_title: album?.title || 'Unknown', - }); -} - -export function trackBlockArtist(artist) { - trackEvent('Block Artist', { - artist_id: artist?.id || 'unknown', - artist_name: artist?.name || 'Unknown', - }); -} - -export function trackUnblockArtist(artist) { - trackEvent('Unblock Artist', { - artist_id: artist?.id || 'unknown', - artist_name: artist?.name || 'Unknown', - }); -} - -export function trackCopyLink(type, id) { - trackEvent('Copy Link', { type, id }); -} - -export function trackOpenInNewTab(type, id) { - trackEvent('Open in New Tab', { type, id }); -} - -// Lyrics Events -export function trackOpenLyrics(track) { - trackEvent('Open Lyrics', { - track_title: track?.title || 'Unknown', - artist: track?.artist?.name || track?.artists?.[0]?.name || 'Unknown', - }); -} - -export function trackCloseLyrics(track) { - trackEvent('Close Lyrics', { - track_title: track?.title || 'Unknown', - }); -} - -// Fullscreen/Cover View Events -export function trackOpenFullscreenCover(track) { - trackEvent('Open Fullscreen Cover', { - track_title: track?.title || 'Unknown', - }); -} - -export function trackCloseFullscreenCover() { - trackEvent('Close Fullscreen Cover'); -} - -export function trackToggleVisualizer(enabled) { - trackEvent('Toggle Visualizer', { enabled }); -} - -export function trackToggleLyricsFullscreen(enabled) { - trackEvent('Toggle Lyrics Fullscreen', { enabled }); -} - -// Settings Events -export function trackChangeSetting(setting, value) { - trackEvent('Change Setting', { - setting, - value: typeof value === 'object' ? JSON.stringify(value) : String(value), - }); -} - -export function trackChangeTheme(theme) { - trackEvent('Change Theme', { theme }); -} - -export function trackChangeQuality(type, quality) { - trackEvent('Change Quality', { type, quality }); -} - -export function trackChangeVolume(volume) { - trackEvent('Change Volume', { volume: Math.round(volume * 100) }); -} - -export function trackToggleScrobbler(service, enabled) { - trackEvent('Toggle Scrobbler', { service, enabled }); -} - -export function trackConnectScrobbler(service) { - trackEvent('Connect Scrobbler', { service }); -} - -export function trackDisconnectScrobbler(service) { - trackEvent('Disconnect Scrobbler', { service }); -} - -// Local Files Events -export function trackSelectLocalFolder(fileCount) { - trackEvent('Select Local Folder', { file_count: fileCount }); -} - -export function trackPlayLocalFile(track) { - trackEvent('Play Local File', { - track_title: track?.title || 'Unknown', - }); -} - -export function trackChangeLocalFolder() { - trackEvent('Change Local Folder'); -} - -// Import/Export Events -export function trackImportCSV(playlistName, trackCount, missingCount) { - trackEvent('Import CSV', { - playlist_name: playlistName, - track_count: trackCount, - missing_count: missingCount, - }); -} - -export function trackImportJSPF(playlistName, trackCount, missingCount, source) { - trackEvent('Import JSPF', { - playlist_name: playlistName, - track_count: trackCount, - missing_count: missingCount, - source: source || 'unknown', - }); -} - -export function trackImportXSPF(playlistName, trackCount, missingCount) { - trackEvent('Import XSPF', { - playlist_name: playlistName, - track_count: trackCount, - missing_count: missingCount, - }); -} - -export function trackImportXML(playlistName, trackCount, missingCount) { - trackEvent('Import XML', { - playlist_name: playlistName, - track_count: trackCount, - missing_count: missingCount, - }); -} - -export function trackImportM3U(playlistName, trackCount, missingCount) { - trackEvent('Import M3U', { - playlist_name: playlistName, - track_count: trackCount, - missing_count: missingCount, - }); -} - -// Sleep Timer Events -export function trackSetSleepTimer(minutes) { - trackEvent('Set Sleep Timer', { minutes }); -} - -export function trackCancelSleepTimer() { - trackEvent('Cancel Sleep Timer'); -} - -// History Events -export function trackClearHistory() { - trackEvent('Clear History'); -} - -export function trackClearRecent() { - trackEvent('Clear Recent'); -} - -// Casting Events -export function trackStartCasting(deviceType) { - trackEvent('Start Casting', { device_type: deviceType }); -} - -export function trackStopCasting() { - trackEvent('Stop Casting'); -} - -// Keyboard Shortcuts -export function trackKeyboardShortcut(key) { - trackEvent('Keyboard Shortcut', { key }); -} - -// Pinning Events -export function trackPinItem(type, item) { - trackEvent('Pin Item', { - type, - item_name: item?.title || item?.name || 'Unknown', - }); -} - -export function trackUnpinItem(type, item) { - trackEvent('Unpin Item', { - type, - item_name: item?.title || item?.name || 'Unknown', - }); -} - -// Side Panel Events -export function trackOpenSidePanel(panelType) { - trackEvent('Open Side Panel', { panel_type: panelType }); -} - -export function trackCloseSidePanel() { - trackEvent('Close Side Panel'); -} - -// Queue Panel Events -export function trackOpenQueue() { - trackEvent('Open Queue'); -} - -export function trackCloseQueue() { - trackEvent('Close Queue'); -} - -// Mix Events -export function trackStartMix(sourceType, source) { - trackEvent('Start Mix', { - source_type: sourceType, - source_name: source?.title || source?.name || 'Unknown', - }); -} - -export function trackPlayMix(mixId) { - trackEvent('Play Mix', { mix_id: mixId }); -} - -// Search History Events -export function trackClearSearchHistory() { - trackEvent('Clear Search History'); -} - -export function trackClickSearchHistory(query) { - trackEvent('Click Search History', { query_length: query?.length || 0 }); -} - -// PWA/Update Events -export function trackPwaInstall() { - trackEvent('PWA Install'); -} - -export function trackPwaUpdate() { - trackEvent('PWA Update'); -} - -export function trackDismissUpdate() { - trackEvent('Dismiss Update'); -} - -// Sort Events -export function trackChangeSort(sortType) { - trackEvent('Change Sort', { sort_type: sortType }); -} - -// Modal Events -export function trackOpenModal(modalName) { - trackEvent('Open Modal', { modal_name: modalName }); -} - -export function trackCloseModal(modalName) { - trackEvent('Close Modal', { modal_name: modalName }); -} - -// Sharing Events -export function trackSharePlaylist(playlist, isPublic) { - trackEvent('Share Playlist', { - playlist_name: playlist?.name || 'Unknown', - is_public: isPublic, - }); -} - -// Audio Effects Events -export function trackChangePlaybackSpeed(speed) { - trackEvent('Change Playback Speed', { speed }); -} - -export function trackToggleReplayGain(mode) { - trackEvent('Toggle ReplayGain', { mode }); -} - -export function trackChangeEqualizer(preset) { - trackEvent('Change Equalizer', { preset }); -} - -// Waveform Events -export function trackToggleWaveform(enabled) { - trackEvent('Toggle Waveform', { enabled }); -} - -// Error Events -export function trackPlaybackError(errorType, track) { - trackEvent('Playback Error', { - error_type: errorType, - track_title: track?.title || 'Unknown', - }); -} - -export function trackSearchError(query) { - trackEvent('Search Error', { query_length: query?.length || 0 }); -} - -export function trackApiError(endpoint) { - trackEvent('API Error', { endpoint }); -} - -// Feature Discovery Events -export function trackViewFeature(feature) { - trackEvent('View Feature', { feature }); -} - -export function trackUseFeature(feature) { - trackEvent('Use Feature', { feature }); -} - -// Session Events -export function trackSessionStart() { - trackEvent('Session Start', { - user_agent: navigator.userAgent, - screen_width: window.screen.width, - screen_height: window.screen.height, - language: navigator.language, - }); -} - -export function trackSessionEnd(duration) { - trackEvent('Session End', { duration }); -} - // Initialize analytics on page load export function initAnalytics() { if (!isAnalyticsEnabled()) return; // Track initial page view trackPageView(window.location.pathname); - - // Track session start - trackSessionStart(); - - // Track navigation changes - let lastPath = window.location.pathname; - setInterval(() => { - const currentPath = window.location.pathname; - if (currentPath !== lastPath) { - trackPageView(currentPath); - lastPath = currentPath; - } - }, 500); - - // Track online/offline status - window.addEventListener('online', () => trackEvent('Go Online')); - window.addEventListener('offline', () => trackEvent('Go Offline')); - - // Track visibility changes (app focus/blur) - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - trackEvent('App Background'); - } else { - trackEvent('App Foreground'); - } - }); } diff --git a/js/app.js b/js/app.js index 311d15c..8293949 100644 --- a/js/app.js +++ b/js/app.js @@ -35,28 +35,7 @@ import { openEditProfile } from './profile.js'; import { ThemeStore } from './themeStore.js'; import './commandPalette.js'; import { initTracker } from './tracker.js'; -import { - initAnalytics, - trackSidebarNavigation, - trackCreatePlaylist, - trackCreateFolder, - trackImportJSPF, - trackImportCSV, - trackImportXSPF, - trackImportXML, - trackImportM3U, - trackSelectLocalFolder, - trackChangeLocalFolder, - trackOpenModal, - trackCloseModal, - trackKeyboardShortcut, - trackPwaUpdate, - trackDismissUpdate, - trackOpenFullscreenCover, - trackCloseFullscreenCover, - trackOpenLyrics, - trackCloseLyrics, -} from './analytics.js'; +import { initAnalytics } from './analytics.js'; import { parseCSV, parseJSPF, @@ -253,52 +232,40 @@ function initializeCasting(audioPlayer, castBtn) { function initializeKeyboardShortcuts(player, _audioPlayer) { const keyActionMap = { playPause: () => { - trackKeyboardShortcut('Space'); player.handlePlayPause(); }, seekForward: () => { - trackKeyboardShortcut('Right'); player.seekForward(10); }, seekBackward: () => { - trackKeyboardShortcut('Left'); player.seekBackward(10); }, nextTrack: () => { - trackKeyboardShortcut('Shift+Right'); player.playNext(); }, previousTrack: () => { - trackKeyboardShortcut('Shift+Left'); player.playPrev(); }, volumeUp: () => { - trackKeyboardShortcut('Up'); player.setVolume(player.userVolume + 0.1); }, volumeDown: () => { - trackKeyboardShortcut('Down'); player.setVolume(player.userVolume - 0.1); }, mute: () => { - trackKeyboardShortcut('M'); const el = player.activeElement; el.muted = !el.muted; }, shuffle: () => { - trackKeyboardShortcut('S'); document.getElementById('shuffle-btn')?.click(); }, repeat: () => { - trackKeyboardShortcut('R'); document.getElementById('repeat-btn')?.click(); }, queue: () => { - trackKeyboardShortcut('Q'); document.getElementById('queue-btn')?.click(); }, lyrics: () => { - trackKeyboardShortcut('L'); const overlay = document.getElementById('fullscreen-cover-overlay'); const isFullscreenOpen = overlay && getComputedStyle(overlay).display !== 'none'; @@ -309,29 +276,24 @@ function initializeKeyboardShortcuts(player, _audioPlayer) { document.getElementById('toggle-lyrics-btn')?.click(); }, search: () => { - trackKeyboardShortcut('/'); document.getElementById('search-input')?.focus(); }, escape: () => { - trackKeyboardShortcut('Escape'); document.getElementById('search-input')?.blur(); sidePanelManager.close(); clearLyricsPanelSync(player.activeElement, sidePanelManager.panel); }, visualizerNext: () => { - trackKeyboardShortcut('VisualizerNext'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].nextPreset(); } }, visualizerPrev: () => { - trackKeyboardShortcut('VisualizerPrev'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].prevPreset(); } }, visualizerCycle: () => { - trackKeyboardShortcut('VisualizerCycle'); if (UIRenderer.instance.visualizer?.presets?.['butterchurn']) { UIRenderer.instance.visualizer.presets['butterchurn'].toggleCycle(); } @@ -694,7 +656,6 @@ document.addEventListener('DOMContentLoaded', async () => { const href = link.getAttribute('href'); if (href && !href.startsWith('http')) { const item = link.querySelector('span')?.textContent || href; - trackSidebarNavigation(item); } }); }); @@ -729,18 +690,10 @@ document.addEventListener('DOMContentLoaded', async () => { if (mode === 'lyrics') { const isActive = sidePanelManager.isActive('lyrics'); - - if (isActive) { - trackCloseLyrics(Player.instance.currentTrack); - } else { - trackOpenLyrics(Player.instance.currentTrack); - } } else if (mode === 'cover') { const overlay = document.getElementById('fullscreen-cover-overlay'); if (overlay && overlay.style.display === 'flex') { - trackCloseFullscreenCover(); } else { - trackOpenFullscreenCover(Player.instance.currentTrack); } } @@ -781,7 +734,6 @@ document.addEventListener('DOMContentLoaded', async () => { }); document.getElementById('close-fullscreen-cover-btn')?.addEventListener('click', async () => { - trackCloseFullscreenCover(); await closeFullscreenOverlay(); }); @@ -1332,7 +1284,6 @@ document.addEventListener('DOMContentLoaded', async () => { } if (e.target.closest('#create-playlist-btn') || e.target.closest('#library-create-playlist-card')) { - trackOpenModal('Create Playlist'); const modal = document.getElementById('playlist-modal'); document.getElementById('playlist-modal-title').textContent = 'Create Playlist'; document.getElementById('playlist-name-input').value = ''; @@ -1386,7 +1337,6 @@ document.addEventListener('DOMContentLoaded', async () => { } if (e.target.closest('#create-folder-btn') || e.target.closest('#library-create-folder-card')) { - trackOpenModal('Create Folder'); const modal = document.getElementById('folder-modal'); document.getElementById('folder-name-input').value = ''; document.getElementById('folder-cover-input').value = ''; @@ -1400,11 +1350,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (name) { const folder = await db.createFolder(name, cover); - trackCreateFolder(folder); await syncManager.syncUserFolder(folder, 'create'); UIRenderer.instance.renderLibraryPage(); document.getElementById('folder-modal').classList.remove('active'); - trackCloseModal('Create Folder'); } else { showNotification('Please enter a folder name.'); } @@ -1593,7 +1541,6 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from YouTube`); - trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length); if (missingTracks.length > 0) { setTimeout(() => { @@ -1673,12 +1620,6 @@ document.addEventListener('DOMContentLoaded', async () => { jspfPlaylist?.creator || jspfPlaylist?.extension?.['https://musicbrainz.org/doc/jspf#playlist']?.creator || 'unknown'; - trackImportJSPF( - name || jspfPlaylist?.title || 'Untitled', - tracks.length, - missingTracks.length, - jspfCreator - ); if (missingTracks.length > 0) { setTimeout(() => { @@ -1792,8 +1733,6 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from CSV`); - trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length); - if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); @@ -1851,8 +1790,6 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from XSPF`); - trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length); - if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); @@ -1910,8 +1847,6 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from XML`); - trackImportXML(name || 'Untitled', tracks.length, missingTracks.length); - if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); @@ -1969,8 +1904,6 @@ document.addEventListener('DOMContentLoaded', async () => { } console.log(`Imported ${tracks.length} tracks from M3U`); - trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length); - if (missingTracks.length > 0) { setTimeout(() => { showMissingTracksNotification(missingTracks, name || 'Untitled'); @@ -2002,10 +1935,8 @@ document.addEventListener('DOMContentLoaded', async () => { // Update DB again with isPublic flag await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await syncManager.syncUserPlaylist(playlist, 'create'); - trackCreatePlaylist(playlist, importSource); UIRenderer.instance.renderLibraryPage(); modal.classList.remove('active'); - trackCloseModal('Create Playlist'); }); } } else { @@ -2507,9 +2438,6 @@ document.addEventListener('DOMContentLoaded', async () => { }); await db.saveSetting('local_folder_handle', handle); - if (isChange) { - trackChangeLocalFolder(); - } const btn = document.getElementById('select-local-folder-btn'); const btnText = document.getElementById('select-local-folder-text'); @@ -2520,7 +2448,6 @@ document.addEventListener('DOMContentLoaded', async () => { } const tracks = scanLocalMediaFolder(true); - trackSelectLocalFolder(tracks?.length ?? 0); UIRenderer.instance.renderLibraryPage(); } catch (err) { if (err.name !== 'AbortError') { @@ -2707,12 +2634,10 @@ document.addEventListener('DOMContentLoaded', async () => { onNeedRefresh() { if (pwaUpdateSettings.isAutoUpdateEnabled()) { // Auto-update: immediately activate the new service worker - trackPwaUpdate(); updateSW(true); } else { // Show notification with Update button and dismiss option showUpdateNotification(() => { - trackPwaUpdate(); updateSW(true); }); } @@ -2955,7 +2880,6 @@ function showUpdateNotification(updateCallback) { }); document.getElementById('dismiss-update-btn').addEventListener('click', () => { - trackDismissUpdate(); notification.remove(); }); } diff --git a/js/events.js b/js/events.js index cb9e5b7..d173fa3 100644 --- a/js/events.js +++ b/js/events.js @@ -23,37 +23,6 @@ import { syncManager } from './accounts/pocketbase.js'; import { waveformGenerator } from './waveform.js'; import { audioContextManager } from './audio-context.js'; import { hapticLongPress, hapticMedium, hapticLight } from './haptics.js'; -import { - trackPlayTrack, - trackPauseTrack, - trackSkipTrack, - trackToggleShuffle, - trackToggleRepeat, - trackAddToQueue, - trackPlayNext, - trackLikeTrack, - trackUnlikeTrack, - trackLikeAlbum, - trackUnlikeAlbum, - trackLikeArtist, - trackUnlikeArtist, - trackLikePlaylist, - trackUnlikePlaylist, - trackDownloadTrack, - trackContextMenuAction, - trackBlockTrack, - trackUnblockTrack, - trackBlockAlbum, - trackUnblockAlbum, - trackBlockArtist, - trackUnblockArtist, - trackCopyLink, - trackOpenInNewTab, - trackSetSleepTimer, - trackCancelSleepTimer, - trackStartMix, - trackEvent, -} from './analytics.js'; import { SVG_BIN, SVG_MUTE, SVG_PAUSE, SVG_PLAY, SVG_VOLUME, SVG_CHECKBOX, SVG_CHECKBOX_CHECKED } from './icons.js'; import { partyManager } from './listening-party.js'; import { MusicAPI } from './music-api.js'; @@ -435,9 +404,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { await audioContextManager.resume(); if (player.currentTrack) { - // Track play event - trackPlayTrack(player.currentTrack); - // Scrobble if (scrobbler.isAuthenticated()) { scrobbler.updateNowPlaying(player.currentTrack); @@ -460,9 +426,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { element.addEventListener('pause', () => { if (player.activeElement !== element) return; - if (player.currentTrack) { - trackPauseTrack(player.currentTrack); - } playPauseBtn.innerHTML = SVG_PLAY(20); player.updateMediaSessionPlaybackState(); player.updateMediaSessionPositionState(); @@ -566,19 +529,16 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { }); nextBtn.addEventListener('click', async () => { await hapticMedium(); - trackSkipTrack(player.currentTrack, 'next'); player.playNext(); }); prevBtn.addEventListener('click', async () => { await hapticMedium(); - trackSkipTrack(player.currentTrack, 'previous'); player.playPrev(); }); shuffleBtn.addEventListener('click', async () => { await hapticLight(); player.toggleShuffle(); - trackToggleShuffle(player.shuffleActive); shuffleBtn.classList.toggle('active', player.shuffleActive); if (window.renderQueueFunction) await window.renderQueueFunction(); }); @@ -586,7 +546,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { repeatBtn.addEventListener('click', async () => { await hapticLight(); const mode = await player.toggleRepeat(); - trackToggleRepeat(mode === REPEAT_MODE.OFF ? 'off' : mode === REPEAT_MODE.ALL ? 'all' : 'one'); repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF); repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE); repeatBtn.title = @@ -604,7 +563,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { sleepTimerBtnDesktop.addEventListener('click', () => { if (player.isSleepTimerActive()) { player.clearSleepTimer(); - trackCancelSleepTimer(); showNotification('Sleep timer cancelled'); } else { showSleepTimerModal(player); @@ -617,7 +575,6 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { sleepTimerBtnMobile.addEventListener('click', () => { if (player.isSleepTimerActive()) { player.clearSleepTimer(); - trackCancelSleepTimer(); showNotification('Sleep timer cancelled'); } else { showSleepTimerModal(player); @@ -1349,12 +1306,10 @@ export async function handleTrackAction( } if (action === 'add-to-queue') { - trackAddToQueue(item, 'end'); player.addToQueue(item); if (window.renderQueueFunction) await window.renderQueueFunction(); showNotification(`Added to queue: ${item.title}`); } else if (action === 'play-next') { - trackPlayNext(item); player.addNextToQueue(item); if (window.renderQueueFunction) await window.renderQueueFunction(); showNotification(`Playing next: ${item.title}`); @@ -1363,34 +1318,17 @@ export async function handleTrackAction( player.playAtIndex(0); showNotification(`Playing track: ${item.title}`); } else if (action === 'start-mix') { - trackStartMix(type, item); if (item.mixes?.TRACK_MIX) { navigate(`/mix/${item.mixes.TRACK_MIX}`); } else { showNotification('No mix available for this track'); } } else if (action === 'download') { - trackDownloadTrack(item, downloadQualitySettings.getQuality()); await downloadTrackWithMetadata(item, downloadQualitySettings.getQuality(), api, lyricsManager); } else if (action === 'toggle-like') { const added = await db.toggleFavorite(type, item); await syncManager.syncLibraryItem(type, item, added); - // Track like/unlike - if (added) { - if (type === 'track') trackLikeTrack(item); - else if (type === 'video') trackEvent('Like Video', { title: item.title }); - else if (type === 'album') trackLikeAlbum(item); - else if (type === 'artist') trackLikeArtist(item); - else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item); - } else { - if (type === 'track') trackUnlikeTrack(item); - else if (type === 'video') trackEvent('Unlike Video', { title: item.title }); - else if (type === 'album') trackUnlikeAlbum(item); - else if (type === 'artist') trackUnlikeArtist(item); - else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item); - } - if (added && type === 'track' && scrobbler) { if (lastFMStorage.isEnabled() && lastFMStorage.shouldLoveOnLike()) { scrobbler.loveTrack(item); @@ -1675,7 +1613,6 @@ export async function handleTrackAction( const typeForUrl = type === 'user-playlist' ? 'userplaylist' : type; const url = getShareUrl(storedHref ? storedHref : `/${typeForUrl}/${item.id || item.uuid}`); - trackCopyLink(type, item.id || item.uuid); await navigator.clipboard .writeText(url) .then(() => { @@ -1690,7 +1627,6 @@ export async function handleTrackAction( ? `${window.location.origin}${storedHref}` : `${window.location.origin}/${type}/${item.id || item.uuid}`; - trackOpenInNewTab(type, item.id || item.uuid); window.open(url, '_blank'); } else if (action === 'open-in-harmony') { const albumId = item.id; @@ -1868,11 +1804,9 @@ export async function handleTrackAction( const { contentBlockingSettings } = await import('./storage.js'); if (contentBlockingSettings.isTrackBlocked(item.id)) { contentBlockingSettings.unblockTrack(item.id); - trackUnblockTrack(item); showNotification(`Unblocked track: ${item.title}`); } else { contentBlockingSettings.blockTrack(item); - trackBlockTrack(item); showNotification(`Blocked track: ${item.title}`); } } else if (action === 'block-album') { @@ -1891,11 +1825,9 @@ export async function handleTrackAction( if (contentBlockingSettings.isAlbumBlocked(albumId)) { contentBlockingSettings.unblockAlbum(albumId); - trackUnblockAlbum(albumObj); showNotification(`Unblocked album: ${albumTitle || 'Unknown Album'}`); } else { contentBlockingSettings.blockAlbum(albumObj); - trackBlockAlbum(albumObj); showNotification(`Blocked album: ${albumTitle || 'Unknown Album'}`); } } else if (action === 'block-artist') { @@ -1912,11 +1844,9 @@ export async function handleTrackAction( if (contentBlockingSettings.isArtistBlocked(artistId)) { contentBlockingSettings.unblockArtist(artistId); - trackUnblockArtist(artistObj); showNotification(`Unblocked artist: ${artistName || 'Unknown Artist'}`); } else { contentBlockingSettings.blockArtist(artistObj); - trackBlockArtist(artistObj); showNotification(`Blocked artist: ${artistName || 'Unknown Artist'}`); } } @@ -2494,7 +2424,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen switch (action) { case 'play-next': selectedTracks.forEach((t) => { - trackPlayNext(t); player.addNextToQueue(t); }); if (window.renderQueueFunction) await window.renderQueueFunction(); @@ -2536,8 +2465,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen break; } } else { - // Track context menu action - trackContextMenuAction(action, type, track); await handleTrackAction(action, track, player, api, lyricsManager, type, ui, scrobbler, target.dataset); } } @@ -2700,7 +2627,6 @@ function showSleepTimerModal(player) { if (minutes) { player.setSleepTimer(minutes); - trackSetSleepTimer(minutes); showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`); closeModal(); } diff --git a/js/side-panel.js b/js/side-panel.js index 5da3b4a..23499e2 100644 --- a/js/side-panel.js +++ b/js/side-panel.js @@ -1,5 +1,3 @@ -import { trackCloseSidePanel, trackCloseQueue, trackCloseLyrics } from './analytics.js'; - export class SidePanelManager { constructor() { this.panel = document.getElementById('side-panel'); @@ -103,15 +101,9 @@ export class SidePanelManager { close() { // Track side panel close if (this.currentView) { - trackCloseSidePanel(); - if (this.currentView === 'queue') { - trackCloseQueue(); - } else if (this.currentView === 'lyrics') { + if (this.currentView === 'lyrics') { // Get current track from audio player context const audioPlayer = document.getElementById('audio-player'); - if (audioPlayer && audioPlayer._currentTrack) { - trackCloseLyrics(audioPlayer._currentTrack); - } } } diff --git a/js/ui-interactions.js b/js/ui-interactions.js index 0a66529..5c93f6d 100644 --- a/js/ui-interactions.js +++ b/js/ui-interactions.js @@ -12,7 +12,6 @@ import { downloadQualitySettings, contentBlockingSettings } from './storage.js'; import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; import { showNotification, downloadTracks } from './downloads.js'; -import { trackSearchTabChange, trackOpenQueue } from './analytics.js'; import { SVG_CLOSE, SVG_BIN, @@ -474,7 +473,6 @@ export function initializeUIInteractions(player, api, ui) { }; const openQueuePanel = () => { - trackOpenQueue(); sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); setTimeout(() => { @@ -536,9 +534,6 @@ export function initializeUIInteractions(player, api, ui) { const page = tab.closest('.page'); if (!page) return; - // Track tab change - trackSearchTabChange(tab.dataset.tab); - page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active')); page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active')); diff --git a/js/ui.js b/js/ui.js index 8f42aa1..f29712a 100644 --- a/js/ui.js +++ b/js/ui.js @@ -49,7 +49,6 @@ import { createProjectCardHTML, createTrackFromSong, } from './tracker.js'; -import { trackSearch, trackChangeSort } from './analytics.js'; fontSettings.applyFont().catch(console.error); fontSettings.applyFontSize(); @@ -3608,7 +3607,6 @@ export class UIRenderer { // Track search with results const totalResults = finalTracks.length + finalArtists.length + finalAlbums.length + finalPlaylists.length; - trackSearch(query, totalResults); if (finalTracks.length) { await this.renderListWithTracks(tracksContainer, finalTracks, true, false, false, true); @@ -5674,7 +5672,6 @@ export class UIRenderer { const handleSort = async (ev) => { const li = ev.target.closest('li'); if (li && li.dataset.sort) { - trackChangeSort(li.dataset.sort); await onSort(li.dataset.sort); closeMenu(); } From d35c15370e079c18fac526788b1bb6e76e0b5991 Mon Sep 17 00:00:00 2001 From: binimum Date: Wed, 8 Apr 2026 22:19:49 +0100 Subject: [PATCH 43/76] fix: temporarily disable video artwork at maintainer's request --- js/music-api.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/music-api.js b/js/music-api.js index 1590452..e5b2570 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -247,6 +247,10 @@ export class MusicAPI { } try { + /* + Maintainer of artwork.boidu.dev has asked for his API to be removed for the time being due to spam + */ + /* const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`; const response = await fetch(url); if (!response.ok) return null; @@ -257,6 +261,8 @@ export class MusicAPI { }; this.videoArtworkCache.set(cacheKey, result); return result; + */ + throw new Error("Video artwork is disabled for now."); } catch (error) { console.warn('Failed to fetch video artwork:', error); return null; From 4394cce61dfa7b284a2868b49bb420c09f1124e3 Mon Sep 17 00:00:00 2001 From: binimum <61615730+binimum@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:20:29 +0000 Subject: [PATCH 44/76] style: auto-fix linting issues --- js/music-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/music-api.js b/js/music-api.js index e5b2570..fe7384b 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -262,7 +262,7 @@ export class MusicAPI { this.videoArtworkCache.set(cacheKey, result); return result; */ - throw new Error("Video artwork is disabled for now."); + throw new Error('Video artwork is disabled for now.'); } catch (error) { console.warn('Failed to fetch video artwork:', error); return null; From 493ac9f175820d28056bbf62a5ef1c046825f3bb Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 9 Apr 2026 01:38:52 +0300 Subject: [PATCH 45/76] mobile contribs --- styles.css | 66 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/styles.css b/styles.css index 7881de7..91d1dc6 100644 --- a/styles.css +++ b/styles.css @@ -10083,30 +10083,80 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .about-contributors { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; + justify-content: center; } .about-contributors div { - width: calc(20% - 8px); + width: calc(20% - 10px); + min-width: 150px; border: 1px solid var(--border); border-radius: 14px; - padding: 30px; + padding: 20px; text-align: center; overflow: hidden; + background: var(--card); + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.about-contributors div:hover { + border-color: var(--primary); + transform: translateY(-2px); +} + +.about-contributors a { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-decoration: none; + color: inherit; } .about-contributors img { display: block; - margin: 0 auto 8px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; } .about-contributors span { - color: var(--muted-foreground); - text-align: center; + color: var(--foreground); + font-weight: 500; + font-size: 14px; + line-height: 1.2; + display: block; + word-break: break-word; } -.contrib { - font-size: 10px; +.about-contributors .contrib { + color: var(--muted-foreground); + font-size: 12px; + font-weight: 400; +} + +@media (max-width: 768px) { + .about-contributors div { + width: calc(33.33% - 12px); + padding: 15px; + } +} + +@media (max-width: 480px) { + .about-contributors { + gap: 8px; + } + .about-contributors div { + width: calc(50% - 8px); + min-width: 0; + padding: 12px; + } + .about-contributors span { + font-size: 13px; + } + .about-contributors .contrib { + font-size: 11px; + } } /* Fullscreen layout rebuild on PR 378 base */ From f65af3fb10ef1fd94aac752fad3e36bb583613b5 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:39:36 +0000 Subject: [PATCH 46/76] style: auto-fix linting issues --- styles.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/styles.css b/styles.css index 91d1dc6..72a338e 100644 --- a/styles.css +++ b/styles.css @@ -10096,7 +10096,9 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { text-align: center; overflow: hidden; background: var(--card); - transition: transform 0.2s ease, border-color 0.2s ease; + transition: + transform 0.2s ease, + border-color 0.2s ease; } .about-contributors div:hover { @@ -10146,14 +10148,17 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .about-contributors { gap: 8px; } + .about-contributors div { width: calc(50% - 8px); min-width: 0; padding: 12px; } + .about-contributors span { font-size: 13px; } + .about-contributors .contrib { font-size: 11px; } From c6a967dc3aef12721fbc672534017293255f2f3d Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:29:45 +0100 Subject: [PATCH 47/76] deps: bump am-lyrics --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b60e3f3..2e03d4e 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.7", + "@uimaxbai/am-lyrics": "^1.1.8", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", From ae9446cbd1a0f91a8560130a59d25e022649052a Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 03:41:22 +0000 Subject: [PATCH 48/76] Add import/export for legacy graphic EQ Export outputs EqualizerAPO-compatible format with all 16 bands. Import accepts EqualizerAPO format or simple freq/gain pairs, maps to nearest GEQ band by log-frequency distance, and ignores Q values since legacy mode uses fixed dB sliders. https://claude.ai/code/session_01AgSx7SP1dH5KFmpGXCAUvU --- index.html | 20 +++++++++++ js/settings.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/index.html b/index.html index 6d91859..5f304dd 100644 --- a/index.html +++ b/index.html @@ -4352,6 +4352,26 @@ + + +
diff --git a/js/settings.js b/js/settings.js index ac17f46..d2b7f62 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1383,6 +1383,102 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); }); + // Legacy EQ Import / Export + const GEQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000]; + const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn'); + const legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn'); + const legacyGeqImportFile = document.getElementById('legacy-geq-import-file'); + + if (legacyGeqExportBtn) { + legacyGeqExportBtn.addEventListener('click', () => { + const lines = [`Preamp: ${geqPreamp.toFixed(1)} dB`]; + GEQ_FREQUENCIES.forEach((freq, i) => { + lines.push(`Filter ${i + 1}: ON PK Fc ${freq} Hz Gain ${geqGains[i].toFixed(1)} dB Q 1.41`); + }); + const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'legacy-eq.txt'; + a.click(); + URL.revokeObjectURL(url); + }); + } + + if (legacyGeqImportBtn && legacyGeqImportFile) { + legacyGeqImportBtn.addEventListener('click', () => legacyGeqImportFile.click()); + legacyGeqImportFile.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + try { + const text = event.target.result; + const lines = text.split('\n'); + let preamp = 0; + const importedPoints = []; + + for (const line of lines) { + const preampMatch = line.match(/Preamp:\s*([-\d.]+)\s*dB/i); + if (preampMatch) { + preamp = parseFloat(preampMatch[1]); + continue; + } + // EqualizerAPO format: Filter N: ON PK Fc XXXX Hz Gain X.X dB Q X.XX + const filterMatch = line.match( + /Filter\s+\d+:\s*ON\s+\w+\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB/i + ); + if (filterMatch) { + importedPoints.push({ freq: parseFloat(filterMatch[1]), gain: parseFloat(filterMatch[2]) }); + continue; + } + // Simple two-column format: freq gain (whitespace/tab/comma separated) + const simpleMatch = line.trim().match(/^([\d.]+)[,\s\t]+([-\d.]+)/); + if (simpleMatch) { + importedPoints.push({ freq: parseFloat(simpleMatch[1]), gain: parseFloat(simpleMatch[2]) }); + } + } + + if (importedPoints.length === 0) return; + + // Sort by frequency + importedPoints.sort((a, b) => a.freq - b.freq); + + // Map imported points to the 16 GEQ bands using nearest-frequency matching + const newGains = GEQ_FREQUENCIES.map((targetFreq) => { + // Find the closest imported point by log-frequency distance + let closest = importedPoints[0]; + let minDist = Math.abs(Math.log10(targetFreq) - Math.log10(closest.freq)); + for (let j = 1; j < importedPoints.length; j++) { + const dist = Math.abs(Math.log10(targetFreq) - Math.log10(importedPoints[j].freq)); + if (dist < minDist) { + minDist = dist; + closest = importedPoints[j]; + } + } + // Clamp to slider range + return Math.max(parseFloat(geqRange.min), Math.min(parseFloat(geqRange.max), closest.gain)); + }); + + geqGains = newGains; + geqPreamp = Math.max(-20, Math.min(20, preamp)); + equalizerSettings.setGraphicEqGains(geqGains); + equalizerSettings.setGraphicEqPreamp(geqPreamp); + audioContextManager.setGraphicEqAllGains(geqGains); + audioContextManager.setGraphicEqPreamp(geqPreamp); + geqSyncAllSliders(); + geqPreampSliders.forEach((s) => (s.value = geqPreamp)); + geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + geqPresetSelects.forEach((s) => (s.value = '')); + } catch (err) { + console.error('[Legacy GEQ Import] Failed:', err); + } + }; + reader.readAsText(file); + e.target.value = ''; + }); + } + // ======================================== // Precision AutoEQ - Redesigned Equalizer // ======================================== From 79a5e8cf71bb4183e996883319e2ab3af3d13b8a Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 03:48:49 +0000 Subject: [PATCH 49/76] Add save/delete custom presets for legacy graphic EQ Save button prompts for a name and stores the current 16-band gains as a custom preset in localStorage. Custom presets appear in an optgroup at the bottom of the preset dropdown. Delete button shows when a custom preset is selected. https://claude.ai/code/session_01AgSx7SP1dH5KFmpGXCAUvU --- index.html | 15 +++++++ js/settings.js | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/index.html b/index.html index 5f304dd..db93876 100644 --- a/index.html +++ b/index.html @@ -4323,6 +4323,21 @@ + +
diff --git a/js/settings.js b/js/settings.js index d2b7f62..7bea229 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1479,6 +1479,117 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); } + // Legacy EQ Custom Presets (Save / Delete) + const LEGACY_GEQ_CUSTOM_PRESETS_KEY = 'legacy-geq-custom-presets'; + const legacyGeqSavePresetBtn = document.getElementById('legacy-geq-save-preset-btn'); + const legacyGeqDeletePresetBtn = document.getElementById('legacy-geq-delete-preset-btn'); + + const getLegacyGeqCustomPresets = () => { + try { + const stored = localStorage.getItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } + }; + + const saveLegacyGeqCustomPresets = (presets) => { + localStorage.setItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); + }; + + /** Rebuild custom preset options in all legacy GEQ preset dropdowns */ + const refreshLegacyGeqCustomPresetOptions = () => { + const presets = getLegacyGeqCustomPresets(); + geqPresetSelects.forEach((select) => { + // Remove existing custom options + select.querySelectorAll('option[data-custom]').forEach((opt) => opt.remove()); + // Remove existing separator + select.querySelectorAll('optgroup[data-custom-group]').forEach((g) => g.remove()); + + const entries = Object.entries(presets); + if (entries.length === 0) return; + + const group = document.createElement('optgroup'); + group.label = 'Custom Presets'; + group.setAttribute('data-custom-group', ''); + entries.forEach(([id, preset]) => { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = preset.name; + opt.setAttribute('data-custom', ''); + group.appendChild(opt); + }); + select.appendChild(group); + }); + }; + + // Populate custom presets on load + refreshLegacyGeqCustomPresetOptions(); + + /** Show/hide delete button based on whether a custom preset is selected */ + const updateDeleteBtnVisibility = () => { + const val = legacyGeqPresetSelect?.value || ''; + const isCustom = val.startsWith('geq_custom_'); + if (legacyGeqDeletePresetBtn) { + legacyGeqDeletePresetBtn.style.display = isCustom ? '' : 'none'; + } + }; + + // Update the preset change handler to also handle custom presets + geqPresetSelects.forEach((select) => { + select.addEventListener('change', () => { + updateDeleteBtnVisibility(); + const key = select.value; + if (!key) return; + + // Check custom presets first + const customPresets = getLegacyGeqCustomPresets(); + if (customPresets[key]) { + geqGains = [...customPresets[key].gains]; + equalizerSettings.setGraphicEqGains(geqGains); + audioContextManager.setGraphicEqAllGains(geqGains); + geqSyncAllSliders(); + geqPresetSelects.forEach((s) => { + if (s !== select) s.value = key; + }); + return; + } + }); + }); + + if (legacyGeqSavePresetBtn) { + legacyGeqSavePresetBtn.addEventListener('click', () => { + const name = prompt('Preset name:'); + if (!name || !name.trim()) return; + const sanitized = name.trim().substring(0, 50); + const presets = getLegacyGeqCustomPresets(); + const id = 'geq_custom_' + Date.now(); + presets[id] = { + name: sanitized, + gains: geqGains.map((g) => Math.round(g * 10) / 10), + }; + saveLegacyGeqCustomPresets(presets); + refreshLegacyGeqCustomPresetOptions(); + geqPresetSelects.forEach((s) => (s.value = id)); + updateDeleteBtnVisibility(); + }); + } + + if (legacyGeqDeletePresetBtn) { + legacyGeqDeletePresetBtn.addEventListener('click', () => { + const selected = legacyGeqPresetSelect?.value || ''; + if (!selected.startsWith('geq_custom_')) return; + const presets = getLegacyGeqCustomPresets(); + const presetName = presets[selected]?.name || selected; + if (!confirm(`Delete preset "${presetName}"?`)) return; + delete presets[selected]; + saveLegacyGeqCustomPresets(presets); + refreshLegacyGeqCustomPresetOptions(); + geqPresetSelects.forEach((s) => (s.value = '')); + updateDeleteBtnVisibility(); + }); + } + // ======================================== // Precision AutoEQ - Redesigned Equalizer // ======================================== From 1a6214f3ea92244f0670c80d2626e0625a37d43d Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 15:37:48 +0000 Subject: [PATCH 50/76] fix: restore legacy 16-band graphic EQ CSS removed by PR #509 The visualizer/mobile support PR inadvertently deleted the entire legacy graphic EQ stylesheet block. This restores vertical slider bands, preamp, preset row, and mobile responsive styles. --- styles.css | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/styles.css b/styles.css index 72a338e..baa5e44 100644 --- a/styles.css +++ b/styles.css @@ -7893,6 +7893,148 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { transform: scale(0.97); } +/* ======================================== + 16-Band Graphic Equalizer (Legacy EQ) + ======================================== */ +.graphic-eq-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.graphic-eq-preset-row { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.graphic-eq-preset-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--foreground); + white-space: nowrap; +} + +.graphic-eq-preset-select { + flex: 1; + padding: 8px 12px; + background: var(--input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--foreground); + font-size: 0.85rem; +} + +.graphic-eq-bands { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 2px; + padding: var(--spacing-md) var(--spacing-sm); + background: rgb(0, 0, 0, 0.15); + border-radius: var(--radius); + min-height: 240px; +} + +.graphic-eq-band { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.graphic-eq-band-value { + font-size: 0.65rem; + color: var(--foreground); + font-variant-numeric: tabular-nums; + white-space: nowrap; + min-height: 14px; + opacity: 0.7; +} + +.graphic-eq-band-slider-wrap { + position: relative; + height: 160px; + width: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.graphic-eq-band-slider-wrap input[type='range'] { + writing-mode: vertical-lr; + direction: rtl; + width: 28px; + height: 100%; + accent-color: var(--foreground); + cursor: pointer; + margin: 0; + padding: 0; +} + +.graphic-eq-band-label { + font-size: 0.6rem; + color: var(--muted-foreground); + white-space: nowrap; + text-align: center; + letter-spacing: -0.02em; +} + +.graphic-eq-bottom-row { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.graphic-eq-preamp { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex: 1; +} + +.graphic-eq-preamp-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-foreground); + white-space: nowrap; +} + +.graphic-eq-preamp-slider { + flex: 1; + height: 4px; + accent-color: var(--highlight); +} + +.graphic-eq-preamp-value { + font-size: 0.75rem; + color: var(--muted-foreground); + min-width: 45px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 600px) { + .graphic-eq-bands { + min-height: 180px; + } + + .graphic-eq-band-slider-wrap { + height: 130px; + width: 22px; + } + + .graphic-eq-band-label { + font-size: 0.5rem; + } + + .graphic-eq-band-value { + font-size: 0.5rem; + } +} + /* ======================================== Precision AutoEQ - Redesigned Equalizer ======================================== */ From 452b810efd2cf2b974bd46cd4b4b45111dd28da6 Mon Sep 17 00:00:00 2001 From: tryptz <216453278+tryptz@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:17:36 +0000 Subject: [PATCH 51/76] fix: apply PR review comments - derive GEQ_FREQUENCIES, validate import, fix delete btn visibility, store preamp in presets --- js/settings.js | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/js/settings.js b/js/settings.js index 7bea229..ced2eae 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1384,7 +1384,14 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); // Legacy EQ Import / Export - const GEQ_FREQUENCIES = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000, 20000]; + const parseGeqLabelFrequency = (label) => { + const normalized = String(label).trim().toLowerCase().replace(/hz$/, '').trim(); + if (normalized.endsWith('k')) { + return Number.parseFloat(normalized.slice(0, -1)) * 1000; + } + return Number.parseFloat(normalized); + }; + const GEQ_FREQUENCIES = GEQ_LABELS.map((label) => parseGeqLabelFrequency(label)); const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn'); const legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn'); const legacyGeqImportFile = document.getElementById('legacy-geq-import-file'); @@ -1441,19 +1448,25 @@ export async function initializeSettings(scrobbler, player, api, ui) { if (importedPoints.length === 0) return; + // Filter out invalid frequencies (0, negative, NaN, Infinity) + const validPoints = importedPoints.filter( + (p) => Number.isFinite(p.freq) && p.freq > 0 && Number.isFinite(p.gain) + ); + if (validPoints.length === 0) return; + // Sort by frequency - importedPoints.sort((a, b) => a.freq - b.freq); + validPoints.sort((a, b) => a.freq - b.freq); // Map imported points to the 16 GEQ bands using nearest-frequency matching const newGains = GEQ_FREQUENCIES.map((targetFreq) => { // Find the closest imported point by log-frequency distance - let closest = importedPoints[0]; + let closest = validPoints[0]; let minDist = Math.abs(Math.log10(targetFreq) - Math.log10(closest.freq)); - for (let j = 1; j < importedPoints.length; j++) { - const dist = Math.abs(Math.log10(targetFreq) - Math.log10(importedPoints[j].freq)); + for (let j = 1; j < validPoints.length; j++) { + const dist = Math.abs(Math.log10(targetFreq) - Math.log10(validPoints[j].freq)); if (dist < minDist) { minDist = dist; - closest = importedPoints[j]; + closest = validPoints[j]; } } // Clamp to slider range @@ -1469,7 +1482,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { geqSyncAllSliders(); geqPreampSliders.forEach((s) => (s.value = geqPreamp)); geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); - geqPresetSelects.forEach((s) => (s.value = '')); + geqPresetSelects.forEach((s) => { + s.value = ''; + s.dispatchEvent(new Event('change')); + }); } catch (err) { console.error('[Legacy GEQ Import] Failed:', err); } @@ -1538,9 +1554,11 @@ export async function initializeSettings(scrobbler, player, api, ui) { // Update the preset change handler to also handle custom presets geqPresetSelects.forEach((select) => { select.addEventListener('change', () => { - updateDeleteBtnVisibility(); const key = select.value; - if (!key) return; + if (!key) { + updateDeleteBtnVisibility(); + return; + } // Check custom presets first const customPresets = getLegacyGeqCustomPresets(); @@ -1549,9 +1567,17 @@ export async function initializeSettings(scrobbler, player, api, ui) { equalizerSettings.setGraphicEqGains(geqGains); audioContextManager.setGraphicEqAllGains(geqGains); geqSyncAllSliders(); + if (customPresets[key].preamp !== undefined) { + geqPreamp = customPresets[key].preamp; + equalizerSettings.setGraphicEqPreamp(geqPreamp); + audioContextManager.setGraphicEqPreamp(geqPreamp); + geqPreampSliders.forEach((s) => (s.value = geqPreamp)); + geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + } geqPresetSelects.forEach((s) => { if (s !== select) s.value = key; }); + updateDeleteBtnVisibility(); return; } }); @@ -1567,6 +1593,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { presets[id] = { name: sanitized, gains: geqGains.map((g) => Math.round(g * 10) / 10), + preamp: Math.round(geqPreamp * 10) / 10, }; saveLegacyGeqCustomPresets(presets); refreshLegacyGeqCustomPresetOptions(); From cb49904a211a893faa6dd7e12aeb816a1a08b656 Mon Sep 17 00:00:00 2001 From: tryptz <216453278+tryptz@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:52:07 +0000 Subject: [PATCH 52/76] fix: address remaining PR #519 review comments - Preserve existing preamp when importing files without Preamp line - Extend import regex to accept k/kHz/Hz suffixes and +signed gains - Rename localStorage key to monochrome- prefix for backup/restore compat - Add try/catch to localStorage writes for quota/disabled storage - Validate custom preset gains array before applying - Fix delete button visibility for all preset change paths - Add overflow-x: auto for mobile EQ band scrolling - Add flex-wrap and responsive sizing for bottom controls on mobile --- js/settings.js | 101 ++++++++++++++++++++++++++++++++----------------- styles.css | 12 ++++++ 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/js/settings.js b/js/settings.js index ced2eae..fce32fc 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1422,27 +1422,35 @@ export async function initializeSettings(scrobbler, player, api, ui) { try { const text = event.target.result; const lines = text.split('\n'); - let preamp = 0; + let preamp = geqPreamp; + let hasPreamp = false; const importedPoints = []; for (const line of lines) { const preampMatch = line.match(/Preamp:\s*([-\d.]+)\s*dB/i); if (preampMatch) { preamp = parseFloat(preampMatch[1]); + hasPreamp = true; continue; } // EqualizerAPO format: Filter N: ON PK Fc XXXX Hz Gain X.X dB Q X.XX const filterMatch = line.match( - /Filter\s+\d+:\s*ON\s+\w+\s+Fc\s+([\d.]+)\s*Hz\s+Gain\s+([-\d.]+)\s*dB/i + /Filter\s+\d+:\s*ON\s+\w+\s+Fc\s+([\d.]+[kK]?)\s*(?:Hz)?\s+Gain\s+([+-]?[\d.]+)\s*dB/i ); if (filterMatch) { - importedPoints.push({ freq: parseFloat(filterMatch[1]), gain: parseFloat(filterMatch[2]) }); + importedPoints.push({ + freq: parseGeqLabelFrequency(filterMatch[1]), + gain: parseFloat(filterMatch[2]), + }); continue; } // Simple two-column format: freq gain (whitespace/tab/comma separated) - const simpleMatch = line.trim().match(/^([\d.]+)[,\s\t]+([-\d.]+)/); + const simpleMatch = line.trim().match(/^([\d.]+[kK]?)\s*(?:Hz|kHz)?\s*[,\s\t]+([+-]?[\d.]+)/); if (simpleMatch) { - importedPoints.push({ freq: parseFloat(simpleMatch[1]), gain: parseFloat(simpleMatch[2]) }); + importedPoints.push({ + freq: parseGeqLabelFrequency(simpleMatch[1]), + gain: parseFloat(simpleMatch[2]), + }); } } @@ -1474,14 +1482,16 @@ export async function initializeSettings(scrobbler, player, api, ui) { }); geqGains = newGains; - geqPreamp = Math.max(-20, Math.min(20, preamp)); equalizerSettings.setGraphicEqGains(geqGains); - equalizerSettings.setGraphicEqPreamp(geqPreamp); audioContextManager.setGraphicEqAllGains(geqGains); - audioContextManager.setGraphicEqPreamp(geqPreamp); geqSyncAllSliders(); - geqPreampSliders.forEach((s) => (s.value = geqPreamp)); - geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + if (hasPreamp) { + geqPreamp = Math.max(-20, Math.min(20, preamp)); + equalizerSettings.setGraphicEqPreamp(geqPreamp); + audioContextManager.setGraphicEqPreamp(geqPreamp); + geqPreampSliders.forEach((s) => (s.value = geqPreamp)); + geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + } geqPresetSelects.forEach((s) => { s.value = ''; s.dispatchEvent(new Event('change')); @@ -1496,7 +1506,17 @@ export async function initializeSettings(scrobbler, player, api, ui) { } // Legacy EQ Custom Presets (Save / Delete) - const LEGACY_GEQ_CUSTOM_PRESETS_KEY = 'legacy-geq-custom-presets'; + const LEGACY_GEQ_CUSTOM_PRESETS_KEY = 'monochrome-legacy-geq-custom-presets'; + // Migrate from old key if present + try { + const oldData = localStorage.getItem('legacy-geq-custom-presets'); + if (oldData && !localStorage.getItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY)) { + localStorage.setItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY, oldData); + localStorage.removeItem('legacy-geq-custom-presets'); + } + } catch { + /* ignore */ + } const legacyGeqSavePresetBtn = document.getElementById('legacy-geq-save-preset-btn'); const legacyGeqDeletePresetBtn = document.getElementById('legacy-geq-delete-preset-btn'); @@ -1510,7 +1530,12 @@ export async function initializeSettings(scrobbler, player, api, ui) { }; const saveLegacyGeqCustomPresets = (presets) => { - localStorage.setItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); + try { + localStorage.setItem(LEGACY_GEQ_CUSTOM_PRESETS_KEY, JSON.stringify(presets)); + } catch (e) { + console.error('[Legacy GEQ] Failed to save presets:', e); + alert('Failed to save preset. Storage may be full.'); + } }; /** Rebuild custom preset options in all legacy GEQ preset dropdowns */ @@ -1555,31 +1580,37 @@ export async function initializeSettings(scrobbler, player, api, ui) { geqPresetSelects.forEach((select) => { select.addEventListener('change', () => { const key = select.value; - if (!key) { - updateDeleteBtnVisibility(); - return; - } - - // Check custom presets first - const customPresets = getLegacyGeqCustomPresets(); - if (customPresets[key]) { - geqGains = [...customPresets[key].gains]; - equalizerSettings.setGraphicEqGains(geqGains); - audioContextManager.setGraphicEqAllGains(geqGains); - geqSyncAllSliders(); - if (customPresets[key].preamp !== undefined) { - geqPreamp = customPresets[key].preamp; - equalizerSettings.setGraphicEqPreamp(geqPreamp); - audioContextManager.setGraphicEqPreamp(geqPreamp); - geqPreampSliders.forEach((s) => (s.value = geqPreamp)); - geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + if (key) { + // Check custom presets first + const customPresets = getLegacyGeqCustomPresets(); + if (customPresets[key]) { + const gains = customPresets[key]?.gains; + if (!Array.isArray(gains) || gains.length !== GEQ_FREQUENCIES.length) { + updateDeleteBtnVisibility(); + return; + } + geqGains = gains.map((g) => { + const n = Number(g); + return Number.isFinite(n) + ? Math.max(parseFloat(geqRange.min), Math.min(parseFloat(geqRange.max), n)) + : 0; + }); + equalizerSettings.setGraphicEqGains(geqGains); + audioContextManager.setGraphicEqAllGains(geqGains); + geqSyncAllSliders(); + if (customPresets[key].preamp !== undefined) { + geqPreamp = customPresets[key].preamp; + equalizerSettings.setGraphicEqPreamp(geqPreamp); + audioContextManager.setGraphicEqPreamp(geqPreamp); + geqPreampSliders.forEach((s) => (s.value = geqPreamp)); + geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + } + geqPresetSelects.forEach((s) => { + if (s !== select) s.value = key; + }); } - geqPresetSelects.forEach((s) => { - if (s !== select) s.value = key; - }); - updateDeleteBtnVisibility(); - return; } + updateDeleteBtnVisibility(); }); }); diff --git a/styles.css b/styles.css index baa5e44..b3d3241 100644 --- a/styles.css +++ b/styles.css @@ -7934,6 +7934,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { background: rgb(0, 0, 0, 0.15); border-radius: var(--radius); min-height: 240px; + overflow-x: auto; + overscroll-behavior-x: contain; } .graphic-eq-band { @@ -7986,6 +7988,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { display: flex; align-items: center; gap: var(--spacing-md); + flex-wrap: wrap; } .graphic-eq-preamp { @@ -8033,6 +8036,15 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .graphic-eq-band-value { font-size: 0.5rem; } + + .graphic-eq-bottom-row { + gap: var(--spacing-sm); + } + + .graphic-eq-preamp { + min-width: 0; + flex-basis: 100%; + } } /* ======================================== From 31b6af317e4ac41a1ed0fcc33268b12c4c6b047a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:17:36 +0000 Subject: [PATCH 53/76] fix: apply PR review comments - derive GEQ_FREQUENCIES, validate import, fix delete btn visibility, store preamp in presets Agent-Logs-Url: https://github.com/tryptz/monochrome-autoeq/sessions/6b47309f-1fb1-479d-91be-c0a54aa94b84 Co-authored-by: tryptz <216453278+tryptz@users.noreply.github.com> --- js/settings.js | 61 +++++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/js/settings.js b/js/settings.js index fce32fc..670ead9 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1580,35 +1580,40 @@ export async function initializeSettings(scrobbler, player, api, ui) { geqPresetSelects.forEach((select) => { select.addEventListener('change', () => { const key = select.value; - if (key) { - // Check custom presets first - const customPresets = getLegacyGeqCustomPresets(); - if (customPresets[key]) { - const gains = customPresets[key]?.gains; - if (!Array.isArray(gains) || gains.length !== GEQ_FREQUENCIES.length) { - updateDeleteBtnVisibility(); - return; - } - geqGains = gains.map((g) => { - const n = Number(g); - return Number.isFinite(n) - ? Math.max(parseFloat(geqRange.min), Math.min(parseFloat(geqRange.max), n)) - : 0; - }); - equalizerSettings.setGraphicEqGains(geqGains); - audioContextManager.setGraphicEqAllGains(geqGains); - geqSyncAllSliders(); - if (customPresets[key].preamp !== undefined) { - geqPreamp = customPresets[key].preamp; - equalizerSettings.setGraphicEqPreamp(geqPreamp); - audioContextManager.setGraphicEqPreamp(geqPreamp); - geqPreampSliders.forEach((s) => (s.value = geqPreamp)); - geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); - } - geqPresetSelects.forEach((s) => { - if (s !== select) s.value = key; - }); + if (!key) { + updateDeleteBtnVisibility(); + return; + } + + // Check custom presets first + const customPresets = getLegacyGeqCustomPresets(); + if (customPresets[key]) { + const gains = customPresets[key]?.gains; + if (!Array.isArray(gains) || gains.length !== GEQ_FREQUENCIES.length) { + updateDeleteBtnVisibility(); + return; } + geqGains = gains.map((g) => { + const n = Number(g); + return Number.isFinite(n) + ? Math.max(parseFloat(geqRange.min), Math.min(parseFloat(geqRange.max), n)) + : 0; + }); + equalizerSettings.setGraphicEqGains(geqGains); + audioContextManager.setGraphicEqAllGains(geqGains); + geqSyncAllSliders(); + if (customPresets[key].preamp !== undefined) { + geqPreamp = customPresets[key].preamp; + equalizerSettings.setGraphicEqPreamp(geqPreamp); + audioContextManager.setGraphicEqPreamp(geqPreamp); + geqPreampSliders.forEach((s) => (s.value = geqPreamp)); + geqPreampValues.forEach((v) => (v.textContent = `${geqPreamp.toFixed(1)} dB`)); + } + geqPresetSelects.forEach((s) => { + if (s !== select) s.value = key; + }); + updateDeleteBtnVisibility(); + return; } updateDeleteBtnVisibility(); }); From eae8655877b724e27ed2e262d4d7809d5ba6a525 Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 22:41:49 -0400 Subject: [PATCH 54/76] feat: add APO export for legacy EQ and fix shelf filter Q support - Add Export APO button to legacy graphic EQ (GraphicEQ config line format) - Fix shelf filters ignoring Q: use IIR filters with RBJ cookbook coefficients - Update graph visualization to use actual Q for shelf curves - Omit Q from shelf filters in all EQ text exports --- index.html | 7 +++ js/audio-context.js | 122 +++++++++++++++++++++++++++++++++++++------- js/autoeq-engine.js | 3 +- js/equalizer.js | 12 ++++- js/settings.js | 35 ++++++++++--- 5 files changed, 149 insertions(+), 30 deletions(-) diff --git a/index.html b/index.html index db93876..aeeae68 100644 --- a/index.html +++ b/index.html @@ -4381,6 +4381,13 @@ > Export + { + const type = (this.currentTypes && this.currentTypes[index]) || 'peaking'; + const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); + const gain = this.currentGains[index] || 0; + + if (type === 'lowshelf' || type === 'highshelf') { + const coeffs = computeShelfCoefficients(type, freq, gain, q, this.audioContext.sampleRate); + const iir = this.audioContext.createIIRFilter(coeffs.feedforward, coeffs.feedback); + iir._shelfType = type; + return iir; + } + const filter = this.audioContext.createBiquadFilter(); - filter.type = (this.currentTypes && this.currentTypes[index]) || 'peaking'; + filter.type = type; filter.frequency.value = freq; - filter.Q.value = - this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); - filter.gain.value = this.currentGains[index] || 0; + filter.Q.value = q; + filter.gain.value = gain; return filter; }); @@ -843,12 +888,41 @@ class AudioContextManager { // If filter count matches, update params in-place (no graph rebuild) if (this.filters.length === count) { const now = this.audioContext.currentTime; + let needsReconnect = false; this.filters.forEach((filter, i) => { - filter.type = newTypes[i] || 'peaking'; - filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005); - filter.gain.setTargetAtTime(newGains[i], now, 0.005); - filter.Q.setTargetAtTime(newQs[i] > 0 ? newQs[i] : this._calculateQ(i), now, 0.005); + const type = newTypes[i] || 'peaking'; + const q = newQs[i] > 0 ? newQs[i] : this._calculateQ(i); + const isShelf = type === 'lowshelf' || type === 'highshelf'; + const wasShelf = !!filter._shelfType; + + if (isShelf) { + // IIR filters can't update params — must replace the node + const coeffs = computeShelfCoefficients(type, newFrequencies[i], newGains[i], q, this.audioContext.sampleRate); + const iir = this.audioContext.createIIRFilter(coeffs.feedforward, coeffs.feedback); + iir._shelfType = type; + try { filter.disconnect(); } catch { /* ignore */ } + this.filters[i] = iir; + needsReconnect = true; + } else if (wasShelf) { + // Was shelf IIR, now peaking — create new BiquadFilterNode + const biquad = this.audioContext.createBiquadFilter(); + biquad.type = type; + biquad.frequency.value = newFrequencies[i]; + biquad.gain.value = newGains[i]; + biquad.Q.value = q; + try { filter.disconnect(); } catch { /* ignore */ } + this.filters[i] = biquad; + needsReconnect = true; + } else { + filter.type = type; + filter.frequency.setTargetAtTime(newFrequencies[i], now, 0.005); + filter.gain.setTargetAtTime(newGains[i], now, 0.005); + filter.Q.setTargetAtTime(q, now, 0.005); + } }); + if (needsReconnect) { + this._connectGraph(); + } } else { // Band count changed — must rebuild this._destroyEQ(); @@ -872,10 +946,16 @@ class AudioContextManager { const lines = [`Preamp: ${this.preamp.toFixed(1)} dB`]; sortedBands.forEach((band, index) => { if (index >= count) return; - const filterType = band.type === 'lowshelf' ? 'LS' : band.type === 'highshelf' ? 'HS' : 'PK'; - lines.push( - `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}` - ); + const filterType = band.type === 'lowshelf' ? 'LSC' : band.type === 'highshelf' ? 'HSC' : 'PK'; + if (band.type === 'lowshelf' || band.type === 'highshelf') { + lines.push( + `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB` + ); + } else { + lines.push( + `Filter ${index + 1}: ON ${filterType} Fc ${newFrequencies[index]} Hz Gain ${newGains[index].toFixed(1)} dB Q ${newQs[index].toFixed(2)}` + ); + } }); return lines.join('\n'); @@ -893,12 +973,18 @@ class AudioContextManager { this.frequencies.forEach((freq, index) => { const gain = this.currentGains[index] || 0; const type = (this.currentTypes && this.currentTypes[index]) || 'peaking'; - const filterType = type === 'lowshelf' ? 'LS' : type === 'highshelf' ? 'HS' : 'PK'; - const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); + const filterType = type === 'lowshelf' ? 'LSC' : type === 'highshelf' ? 'HSC' : 'PK'; const filterNum = index + 1; - lines.push( - `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}` - ); + if (type === 'lowshelf' || type === 'highshelf') { + lines.push( + `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB` + ); + } else { + const q = this.currentQs && this.currentQs[index] > 0 ? this.currentQs[index] : this._calculateQ(index); + lines.push( + `Filter ${filterNum}: ON ${filterType} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}` + ); + } }); return lines.join('\n'); diff --git a/js/autoeq-engine.js b/js/autoeq-engine.js index 2989313..bba9a4b 100644 --- a/js/autoeq-engine.js +++ b/js/autoeq-engine.js @@ -24,8 +24,7 @@ function calculateBiquadResponse(f, band, sr = DEFAULT_SR) { const w = (2 * PI * band.freq) / sr; const p = (2 * PI * f) / sr; const t = band.type[0]; - // WebAudio ignores Q for shelf filters; use 1/√2 (slope = 1) to match - const effectiveQ = t === 'l' || t === 'h' ? Math.SQRT1_2 : band.q; + const effectiveQ = band.q; const s = Math.sin(w) / (2 * effectiveQ); const A = Math.pow(DB_BASE, band.gain / DB_DIVISOR); const c = Math.cos(w); diff --git a/js/equalizer.js b/js/equalizer.js index 797934a..d8a9c72 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -621,9 +621,17 @@ export class Equalizer { this.frequencies.forEach((freq, index) => { const gain = this.currentGains[index] || 0; - const q = this.filters[index] ? this.filters[index].Q.value : this._calculateQ(index); + const filter = this.filters[index]; + const type = filter ? filter.type : 'peaking'; + const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' }; + const typeStr = typeMap[type] || 'PK'; const filterNum = index + 1; - lines.push(`Filter ${filterNum}: ON PK Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`); + if (type === 'lowshelf' || type === 'highshelf') { + lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB`); + } else { + const q = filter ? filter.Q.value : this._calculateQ(index); + lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`); + } }); return lines.join('\n'); diff --git a/js/settings.js b/js/settings.js index 670ead9..67ee468 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1385,14 +1385,19 @@ export async function initializeSettings(scrobbler, player, api, ui) { // Legacy EQ Import / Export const parseGeqLabelFrequency = (label) => { - const normalized = String(label).trim().toLowerCase().replace(/hz$/, '').trim(); - if (normalized.endsWith('k')) { - return Number.parseFloat(normalized.slice(0, -1)) * 1000; + const normalized = String(label).trim().toLowerCase().replace(/\s+/g, ''); + if (normalized.endsWith('khz')) { + return Number.parseFloat(normalized.slice(0, -3)) * 1000; } - return Number.parseFloat(normalized); + const withoutHz = normalized.replace(/hz$/, ''); + if (withoutHz.endsWith('k')) { + return Number.parseFloat(withoutHz.slice(0, -1)) * 1000; + } + return Number.parseFloat(withoutHz); }; const GEQ_FREQUENCIES = GEQ_LABELS.map((label) => parseGeqLabelFrequency(label)); const legacyGeqExportBtn = document.getElementById('legacy-geq-export-btn'); + const legacyGeqExportCsvBtn = document.getElementById('legacy-geq-export-csv-btn'); const legacyGeqImportBtn = document.getElementById('legacy-geq-import-btn'); const legacyGeqImportFile = document.getElementById('legacy-geq-import-file'); @@ -1408,7 +1413,21 @@ export async function initializeSettings(scrobbler, player, api, ui) { a.href = url; a.download = 'legacy-eq.txt'; a.click(); - URL.revokeObjectURL(url); + setTimeout(() => URL.revokeObjectURL(url), 0); + }); + } + + if (legacyGeqExportCsvBtn) { + legacyGeqExportCsvBtn.addEventListener('click', () => { + const pairs = GEQ_FREQUENCIES.map((freq, i) => `${freq} ${geqGains[i].toFixed(1)}`).join('; '); + const lines = [`Preamp: ${geqPreamp.toFixed(1)} dB`, `GraphicEQ: ${pairs}`]; + const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'legacy-eq-apo.txt'; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); }); } @@ -1445,11 +1464,11 @@ export async function initializeSettings(scrobbler, player, api, ui) { continue; } // Simple two-column format: freq gain (whitespace/tab/comma separated) - const simpleMatch = line.trim().match(/^([\d.]+[kK]?)\s*(?:Hz|kHz)?\s*[,\s\t]+([+-]?[\d.]+)/); + const simpleMatch = line.trim().match(/^([\d.]+)\s*([kK])?(?:Hz)?\s*[,\s\t]+([+-]?[\d.]+)/); if (simpleMatch) { importedPoints.push({ - freq: parseGeqLabelFrequency(simpleMatch[1]), - gain: parseFloat(simpleMatch[2]), + freq: parseGeqLabelFrequency(`${simpleMatch[1]}${simpleMatch[2] || ''}`), + gain: parseFloat(simpleMatch[3]), }); } } From 0cfff0b0b20b82d8f16e20f80bc0e40ffcfb900c Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 23:20:31 -0400 Subject: [PATCH 55/76] fix: make Q optional in EQ import regex for shelf filters --- js/audio-context.js | 4 ++-- js/equalizer.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/js/audio-context.js b/js/audio-context.js index d94019a..807ad3d 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -1014,13 +1014,13 @@ class AudioContextManager { // 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 + /^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]); + const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2; filters.push({ type, freq, gain, q }); } } diff --git a/js/equalizer.js b/js/equalizer.js index d8a9c72..0186f5d 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -661,13 +661,13 @@ export class Equalizer { // 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 + /^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]); + const q = filterMatch[4] ? parseFloat(filterMatch[4]) : Math.SQRT1_2; filters.push({ type, freq, gain, q }); } } From 91eaa1f1dcb2c92e7dcd704cf376f15822c0f50e Mon Sep 17 00:00:00 2001 From: tryptz Date: Mon, 6 Apr 2026 23:49:52 -0400 Subject: [PATCH 56/76] feat: configurable band count and frequency range for legacy graphic EQ - Add Bands/Min Hz/Max Hz controls to legacy EQ section - Dynamic frequency generation with log spacing and auto-scaling Q - Import/export handles variable band counts, Q optional for shelves - Custom presets interpolate across different band counts - Update legacy EQ tutorial for new controls --- index.html | 77 ++++++++++++++++++++++++++++++ js/audio-context.js | 60 +++++++++++++++++++++--- js/settings.js | 111 +++++++++++++++++++++++++++++++++----------- js/storage.js | 50 ++++++++++++++++++-- styles.css | 23 +++++++++ 5 files changed, 283 insertions(+), 38 deletions(-) diff --git a/index.html b/index.html index aeeae68..91c19e7 100644 --- a/index.html +++ b/index.html @@ -4174,6 +4174,54 @@ × + +

AutoEQ - Headphone Correction

    @@ -4297,6 +4345,35 @@ @@ -4360,8 +4357,8 @@ Right-click a node to change type or channel mode.
  1. - Right-click empty space or double-click to add a - node. Double-click a node to remove it. + Right-click empty space or double-click to add a node. + Double-click a node to remove it.
  2. Repeat for each channel, then Export JSON with all channels. diff --git a/js/audio-context.js b/js/audio-context.js index a7678d4..0dec0dc 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -326,7 +326,7 @@ class AudioContextManager { const type = (this.currentTypes && this.currentTypes[i]) || 'peaking'; const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i); const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo'; - const gain = ch === 'side' ? 0 : (this.currentGains[i] || 0); + const gain = ch === 'side' ? 0 : this.currentGains[i] || 0; const filter = this.audioContext.createBiquadFilter(); filter.type = type; filter.frequency.value = freq; @@ -339,7 +339,7 @@ class AudioContextManager { const type = (this.currentTypes && this.currentTypes[i]) || 'peaking'; const q = this.currentQs && this.currentQs[i] > 0 ? this.currentQs[i] : this._calculateQ(i); const ch = (this.currentChannels && this.currentChannels[i]) || 'stereo'; - const gain = ch === 'mid' ? 0 : (this.currentGains[i] || 0); + const gain = ch === 'mid' ? 0 : this.currentGains[i] || 0; const filter = this.audioContext.createBiquadFilter(); filter.type = type; filter.frequency.value = freq; @@ -353,7 +353,13 @@ class AudioContextManager { * Destroy M/S parallel filter chains */ _destroyMSFilters() { - const sd = (node) => { try { node?.disconnect(); } catch { /* */ } }; + const sd = (node) => { + try { + node?.disconnect(); + } catch { + /* */ + } + }; this.midFilters.forEach(sd); this.sideFilters.forEach(sd); this.midFilters = []; @@ -660,15 +666,15 @@ class AudioContextManager { // Encode L/R → M/S lastNode.connect(this.msSplitter); - this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid - this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid + this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid + this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid this.msEncoderMidL.connect(this.msMidInput); - this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5 + this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5 this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5) this.msEncoderSideL.connect(this.msSideInput); - this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5 + this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5 // Mid filter chain this.msMidInput.connect(this.midFilters[0]); @@ -688,12 +694,12 @@ class AudioContextManager { this.midOutputNode.connect(this.msDecoderMidToL); this.sideOutputNode.connect(this.msDecoderSideToL); this.msDecoderMidToL.connect(this.msLMix); - this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side + this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side this.midOutputNode.connect(this.msDecoderMidToR); this.sideOutputNode.connect(this.msDecoderSideToR); this.msDecoderMidToR.connect(this.msRMix); - this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side + this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side this.msLMix.connect(this.msMerger, 0, 0); this.msRMix.connect(this.msMerger, 0, 1); @@ -1089,7 +1095,8 @@ class AudioContextManager { this.msEnabled = needsMS; if (this.isInitialized && this.audioContext) { - const needsRebuild = msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count); + const needsRebuild = + msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count); if (needsRebuild) { // M/S state changed or band count changed — full rebuild @@ -1108,11 +1115,11 @@ class AudioContextManager { this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now); // Update mid filters (gain = 0 for side-only bands) - const midGains = newGains.map((g, i) => newChannels[i] === 'side' ? 0 : g); + const midGains = newGains.map((g, i) => (newChannels[i] === 'side' ? 0 : g)); this._updateFilterChain(this.midFilters, newFrequencies, newTypes, newQs, midGains, now); // Update side filters (gain = 0 for mid-only bands) - const sideGains = newGains.map((g, i) => newChannels[i] === 'mid' ? 0 : g); + const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g)); this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now); } else if (this.filters.length === count) { // Normal stereo — update in-place diff --git a/js/settings.js b/js/settings.js index 76ee01e..8384a4a 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2561,7 +2561,12 @@ export async function initializeSettings(scrobbler, player, api, ui) { const bands = getActiveBands(); // Filter type actions - if (action.startsWith('eq-type-') && contextMenuNodeIdx !== null && bands && bands[contextMenuNodeIdx]) { + if ( + action.startsWith('eq-type-') && + contextMenuNodeIdx !== null && + bands && + bands[contextMenuNodeIdx] + ) { const typeMap = { 'eq-type-lowshelf': 'lowshelf', 'eq-type-peaking': 'peaking', @@ -2578,7 +2583,12 @@ export async function initializeSettings(scrobbler, player, api, ui) { } // Channel actions (per-band M/S mode) - if (action.startsWith('eq-channel-') && contextMenuNodeIdx !== null && bands && bands[contextMenuNodeIdx]) { + if ( + action.startsWith('eq-channel-') && + contextMenuNodeIdx !== null && + bands && + bands[contextMenuNodeIdx] + ) { const channelMap = { 'eq-channel-stereo': 'stereo', 'eq-channel-mid': 'mid', @@ -2627,12 +2637,21 @@ export async function initializeSettings(scrobbler, player, api, ui) { if (currentMode === 'autoeq') { autoeqCurrentBands = []; bands = autoeqCurrentBands; - } else { hideEmptyContextMenu(); return; } + } else { + hideEmptyContextMenu(); + return; + } + } + if (bands.length >= 32) { + hideEmptyContextMenu(); + return; } - if (bands.length >= 32) { hideEmptyContextMenu(); return; } const rect = autoeqCanvas.getBoundingClientRect(); - const padLeft = 40, padRight = 10, padTop = 10, padBottom = 30; + const padLeft = 40, + padRight = 10, + padTop = 10, + padBottom = 30; const w = rect.width - padLeft - padRight; const h = rect.height - padTop - padBottom; const dbCenter = isParam ? 0 : 75; @@ -2640,9 +2659,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { const dbMin = dbCenter - dbHalf; const dbMax = dbCenter + dbHalf; const freq = Math.max(20, Math.min(20000, Math.round(xToFreq(pendingAddCoords.x - padLeft, w)))); - const gain = Math.max(-30, Math.min(30, - Math.round((yToDb(pendingAddCoords.y - padTop, h, dbMin, dbMax) - dbCenter) * 10) / 10 - )); + const gain = Math.max( + -30, + Math.min(30, Math.round((yToDb(pendingAddCoords.y - padTop, h, dbMin, dbMax) - dbCenter) * 10) / 10) + ); bands.push({ id: bands.length, type: 'peaking', freq, gain, q: 1.0, enabled: true, channel: 'stereo' }); setActiveBands(bands); @@ -2984,7 +3004,15 @@ export async function initializeSettings(scrobbler, player, api, ui) { const defaultBands = []; for (let i = 0; i < 10; i++) { const freq = 20 * Math.pow(20000 / 20, i / 9); - defaultBands.push({ id: i, type: 'peaking', freq: Math.round(freq), gain: 0, q: 1.0, enabled: true, channel: 'stereo' }); + defaultBands.push({ + id: i, + type: 'peaking', + freq: Math.round(freq), + gain: 0, + q: 1.0, + enabled: true, + channel: 'stereo', + }); } parametricBands = defaultBands; applyBandsToAudio(parametricBands); @@ -5295,7 +5323,15 @@ export async function initializeSettings(scrobbler, player, api, ui) { setActiveBands(bands); } if (bands.length >= 32) return; - bands.push({ id: bands.length, type: 'peaking', freq: 1000, gain: 0, q: 1.0, enabled: true, channel: 'stereo' }); + bands.push({ + id: bands.length, + type: 'peaking', + freq: 1000, + gain: 0, + q: 1.0, + enabled: true, + channel: 'stereo', + }); applyBandsToAudio(bands); renderBandControls(bands); computeCorrectedCurve(); From 2cc95c0ca0b3cfdabea56c60c01b54eac04ebb10 Mon Sep 17 00:00:00 2001 From: tryptz Date: Tue, 7 Apr 2026 04:18:37 -0400 Subject: [PATCH 62/76] feat: add channel select (ST/M/S) to parametric EQ band editor --- js/settings.js | 16 ++++++++++++++++ styles.css | 9 ++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/js/settings.js b/js/settings.js index 8384a4a..188d8fc 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2914,6 +2914,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { control.className = 'autoeq-band-control'; control.dataset.band = i; const currentType = band.type || 'peaking'; + const currentChannel = band.channel || 'stereo'; control.innerHTML = `
    ${i + 1} @@ -2922,6 +2923,11 @@ export async function initializeSettings(scrobbler, player, api, ui) { +
    Freq ${formatFreq(band.freq)} Hz @@ -2990,6 +2996,16 @@ export async function initializeSettings(scrobbler, player, api, ui) { applyBandsToAudio(bands); scheduleDrawAutoEQGraph(); }); + + const channelSelect = control.querySelector('.autoeq-channel-select'); + channelSelect.addEventListener('change', () => { + const bands = getActiveBands(); + if (!bands || !bands[i]) return; + bands[i].channel = channelSelect.value; + computeCorrectedCurve(); + applyBandsToAudio(bands); + scheduleDrawAutoEQGraph(); + }); }); }; diff --git a/styles.css b/styles.css index d41c3ba..3ad777e 100644 --- a/styles.css +++ b/styles.css @@ -9085,7 +9085,8 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { opacity: 0.6; } -.autoeq-type-select { +.autoeq-type-select, +.autoeq-channel-select { padding: 0.15rem 0.3rem; font-size: 0.7rem; font-weight: 600; @@ -9098,11 +9099,13 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { flex-shrink: 0; } -.autoeq-type-select:hover { +.autoeq-type-select:hover, +.autoeq-channel-select:hover { border-color: var(--primary); } -.autoeq-type-select:focus { +.autoeq-type-select:focus, +.autoeq-channel-select:focus { border-color: var(--ring); } From 4c35bed3f2a3e07bb1712432a3fa15ba9afcefb9 Mon Sep 17 00:00:00 2001 From: tryptz Date: Tue, 7 Apr 2026 05:07:14 -0400 Subject: [PATCH 63/76] feat: EQ Studio header, channel select in band editor, tab reorder, legend toggle - Rename section header to EQ Studio with updated description - Add ST/M/S channel select dropdown to each parametric band control - Move Parametric EQ tab before AutoEQ - Hide Original/Target/Corrected legend in parametric mode --- index.html | 6 +++--- js/settings.js | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index fbbeaf2..9e3acb9 100644 --- a/index.html +++ b/index.html @@ -4161,9 +4161,9 @@
    - AutoEQ + EQ Studio Precision headphone correction & parametric equalizerMulti-mode equalizer with AutoEQ, M/S processing & room correction
    @@ -4265,9 +4243,7 @@
    Width Amount - 1.0 + 1.0
    { - try { node?.disconnect(); } catch { /* */ } + try { + node?.disconnect(); + } catch { + /* */ + } }; sd(this.inputNode); @@ -340,14 +344,25 @@ export class BinauralDSP { _destroyCrossfeedNodes() { const nodes = [ - this._cfSplitter, this._cfMerger, - this._cfDirectL, this._cfDirectR, - this._cfCrossLR, this._cfCrossRL, - this._cfFilterLR, this._cfFilterRL, - this._cfDelayLR, this._cfDelayRL, + this._cfSplitter, + this._cfMerger, + this._cfDirectL, + this._cfDirectR, + this._cfCrossLR, + this._cfCrossRL, + this._cfFilterLR, + this._cfFilterRL, + this._cfDelayLR, + this._cfDelayRL, this._cfOutputNode, ]; - nodes.forEach((n) => { try { n?.disconnect(); } catch { /* */ } }); + nodes.forEach((n) => { + try { + n?.disconnect(); + } catch { + /* */ + } + }); this._cfSplitter = null; this._cfMerger = null; this._cfDirectL = null; @@ -416,7 +431,13 @@ export class BinauralDSP { } _destroyMultichannelNodes() { - const sd = (n) => { try { n?.disconnect(); } catch { /* */ } }; + const sd = (n) => { + try { + n?.disconnect(); + } catch { + /* */ + } + }; sd(this._mcSplitter); sd(this._mcMerger); sd(this._mcLfeGain); @@ -499,15 +520,31 @@ export class BinauralDSP { _destroyWidenerNodes() { const nodes = [ - this._wSplitter, this._wOutputMerger, - this._wMidL, this._wMidR, this._wSideL, this._wSideR, - this._wMidGain, this._wSideGain, - this._wMidMix, this._wSideMix, - this._wDecoderMidToL, this._wDecoderSideToL, - this._wDecoderMidToR, this._wDecoderSideToR, - this._wLMix, this._wRMix, this._wOutputNode, + this._wSplitter, + this._wOutputMerger, + this._wMidL, + this._wMidR, + this._wSideL, + this._wSideR, + this._wMidGain, + this._wSideGain, + this._wMidMix, + this._wSideMix, + this._wDecoderMidToL, + this._wDecoderSideToL, + this._wDecoderMidToR, + this._wDecoderSideToR, + this._wLMix, + this._wRMix, + this._wOutputNode, ]; - nodes.forEach((n) => { try { n?.disconnect(); } catch { /* */ } }); + nodes.forEach((n) => { + try { + n?.disconnect(); + } catch { + /* */ + } + }); this._wSplitter = null; this._wOutputMerger = null; this._wMidL = null; @@ -570,9 +607,11 @@ export class BinauralDSP { await this._ensureNodesCreated(); this._connectInternal(); - window.dispatchEvent(new CustomEvent('binaural-mode-changed', { - detail: { mode: this.mode, channels: channelCount }, - })); + window.dispatchEvent( + new CustomEvent('binaural-mode-changed', { + detail: { mode: this.mode, channels: channelCount }, + }) + ); } } diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js index b12dcf5..ebd577a 100644 --- a/js/hrtf-generator.js +++ b/js/hrtf-generator.js @@ -91,8 +91,7 @@ export async function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const notchFreq = 8000 + elevationDeg * 50; // Shifts with elevation const notchWidth = 2000; const notchDepth = 0.15 * Math.abs(Math.sin(elevRad + 0.3)); - const notchFactor = - 1.0 - notchDepth * Math.exp(-Math.pow((freq - notchFreq) / notchWidth, 2)); + const notchFactor = 1.0 - notchDepth * Math.exp(-Math.pow((freq - notchFreq) / notchWidth, 2)); const phase = 2 * Math.PI * freq * (t - ipsiDelay / sampleRate); sum += ((ipsiGain * notchFactor) / halfFFT) * Math.cos(phase); @@ -109,7 +108,7 @@ export async function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const shadowGain = calculateHeadShadow(freq, azimuthRad); const phase = 2 * Math.PI * freq * (t - contraDelay / sampleRate); - sum += ((shadowGain) / halfFFT) * Math.cos(phase); + sum += (shadowGain / halfFFT) * Math.cos(phase); } contraData[i] = sum; } @@ -134,9 +133,9 @@ export async function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { * HRTF angle presets for virtual speaker configurations. */ export const HRTF_PRESETS = { - intimate: { label: 'Intimate', angleScale: 0.73 }, // ±22° front - studio: { label: 'Studio', angleScale: 1.0 }, // ±30° front (standard) - wide: { label: 'Wide', angleScale: 1.5 }, // ±45° front + intimate: { label: 'Intimate', angleScale: 0.73 }, // ±22° front + studio: { label: 'Studio', angleScale: 1.0 }, // ±30° front (standard) + wide: { label: 'Wide', angleScale: 1.5 }, // ±45° front }; /** diff --git a/js/player.js b/js/player.js index 1e23b6a..d484fe6 100644 --- a/js/player.js +++ b/js/player.js @@ -1897,7 +1897,8 @@ export class Player { const binauralActive = audioContextManager.isBinauralActive(); badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge'; - badgeEl.innerHTML = SVG_ATMOS(20) + (binauralActive ? ' Binaural' : ''); + badgeEl.innerHTML = + SVG_ATMOS(20) + (binauralActive ? ' Binaural' : ''); } else { // Notify binaural DSP that we're in stereo mode audioContextManager.notifyBinauralChannelCount(2); diff --git a/js/settings.js b/js/settings.js index 958feb7..204684d 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1248,9 +1248,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { const { mode, channels } = e.detail; const label = statusEl.querySelector('.binaural-mode-label'); if (label) { - label.textContent = mode === 'multichannel' - ? `Mode: Multichannel (${channels > 6 ? '7.1' : '5.1'} → Binaural)` - : 'Mode: Stereo'; + label.textContent = + mode === 'multichannel' + ? `Mode: Multichannel (${channels > 6 ? '7.1' : '5.1'} → Binaural)` + : 'Mode: Stereo'; } } }); From 88b01570f5655347924bf1970c8851838e48ac8b Mon Sep 17 00:00:00 2001 From: tryptz Date: Tue, 7 Apr 2026 09:01:10 -0400 Subject: [PATCH 67/76] fix: position EQ nodes at individual band gain, not composite sum --- js/settings.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/js/settings.js b/js/settings.js index 204684d..23a8df6 100644 --- a/js/settings.js +++ b/js/settings.js @@ -2253,16 +2253,17 @@ export async function initializeSettings(scrobbler, player, api, ui) { const x = gx(band.freq); // In parametric mode: node Y = band's individual response at its freq (basically its gain) // In AutoEQ mode: node Y = corrected curve value at band freq (shifted) - let nodeGain; + let nodeGain, sumGain; if (isParametricMode) { - // Sum all bands' response at this frequency - let totalGain = 0; + // Node sits at individual band gain; sum is for tooltip only + nodeGain = band.gain; + sumGain = 0; for (const b of activeBands) { - if (b.enabled) totalGain += calculateBiquadResponse(band.freq, b, sampleRate); + if (b.enabled) sumGain += calculateBiquadResponse(band.freq, b, sampleRate); } - nodeGain = totalGain; } else { nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift; + sumGain = nodeGain; } const y = gy(nodeGain); @@ -2331,7 +2332,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { ctx.font = 'bold 11px system-ui, sans-serif'; const chLabel = bandChannel !== 'stereo' ? ` [${bandChannel.toUpperCase()}]` : ''; const line1 = `${Math.round(band.freq)} Hz ${band.gain > 0 ? '+' : ''}${band.gain.toFixed(1)} dB Q${band.q.toFixed(2)}${chLabel}`; - const line2 = `Sum: ${nodeGain > 0 ? '+' : ''}${nodeGain.toFixed(1)} dB`; + const line2 = `Sum: ${sumGain > 0 ? '+' : ''}${sumGain.toFixed(1)} dB`; const tw1 = ctx.measureText(line1).width; const tw2 = ctx.measureText(line2).width; const tw = Math.max(tw1, tw2) + 12; @@ -2445,10 +2446,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { const x = padLeft + freqToX(band.freq, w); let nodeGain; if (isParam) { - nodeGain = 0; - for (const b of activeBands) { - if (b.enabled) nodeGain += calculateBiquadResponse(band.freq, b, sampleRate); - } + nodeGain = band.gain; } else { nodeGain = interpolate(band.freq, autoeqCorrectedCurve) + graphShift; } From 79313e7a0a8372ae79f44a8560cc22cd75951abc Mon Sep 17 00:00:00 2001 From: tryptz Date: Tue, 7 Apr 2026 09:35:38 -0400 Subject: [PATCH 68/76] fix: address PR #523 review comments for EQ and binaural DSP - Remove stale IIR coefficient JSDoc comment - Reset M/S channel state on EQ import to prevent stale assignments - Enforce strictly increasing GEQ frequencies to prevent rounding duplicates - Guard Q calculation against zero octave spacing (Infinity/NaN) - Export EQ from stored metadata instead of live BiquadFilterNode state - Accept .csv in legacy GEQ import file input - Expose public reconnect() on BinauralDSP instead of calling _connectInternal - Dispatch binaural-mode-changed on channel count change, not just mode change - Remove no-op channelCount/channelCountMode on MediaElementAudioSourceNode - Add void to floating promises (toggleBinaural, notifyBinauralChannelCount, _loadBinauralSettings) - Wrap binauralDspSettings._setAll in try/catch for QuotaExceededError - Make generateHRTF synchronous (no awaits, was misleadingly async) --- index.html | 2 +- js/audio-context.js | 22 +++++++--------------- js/binaural-dsp.js | 12 ++++++++++-- js/equalizer.js | 5 ++--- js/hrtf-generator.js | 2 +- js/player.js | 6 +++--- js/settings.js | 11 +++++++++-- js/storage.js | 6 +++++- 8 files changed, 38 insertions(+), 28 deletions(-) diff --git a/index.html b/index.html index c59aec1..927947a 100644 --- a/index.html +++ b/index.html @@ -4595,7 +4595,7 @@
    diff --git a/js/audio-context.js b/js/audio-context.js index d734057..10a1588 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -6,11 +6,6 @@ import { isIos } from './platform-detection.js'; import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js'; import { BinauralDSP } from './binaural-dsp.js'; -/** - * Compute RBJ cookbook IIR coefficients for shelf filters with Q support. - * Web Audio API's BiquadFilterNode ignores Q for lowshelf/highshelf, - * so we use IIRFilterNode with these coefficients instead. - */ // Generate frequency array for given number of bands using logarithmic spacing function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) { const frequencies = []; @@ -493,14 +488,6 @@ class AudioContextManager { if (!this.sources.has(audioElement)) { const src = this.audioContext.createMediaElementSource(audioElement); - // Allow multichannel passthrough for Atmos/spatial audio - try { - src.channelCount = 6; - src.channelCountMode = 'max'; - src.channelInterpretation = 'discrete'; - } catch { - // Some browsers may not support this - } this.sources.set(audioElement, src); } this.source = this.sources.get(audioElement); @@ -520,7 +507,7 @@ class AudioContextManager { // Create binaural DSP processor this.binauralDsp = new BinauralDSP(this.audioContext); - this._loadBinauralSettings(); + void this._loadBinauralSettings(); this._createEQ(); this._createGraphicEQ(); @@ -689,7 +676,7 @@ class AudioContextManager { if (this.isBinauralEnabled && this.binauralDsp) { const { input, output } = this.binauralDsp.getNodes(); lastNode.connect(input); - this.binauralDsp._connectInternal(); + this.binauralDsp.reconnect(); lastNode = output; } @@ -1432,6 +1419,10 @@ class AudioContextManager { this.currentQs = qs; this.currentGains = gains; + // Reset M/S channel assignments — imported config has no channel info + this.currentChannels = new Array(this.bandCount).fill('stereo'); + this.msEnabled = false; + // Rebuild EQ chain to apply new frequencies, types, and Qs if (this.isInitialized && this.audioContext) { this._destroyMSFilters(); @@ -1446,6 +1437,7 @@ class AudioContextManager { equalizerSettings.setGains(this.currentGains); equalizerSettings.setBandTypes(this.currentTypes); equalizerSettings.setBandQs(this.currentQs); + equalizerSettings.setBandChannels(this.currentChannels); return true; } catch (e) { diff --git a/js/binaural-dsp.js b/js/binaural-dsp.js index e9417a9..402e9ae 100644 --- a/js/binaural-dsp.js +++ b/js/binaural-dsp.js @@ -88,6 +88,13 @@ export class BinauralDSP { return { input: this.inputNode, output: this.outputNode }; } + /** + * Reconnect internal graph (public API for external callers). + */ + reconnect() { + this._connectInternal(); + } + /** * Connect internal graph based on current state. */ @@ -595,6 +602,7 @@ export class BinauralDSP { */ async detectAndConfigure(channelCount) { const prevMode = this.mode; + const prevChannels = this.channelCount; this.channelCount = channelCount; if (channelCount > 2) { @@ -603,13 +611,13 @@ export class BinauralDSP { this.mode = 'stereo'; } - if (this.enabled && this.mode !== prevMode) { + if (this.enabled && (this.mode !== prevMode || channelCount !== prevChannels)) { await this._ensureNodesCreated(); this._connectInternal(); window.dispatchEvent( new CustomEvent('binaural-mode-changed', { - detail: { mode: this.mode, channels: channelCount }, + detail: { mode: this.mode, channels: this.channelCount }, }) ); } diff --git a/js/equalizer.js b/js/equalizer.js index 7bd626b..ad7ca14 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -621,11 +621,10 @@ export class Equalizer { this.frequencies.forEach((freq, index) => { const gain = this.currentGains[index] || 0; - const filter = this.filters[index]; - const type = filter ? filter.type : 'peaking'; + const type = this.currentTypes[index] || 'peaking'; const typeMap = { peaking: 'PK', lowshelf: 'LSC', highshelf: 'HSC' }; const typeStr = typeMap[type] || 'PK'; - const q = filter ? filter.Q.value : this._calculateQ(index); + const q = this.currentQs[index] || this._calculateQ(index); const filterNum = index + 1; lines.push(`Filter ${filterNum}: ON ${typeStr} Fc ${freq} Hz Gain ${gain.toFixed(1)} dB Q ${q.toFixed(2)}`); }); diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js index ebd577a..30b0bf3 100644 --- a/js/hrtf-generator.js +++ b/js/hrtf-generator.js @@ -48,7 +48,7 @@ function calculateHeadShadow(frequency, azimuthRad) { * @param {number} [elevationDeg=0] - Elevation in degrees (currently simplified) * @returns {Promise} Stereo AudioBuffer with HRTF IR */ -export async function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { +export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const sampleRate = audioContext.sampleRate; const buffer = audioContext.createBuffer(2, IR_LENGTH, sampleRate); diff --git a/js/player.js b/js/player.js index d484fe6..664d47c 100644 --- a/js/player.js +++ b/js/player.js @@ -1885,7 +1885,7 @@ export class Player { if (isAtmosPlaying) { // Auto-enable binaural DSP for spatial content if (binauralDspSettings.getAutoEnableForSpatial() && !binauralDspSettings.isEnabled()) { - audioContextManager.toggleBinaural(true); + void audioContextManager.toggleBinaural(true); // Update toggle in settings UI if visible const toggle = document.getElementById('binaural-dsp-toggle'); if (toggle) toggle.checked = true; @@ -1893,7 +1893,7 @@ export class Player { if (container) container.style.display = 'block'; } // Notify binaural DSP of multichannel content (Atmos is typically 5.1+) - audioContextManager.notifyBinauralChannelCount(6); + void audioContextManager.notifyBinauralChannelCount(6); const binauralActive = audioContextManager.isBinauralActive(); badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge'; @@ -1901,7 +1901,7 @@ export class Player { SVG_ATMOS(20) + (binauralActive ? ' Binaural' : ''); } else { // Notify binaural DSP that we're in stereo mode - audioContextManager.notifyBinauralChannelCount(2); + void audioContextManager.notifyBinauralChannelCount(2); badgeEl.className = 'quality-badge quality-hires shaka-quality-badge'; badgeEl.textContent = text; } diff --git a/js/settings.js b/js/settings.js index 23a8df6..a39b666 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1353,7 +1353,12 @@ export async function initializeSettings(scrobbler, player, api, ui) { const freqs = []; for (let i = 0; i < count; i++) { const t = i / (count - 1); - freqs.push(Math.round(min * Math.pow(max / min, t))); + let freq = Math.round(min * Math.pow(max / min, t)); + // Ensure strictly increasing — rounding can produce duplicates at high band counts + if (freqs.length > 0 && freq <= freqs[freqs.length - 1]) { + freq = freqs[freqs.length - 1] + 1; + } + freqs.push(freq); } return freqs; }; @@ -1561,7 +1566,9 @@ export async function initializeSettings(scrobbler, player, api, ui) { const prev = GEQ_FREQUENCIES[Math.max(0, i - 1)]; const next = GEQ_FREQUENCIES[Math.min(GEQ_FREQUENCIES.length - 1, i + 1)]; const octaves = Math.log2(next / prev); - const q = (Math.SQRT2 / (2 * Math.sinh((Math.LN2 / 2) * octaves))).toFixed(2); + const q = octaves > 0 + ? (Math.SQRT2 / (2 * Math.sinh((Math.LN2 / 2) * octaves))).toFixed(2) + : Math.SQRT2.toFixed(2); lines.push(`Filter ${i + 1}: ON PK Fc ${freq} Hz Gain ${geqGains[i].toFixed(1)} dB Q ${q}`); }); const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); diff --git a/js/storage.js b/js/storage.js index 55ce212..27a598d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1878,7 +1878,11 @@ export const binauralDspSettings = { }, _setAll(obj) { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj)); + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj)); + } catch { + // QuotaExceededError — storage full + } }, isEnabled() { From 589504d7fd777f1b48904fb0d97e53f270c7d6cf Mon Sep 17 00:00:00 2001 From: tryptz <216453278+tryptz@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:38:34 +0000 Subject: [PATCH 69/76] style: auto-fix linting issues --- js/settings.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/js/settings.js b/js/settings.js index a39b666..ae71483 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1566,9 +1566,10 @@ export async function initializeSettings(scrobbler, player, api, ui) { const prev = GEQ_FREQUENCIES[Math.max(0, i - 1)]; const next = GEQ_FREQUENCIES[Math.min(GEQ_FREQUENCIES.length - 1, i + 1)]; const octaves = Math.log2(next / prev); - const q = octaves > 0 - ? (Math.SQRT2 / (2 * Math.sinh((Math.LN2 / 2) * octaves))).toFixed(2) - : Math.SQRT2.toFixed(2); + const q = + octaves > 0 + ? (Math.SQRT2 / (2 * Math.sinh((Math.LN2 / 2) * octaves))).toFixed(2) + : Math.SQRT2.toFixed(2); lines.push(`Filter ${i + 1}: ON PK Fc ${freq} Hz Gain ${geqGains[i].toFixed(1)} dB Q ${q}`); }); const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); From 8c9a613beefe10986cf47899998d5a42a4e40d5f Mon Sep 17 00:00:00 2001 From: tryptz Date: Tue, 7 Apr 2026 12:19:24 -0400 Subject: [PATCH 70/76] fix: resolve remaining PR #523 review issues Guard generateFrequencies against bandCount=1 division by zero, fix inverted HRTF ITD delays for left-side sources, remove unused variable, promote _interpolateGains to public API, and add NaN guard to widening. --- js/audio-context.js | 12 +++++++++--- js/equalizer.js | 4 ++-- js/hrtf-generator.js | 5 +++-- js/settings.js | 5 ++--- js/storage.js | 11 ++++++----- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/js/audio-context.js b/js/audio-context.js index 10a1588..fed7887 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -12,6 +12,12 @@ function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) { const safeMin = Math.max(10, minFreq); const safeMax = Math.min(96000, maxFreq); + if (bandCount <= 1) { + // Single band: use geometric mean of range + frequencies.push(Math.round(Math.sqrt(safeMin * safeMax))); + return frequencies; + } + for (let i = 0; i < bandCount; i++) { // Logarithmic interpolation const t = i / (bandCount - 1); @@ -175,7 +181,7 @@ class AudioContextManager { this.frequencies = generateFrequencies(newCount, this.freqRange.min, this.freqRange.max); // Interpolate current gains to new band count - const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount); + const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount); this.currentGains = newGains; equalizerSettings.setGains(newGains); @@ -1079,7 +1085,7 @@ class AudioContextManager { // Ensure gains array matches current band count let adjustedGains = gains; if (gains.length !== this.bandCount) { - adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount); + adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount); } const now = this.audioContext?.currentTime || 0; @@ -1535,7 +1541,7 @@ class AudioContextManager { const oldGains = this.geqGains; this.geqBandCount = newCount; this.geqFrequencies = generateFrequencies(newCount, this.geqFreqRange.min, this.geqFreqRange.max); - this.geqGains = equalizerSettings._interpolateGains(oldGains, newCount); + this.geqGains = equalizerSettings.interpolateGains(oldGains, newCount); equalizerSettings.setGraphicEqBandCount(newCount); equalizerSettings.setGraphicEqGains(this.geqGains); diff --git a/js/equalizer.js b/js/equalizer.js index ad7ca14..2125d3c 100644 --- a/js/equalizer.js +++ b/js/equalizer.js @@ -201,7 +201,7 @@ export class Equalizer { this.frequencyLabels = generateFrequencyLabels(this.frequencies); // Interpolate current gains to new band count - const newGains = equalizerSettings._interpolateGains(this.currentGains, newCount); + const newGains = equalizerSettings.interpolateGains(this.currentGains, newCount); this.currentGains = newGains; equalizerSettings.setGains(newGains); @@ -455,7 +455,7 @@ export class Equalizer { // Ensure gains array matches current band count let adjustedGains = gains; if (gains.length !== this.bandCount) { - adjustedGains = equalizerSettings._interpolateGains(gains, this.bandCount); + adjustedGains = equalizerSettings.interpolateGains(gains, this.bandCount); } const now = this.audioContext?.currentTime || 0; diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js index 30b0bf3..8a114b4 100644 --- a/js/hrtf-generator.js +++ b/js/hrtf-generator.js @@ -65,8 +65,9 @@ export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const contraData = sourceOnRight ? leftData : rightData; // Generate ipsilateral (near ear) IR — mostly a delayed impulse with slight coloring - const ipsiDelay = Math.max(0, sourceOnRight ? 0 : itdSamples); - const contraDelay = Math.max(0, sourceOnRight ? itdSamples : 0); + // Ipsilateral ear (near source) receives sound first; contralateral ear is delayed by ITD + const ipsiDelay = 0; + const contraDelay = Math.abs(itdSamples); // Create frequency-domain representation for head shadow const fftSize = IR_LENGTH; diff --git a/js/settings.js b/js/settings.js index ae71483..246528c 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1512,7 +1512,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { const newCount = Math.max(3, Math.min(32, parseInt(geqBandCountInput.value, 10) || 16)); geqBandCountInput.value = newCount; if (newCount === geqBandCount) return; - geqGains = equalizerSettings._interpolateGains(geqGains, newCount); + geqGains = equalizerSettings.interpolateGains(geqGains, newCount); geqBandCount = newCount; equalizerSettings.setGraphicEqGains(geqGains); audioContextManager.setGraphicEqBandCount(newCount); @@ -1778,7 +1778,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { return; } const adjusted = - gains.length !== geqBandCount ? equalizerSettings._interpolateGains(gains, geqBandCount) : gains; + gains.length !== geqBandCount ? equalizerSettings.interpolateGains(gains, geqBandCount) : gains; geqGains = adjusted.map((g) => { const n = Number(g); return Number.isFinite(n) @@ -2446,7 +2446,6 @@ export async function initializeSettings(scrobbler, player, api, ui) { else if (meas) graphShift = 75 - getNormalizationOffset(meas); } - const sampleRate = autoeqSampleRate ? parseInt(autoeqSampleRate.value, 10) : 48000; let closest = -1, closestDist = Infinity; activeBands.forEach((band, i) => { diff --git a/js/storage.js b/js/storage.js index 27a598d..74237b2 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1329,7 +1329,7 @@ export const equalizerSettings = { } // If different band count, try to interpolate or return flat if (gains.length > 0) { - return this._interpolateGains(gains, count); + return this.interpolateGains(gains, count); } } } @@ -1425,7 +1425,7 @@ export const equalizerSettings = { } // Interpolate stored Qs to match requested band count instead of discarding if (Array.isArray(qs) && qs.length >= this.MIN_BANDS) { - return this._interpolateGains(qs, count); + return this.interpolateGains(qs, count); } } } catch { @@ -1473,7 +1473,7 @@ export const equalizerSettings = { /** * Interpolate gains array to match target band count */ - _interpolateGains(sourceGains, targetCount) { + interpolateGains(sourceGains, targetCount) { if (sourceGains.length === targetCount) { return [...sourceGains]; } @@ -1808,7 +1808,7 @@ export const equalizerSettings = { return parsed.map((v) => (Number.isFinite(v) ? v : 0)); } if (Array.isArray(parsed) && parsed.length > 0) { - return this._interpolateGains(parsed, expectedCount); + return this.interpolateGains(parsed, expectedCount); } } } catch { @@ -1944,7 +1944,8 @@ export const binauralDspSettings = { setWideningAmount(amount) { const all = this._getAll(); - all.wideningAmount = Math.max(0, Math.min(2, amount)); + const n = Number(amount); + all.wideningAmount = Number.isFinite(n) ? Math.max(0, Math.min(2, n)) : 1.0; this._setAll(all); }, From a0e587eb9751005de432a4760fa1d60357950de4 Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 8 Apr 2026 05:00:17 -0400 Subject: [PATCH 71/76] style: fix no-descending-specificity for .binaural-sub-setting rules Move .binaural-sub-setting select before .info/.label/.description so selector specificity only increases, resolving the stylelint violation. --- styles.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/styles.css b/styles.css index 0b50ce0..9e0722f 100644 --- a/styles.css +++ b/styles.css @@ -11234,6 +11234,16 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { min-width: 0; } +.binaural-sub-setting select { + background: var(--secondary); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.85rem; + cursor: pointer; +} + .binaural-sub-setting .info { display: flex; flex-direction: column; @@ -11251,16 +11261,6 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { color: var(--muted-foreground); } -.binaural-sub-setting select { - background: var(--secondary); - color: var(--foreground); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: var(--spacing-xs) var(--spacing-sm); - font-size: 0.85rem; - cursor: pointer; -} - .binaural-slider { width: 140px; accent-color: var(--primary); From 29386883086fe63f78a61fb6f38ce3fea20b84bb Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 8 Apr 2026 05:54:52 -0400 Subject: [PATCH 72/76] Update js/hrtf-generator.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- js/hrtf-generator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js index 8a114b4..1bf03f6 100644 --- a/js/hrtf-generator.js +++ b/js/hrtf-generator.js @@ -46,7 +46,7 @@ function calculateHeadShadow(frequency, azimuthRad) { * @param {AudioContext} audioContext * @param {number} azimuthDeg - Azimuth in degrees (-180 to 180, 0 = front, positive = right) * @param {number} [elevationDeg=0] - Elevation in degrees (currently simplified) - * @returns {Promise} Stereo AudioBuffer with HRTF IR + * @returns {AudioBuffer} Stereo AudioBuffer with HRTF IR */ export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const sampleRate = audioContext.sampleRate; From 714256974112ee618a551b6490a516c2fe0ed81e Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 8 Apr 2026 05:58:16 -0400 Subject: [PATCH 73/76] Update js/player.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- js/player.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/player.js b/js/player.js index 664d47c..17d92c9 100644 --- a/js/player.js +++ b/js/player.js @@ -1892,8 +1892,12 @@ export class Player { const container = document.getElementById('binaural-dsp-container'); if (container) container.style.display = 'block'; } - // Notify binaural DSP of multichannel content (Atmos is typically 5.1+) - void audioContextManager.notifyBinauralChannelCount(6); + // Notify binaural DSP of the actual multichannel layout when Shaka exposes it. + const atmosChannelCount = + Number.isFinite(activeVariant.channelsCount) && activeVariant.channelsCount > 0 + ? activeVariant.channelsCount + : 6; + void audioContextManager.notifyBinauralChannelCount(atmosChannelCount); const binauralActive = audioContextManager.isBinauralActive(); badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge'; From 0cbfa4d3f400784f1ad0e18865536f9b3fc7e622 Mon Sep 17 00:00:00 2001 From: tryptz Date: Wed, 8 Apr 2026 06:00:35 -0400 Subject: [PATCH 74/76] Update js/audio-context.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- js/audio-context.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/audio-context.js b/js/audio-context.js index fed7887..d17f95c 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -373,15 +373,13 @@ class AudioContextManager { } /** - * Update a filter chain in-place. Returns true if reconnect is needed. + * Update an existing filter chain in place. * @param {Array} chain - Filter array to update (this.filters, this.midFilters, or this.sideFilters) * @param {Array} freqs - New frequencies * @param {Array} types - New filter types * @param {Array} qs - New Q values * @param {Array} gains - New gain values * @param {number} now - Current audio context time - * @param {string} [prop] - Property name on this to update replaced filters (e.g. 'midFilters') - * @returns {boolean} Whether graph reconnection is needed */ _updateFilterChain(chain, freqs, types, qs, gains, now) { chain.forEach((filter, i) => { From edbec62a5534bd4e59791f62408c75939eadbfdd Mon Sep 17 00:00:00 2001 From: edideaur Date: Fri, 10 Apr 2026 16:20:04 +0300 Subject: [PATCH 75/76] fix lockfiles --- .../music/AudioPlaybackService.java | 2 +- bun.lock | 4 ++-- ios/App/App/AppDelegate.swift | 6 +++--- js/HiFi.ts | 20 +++++++++---------- js/app.js | 2 +- js/audio-context.js | 10 +++++----- js/binaural-dsp.js | 2 +- js/downloads.js | 10 +++++----- js/hrtf-generator.js | 2 +- js/player.js | 2 +- js/settings.js | 2 +- js/storage.js | 2 +- js/visualizer.js | 2 +- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/android/app/src/main/java/tf/monochrome/music/AudioPlaybackService.java b/android/app/src/main/java/tf/monochrome/music/AudioPlaybackService.java index 11998e0..2444f44 100644 --- a/android/app/src/main/java/tf/monochrome/music/AudioPlaybackService.java +++ b/android/app/src/main/java/tf/monochrome/music/AudioPlaybackService.java @@ -49,7 +49,7 @@ public class AudioPlaybackService extends Service { acquireWakeLock(); - // If the system kills this service, don't restart it automatically — + // If the system kills this service, don't restart it automatically - // MainActivity will re-start it when audio resumes. return START_NOT_STICKY; } diff --git a/bun.lock b/bun.lock index 8494616..68abd7d 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.1.7", + "@uimaxbai/am-lyrics": "^1.1.8", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", @@ -675,7 +675,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.7", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-hEwPl4dFmJ08sJf4VBaR7k7yxA3BNaoINS89j0KrkSFJYpCkohHDy24AIfzEMonPloJ3H6HBA55nCFMnAzm50w=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-VcbrlB2cOmkOjElmivf2SZujDmj8UAUaBkXyIfJ8dYq/Iv4H3PxmQY/s9VaRfF6UTnCgfix8ZPll1T1MA8eS4A=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 88e35b1..7b0e5aa 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -48,10 +48,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { switch type { case .began: - // Interruption began — system pauses audio automatically + // Interruption began - system pauses audio automatically break case .ended: - // Interruption ended — reactivate session so playback can resume + // Interruption ended - reactivate session so playback can resume if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { @@ -75,7 +75,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } if reason == .oldDeviceUnavailable { - // Headphones/Bluetooth disconnected — reactivate session to keep background alive + // Headphones/Bluetooth disconnected - reactivate session to keep background alive do { try AVAudioSession.sharedInstance().setActive(true) } catch { diff --git a/js/HiFi.ts b/js/HiFi.ts index df33f49..255968e 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -108,7 +108,7 @@ export interface TidalArtistProfile { picture: string | null; /** Fallback album cover UUID used when no artist picture exists, or `null`. */ selectedAlbumCoverFallback: string | null; - /** Popularity score (0–100). */ + /** Popularity score (0-100). */ popularity: number; /** List of credited roles for this artist. */ artistRoles: TidalArtistRole[]; @@ -150,7 +150,7 @@ export interface TidalTrackAlbumRef { * Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes. * * @remarks - * Fields `bpm`, `key`, and `keyScale` are nullable — they are absent for some tracks. + * Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks. * `version` is present in the payload but may be `null`. */ export interface TidalTrack { @@ -162,7 +162,7 @@ export interface TidalTrack { duration: number; /** Track replay-gain value in dB. */ replayGain: number; - /** Track peak amplitude (0–1). */ + /** Track peak amplitude (0-1). */ peak: number; /** Whether the track is available for streaming. */ allowStreaming: boolean; @@ -186,7 +186,7 @@ export interface TidalTrack { volumeNumber: number; /** Version suffix (e.g. `"Remastered"`), or `null`. */ version: string | null; - /** Popularity score (0–100). */ + /** Popularity score (0-100). */ popularity: number; /** Copyright notice. */ copyright: string; @@ -299,7 +299,7 @@ export interface TidalAlbum { explicit: boolean; /** UPC barcode. */ upc: string; - /** Popularity score (0–100). */ + /** Popularity score (0-100). */ popularity: number; /** Highest available audio quality. */ audioQuality: string; @@ -339,7 +339,7 @@ export interface TidalVideoItem { volumeNumber: number; /** Track number on the disc. */ trackNumber: number; - /** Popularity score (0–100). */ + /** Popularity score (0-100). */ popularity: number; /** Double-precision popularity score (present in topvideos). */ doublePopularity?: number; @@ -452,7 +452,7 @@ export interface TidalSimilarAlbum { releaseDate: string; /** Copyright information. */ copyright: { text: string }; - /** Popularity score (0–1 float). */ + /** Popularity score (0-1 float). */ popularity: number; /** Access type, e.g. `"PUBLIC"`. */ accessType: string; @@ -533,7 +533,7 @@ export interface SimilarArtist { url: string; /** Relation type, e.g. `"SIMILAR_ARTIST"`. */ relationType: string; - /** Popularity score (0–1 float). */ + /** Popularity score (0-1 float). */ popularity: number; /** External link entries (e.g. TIDAL sharing URL). */ externalLinks: Array<{ href: string; meta: { type: string } }>; @@ -911,7 +911,7 @@ export interface TopVideosResponse extends VersionedResponse { export interface TidalAudioNormData { /** Replay gain value in dB. */ replayGain: number; - /** Peak amplitude (0–1). */ + /** Peak amplitude (0-1). */ peakAmplitude: number; } @@ -961,7 +961,7 @@ export interface TrackManifestAttributes { export interface TrackManifestResource { /** Resource identifier (track ID as a string). */ id: string; - /** JSON:API resource type — always `"trackManifests"`. */ + /** JSON:API resource type - always `"trackManifests"`. */ type: string; /** Manifest attributes. */ attributes: TrackManifestAttributes; diff --git a/js/app.js b/js/app.js index 8293949..c60adaf 100644 --- a/js/app.js +++ b/js/app.js @@ -513,7 +513,7 @@ document.addEventListener('DOMContentLoaded', async () => { * visited the local tab yet). */ async function scanLocalMediaFolder(onlyIfAlreadyScanned = false) { - // Skip the scan if the user has never visited the local tab – they'll + // Skip the scan if the user has never visited the local tab - they'll // get a fresh scan when they navigate there for the first time. if (onlyIfAlreadyScanned && !window.localFilesCache) return; diff --git a/js/audio-context.js b/js/audio-context.js index d17f95c..cc4dce5 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -1252,7 +1252,7 @@ class AudioContextManager { msChanged || this.filters.length !== count || (needsMS && this.midFilters.length !== count); if (needsRebuild) { - // M/S state changed or band count changed — full rebuild + // M/S state changed or band count changed - full rebuild this._destroyMSFilters(); this._destroyEQ(); this._createEQ(); @@ -1261,7 +1261,7 @@ class AudioContextManager { } this._connectGraph(); } else if (needsMS) { - // M/S active — update both parallel chains in-place + // M/S active - update both parallel chains in-place const now = this.audioContext.currentTime; // Update main filters (not connected in M/S mode, kept in sync for stereo fallback) @@ -1275,11 +1275,11 @@ class AudioContextManager { const sideGains = newGains.map((g, i) => (newChannels[i] === 'mid' ? 0 : g)); this._updateFilterChain(this.sideFilters, newFrequencies, newTypes, newQs, sideGains, now); } else if (this.filters.length === count) { - // Normal stereo — update in-place + // Normal stereo - update in-place const now = this.audioContext.currentTime; this._updateFilterChain(this.filters, newFrequencies, newTypes, newQs, newGains, now); } else { - // Band count changed — must rebuild + // Band count changed - must rebuild this._destroyMSFilters(); this._destroyEQ(); this._createEQ(); @@ -1423,7 +1423,7 @@ class AudioContextManager { this.currentQs = qs; this.currentGains = gains; - // Reset M/S channel assignments — imported config has no channel info + // Reset M/S channel assignments - imported config has no channel info this.currentChannels = new Array(this.bandCount).fill('stereo'); this.msEnabled = false; diff --git a/js/binaural-dsp.js b/js/binaural-dsp.js index 402e9ae..78239a6 100644 --- a/js/binaural-dsp.js +++ b/js/binaural-dsp.js @@ -410,7 +410,7 @@ export class BinauralDSP { for (let i = 0; i < numChannels; i++) { const chInfo = CHANNEL_ANGLES_51[i]; if (chInfo.isLFE) { - // Placeholder — LFE uses gain node instead + // Placeholder - LFE uses gain node instead this._mcConvolversL.push(null); this._mcConvolversR.push(null); continue; diff --git a/js/downloads.js b/js/downloads.js index b8ec561..65476e4 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -499,7 +499,7 @@ async function bulkDownload({ * to the configured folder (Local Media Folder or saved Folder Picker handle), * or `null` if the feature is not active / no folder is configured. * - * In contrast to {@link createBulkWriter}, this never prompts the user – it + * In contrast to {@link createBulkWriter}, this never prompts the user - it * only succeeds when the folder is already known. */ async function createSingleTrackFolderWriter() { @@ -533,7 +533,7 @@ async function createSingleTrackFolderWriter() { // fall through to picker } } - // No usable saved handle – open the picker so the user can choose a folder. + // No usable saved handle - open the picker so the user can choose a folder. try { const writer = await FolderPickerWriter.create(); if (rememberFolder) { @@ -542,7 +542,7 @@ async function createSingleTrackFolderWriter() { return writer; } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { - // User cancelled the picker – return null so we fall back to the + // User cancelled the picker - return null so we fall back to the // normal browser download instead of erroring out. return null; } @@ -578,7 +578,7 @@ async function createBulkWriter(folderName) { // fall through to picker } } - // No usable handle – prompt and persist + // No usable handle - prompt and persist try { const writer = await FolderPickerWriter.create(); await db.saveSetting('local_folder_handle', writer.getDirHandle()); @@ -590,7 +590,7 @@ async function createBulkWriter(folderName) { return null; } } - // Browser without File System Access API – fall through to ZIP + // Browser without File System Access API - fall through to ZIP } // ── Folder Picker method ───────────────────────────────────────────────── diff --git a/js/hrtf-generator.js b/js/hrtf-generator.js index 1bf03f6..795f610 100644 --- a/js/hrtf-generator.js +++ b/js/hrtf-generator.js @@ -64,7 +64,7 @@ export function generateHRTF(audioContext, azimuthDeg, elevationDeg = 0) { const ipsiData = sourceOnRight ? rightData : leftData; const contraData = sourceOnRight ? leftData : rightData; - // Generate ipsilateral (near ear) IR — mostly a delayed impulse with slight coloring + // Generate ipsilateral (near ear) IR - mostly a delayed impulse with slight coloring // Ipsilateral ear (near source) receives sound first; contralateral ear is delayed by ITD const ipsiDelay = 0; const contraDelay = Math.abs(itdSamples); diff --git a/js/player.js b/js/player.js index 17d92c9..b9023a4 100644 --- a/js/player.js +++ b/js/player.js @@ -2150,7 +2150,7 @@ export class Player { await this._bgAudioPlugin.stop(); } } catch { - // Not running in Capacitor or plugin unavailable — ignore + // Not running in Capacitor or plugin unavailable - ignore } finally { this._bgAudioPending = false; } diff --git a/js/settings.js b/js/settings.js index 246528c..5fcdf4d 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1354,7 +1354,7 @@ export async function initializeSettings(scrobbler, player, api, ui) { for (let i = 0; i < count; i++) { const t = i / (count - 1); let freq = Math.round(min * Math.pow(max / min, t)); - // Ensure strictly increasing — rounding can produce duplicates at high band counts + // Ensure strictly increasing - rounding can produce duplicates at high band counts if (freqs.length > 0 && freq <= freqs[freqs.length - 1]) { freq = freqs[freqs.length - 1] + 1; } diff --git a/js/storage.js b/js/storage.js index 74237b2..2dab6ee 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1881,7 +1881,7 @@ export const binauralDspSettings = { try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(obj)); } catch { - // QuotaExceededError — storage full + // QuotaExceededError - storage full } }, diff --git a/js/visualizer.js b/js/visualizer.js index 2fb557c..bf099a4 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -37,7 +37,7 @@ export class Visualizer { // Pause animation loop when the app is backgrounded so the analyser's // FFT reads don't compete with the EQ biquad filter chain for audio - // thread time — the main cause of audio skipping with AutoEQ in background. + // thread time - the main cause of audio skipping with AutoEQ in background. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && this.isActive) { this._backgroundPaused = true; From 49fce2944c27109d0bebdcc658ef8ab4fb607613 Mon Sep 17 00:00:00 2001 From: p1nkhamster Date: Fri, 10 Apr 2026 20:48:04 +0200 Subject: [PATCH 76/76] fix: add good editor picks --- public/editors-picks-input.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/editors-picks-input.txt b/public/editors-picks-input.txt index 6418dc8..1b748c9 100644 --- a/public/editors-picks-input.txt +++ b/public/editors-picks-input.txt @@ -24,4 +24,5 @@ album:103897783 album:151728406 album:199412873 album:3280432 -album:37927851 \ No newline at end of file +album:37927851 +album:18083938