From f2135cc455008c9be5b3cacf15628ff7bcfe35e3 Mon Sep 17 00:00:00 2001 From: a <252674497+ap5z@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:46:50 -0400 Subject: [PATCH 1/6] fix: visualizer bugs and better mobile support (#509) * Refine fullscreen player to look more like apple music * fix: buttons when in visualizer only mode * fix: mobile sizing * Update styles.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update js/ui.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: refine fullscreen apple player * fix: add lyrics toggle for mobile * add lyrics toggle for mobile * wrong branch oops :sob: --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fix-gen.py | 1 - index.html | 18 +- js/lyrics.js | 8 + js/ui.js | 298 ++++++++++++++++-- js/visualizer.js | 12 + styles.css | 776 +++++++++++++++++++++++++++-------------------- 6 files changed, 752 insertions(+), 361 deletions(-) diff --git a/fix-gen.py b/fix-gen.py index 7ef2989..938291a 100644 --- a/fix-gen.py +++ b/fix-gen.py @@ -19,4 +19,3 @@ new_func = """def download_and_process_cover(cover_uuid): content = re.sub(r"def download_and_process_cover\(cover_uuid\):[\s\S]*?(?=def process_cover)", new_func + "\n\n", content) with open("gen-editors-picks.py", "w") as f: f.write(content) - diff --git a/index.html b/index.html index 9f1a371..605a632 100644 --- a/index.html +++ b/index.html @@ -205,13 +205,17 @@ z-index: 0; " > + + -
+ @@ -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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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