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] 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%;