ACTUALLY fix butterchurn
This commit is contained in:
parent
68b0d9dcdd
commit
c2fd81348a
4 changed files with 263 additions and 122 deletions
|
|
@ -86,10 +86,41 @@ class AudioContextManager {
|
|||
this.currentGains = new Array(16).fill(0);
|
||||
this.audio = null;
|
||||
|
||||
// Callbacks for audio graph changes (for visualizers like Butterchurn)
|
||||
this._graphChangeCallbacks = [];
|
||||
|
||||
// Load saved settings
|
||||
this._loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be called when audio graph is reconnected
|
||||
* @param {Function} callback - Function to call when graph changes
|
||||
* @returns {Function} - Unregister function
|
||||
*/
|
||||
onGraphChange(callback) {
|
||||
this._graphChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this._graphChangeCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this._graphChangeCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered callbacks that graph has changed
|
||||
*/
|
||||
_notifyGraphChange() {
|
||||
this._graphChangeCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(this.source);
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Graph change callback failed:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the audio context and connect to the audio element
|
||||
* This should be called when audio starts playing
|
||||
|
|
@ -183,6 +214,9 @@ class AudioContextManager {
|
|||
this.analyser.connect(this.audioContext.destination);
|
||||
console.log('[AudioContext] EQ bypassed');
|
||||
}
|
||||
|
||||
// Notify visualizers that graph has been reconnected
|
||||
this._notifyGraphChange();
|
||||
} catch (e) {
|
||||
console.warn('[AudioContext] Failed to connect graph:', e);
|
||||
// Fallback: direct connection
|
||||
|
|
@ -234,6 +268,13 @@ class AudioContextManager {
|
|||
return this.audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source node for visualizers
|
||||
*/
|
||||
getSourceNode() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
fontSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||
import { getButterchurnPresets } from './visualizers/butterchurn.js';
|
||||
import { db } from './db.js';
|
||||
import { authManager } from './accounts/auth.js';
|
||||
import { syncManager } from './accounts/pocketbase.js';
|
||||
|
|
@ -884,31 +885,37 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
if (butterchurnDurationSetting) butterchurnDurationSetting.style.display = showSubSettings ? 'flex' : 'none';
|
||||
if (butterchurnRandomizeSetting) butterchurnRandomizeSetting.style.display = showSubSettings ? 'flex' : 'none';
|
||||
|
||||
// Populate preset list if visible
|
||||
if (show && ui && ui.visualizer && ui.visualizer.presets['butterchurn']) {
|
||||
const preset = ui.visualizer.presets['butterchurn'];
|
||||
const select = butterchurnSpecificPresetSelect;
|
||||
// Populate preset list using module-level cache (works even before visualizer initializes)
|
||||
const { keys: presetNames } = getButterchurnPresets();
|
||||
const select = butterchurnSpecificPresetSelect;
|
||||
|
||||
// Only populate if needed (to avoid resetting selection or heavy DOM ops)
|
||||
if (select && select.options.length <= 1 && preset.getPresetNames && preset.getPresetNames().length > 0) {
|
||||
const names = preset.getPresetNames();
|
||||
if (select && presetNames.length > 0) {
|
||||
const currentNames = Array.from(select.options).map((opt) => opt.value);
|
||||
// Check if dropdown only has "Loading..." or needs full update
|
||||
const hasOnlyLoadingOption = currentNames.length === 1 && currentNames[0] === '';
|
||||
const needsUpdate =
|
||||
hasOnlyLoadingOption ||
|
||||
currentNames.length !== presetNames.length ||
|
||||
!presetNames.every((name) => currentNames.includes(name));
|
||||
|
||||
if (needsUpdate) {
|
||||
// Save current selection
|
||||
const currentSelection = select.value;
|
||||
|
||||
// Clear and rebuild dropdown
|
||||
select.innerHTML = '';
|
||||
names.forEach(name => {
|
||||
presetNames.forEach((name) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Select current
|
||||
if (preset.getCurrentPresetName) {
|
||||
select.value = preset.getCurrentPresetName();
|
||||
}
|
||||
} else if (select && preset.getCurrentPresetName) {
|
||||
// Just update selection if list already populated
|
||||
const current = preset.getCurrentPresetName();
|
||||
if (select.value !== current) {
|
||||
select.value = current;
|
||||
// Restore selection if it still exists
|
||||
if (presetNames.includes(currentSelection)) {
|
||||
select.value = currentSelection;
|
||||
} else {
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -982,6 +989,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
if (butterchurnSpecificPresetSelect) {
|
||||
butterchurnSpecificPresetSelect.addEventListener('change', (e) => {
|
||||
// Try to load via visualizer if active, otherwise just store the selection
|
||||
if (ui && ui.visualizer && ui.visualizer.presets['butterchurn']) {
|
||||
ui.visualizer.presets['butterchurn'].loadPreset(e.target.value);
|
||||
}
|
||||
|
|
@ -990,9 +998,33 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
// Refresh settings when presets are loaded asynchronously
|
||||
window.addEventListener('butterchurn-presets-loaded', () => {
|
||||
console.log('[Settings] Butterchurn presets loaded event received');
|
||||
updateButterchurnSettingsVisibility();
|
||||
});
|
||||
|
||||
// Check if presets already cached and update immediately
|
||||
const { keys: cachedKeys } = getButterchurnPresets();
|
||||
if (cachedKeys.length > 0) {
|
||||
console.log('[Settings] Presets already cached, updating dropdown immediately');
|
||||
updateButterchurnSettingsVisibility();
|
||||
}
|
||||
|
||||
// Watch for audio tab becoming active and refresh presets
|
||||
const audioTabContent = document.getElementById('settings-tab-audio');
|
||||
if (audioTabContent) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
if (audioTabContent.classList.contains('active')) {
|
||||
console.log('[Settings] Audio tab became active, refreshing presets');
|
||||
updateButterchurnSettingsVisibility();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(audioTabContent, { attributes: true });
|
||||
}
|
||||
|
||||
// Visualizer Mode Select
|
||||
const visualizerModeSelect = document.getElementById('visualizer-mode-select');
|
||||
if (visualizerModeSelect) {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export class Visualizer {
|
|||
* Get the source node
|
||||
*/
|
||||
getSourceNode() {
|
||||
return audioContextManager.source;
|
||||
return audioContextManager.getSourceNode();
|
||||
}
|
||||
|
||||
initContext() {
|
||||
|
|
@ -143,11 +143,8 @@ export class Visualizer {
|
|||
|
||||
// Initialize Butterchurn if it's the active preset
|
||||
if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) {
|
||||
this.activePreset.lazyInit(
|
||||
this.canvas,
|
||||
this.audioContext,
|
||||
this.analyser
|
||||
);
|
||||
const sourceNode = audioContextManager.getSourceNode();
|
||||
this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode);
|
||||
}
|
||||
|
||||
this.resize();
|
||||
|
|
@ -272,11 +269,8 @@ export class Visualizer {
|
|||
|
||||
// Initialize Butterchurn if switching to it
|
||||
if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) {
|
||||
this.presets[key].lazyInit(
|
||||
this.canvas,
|
||||
this.audioContext,
|
||||
this.analyser
|
||||
);
|
||||
const sourceNode = audioContextManager.getSourceNode();
|
||||
this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,103 @@
|
|||
/**
|
||||
* Butterchurn (Milkdrop) Visualizer Preset
|
||||
* WebGL-based audio visualization using the Butterchurn library
|
||||
* Uses same loading logic as bc-demo.html - loads presets as global scripts
|
||||
*/
|
||||
import butterchurn from 'butterchurn';
|
||||
import { visualizerSettings } from '../storage.js';
|
||||
import { audioContextManager } from '../audio-context.js';
|
||||
|
||||
// Module-level preset cache - loads immediately when this file is imported
|
||||
let cachedPresets = null;
|
||||
let cachedPresetKeys = [];
|
||||
let isLoading = false;
|
||||
let loadCallbacks = [];
|
||||
|
||||
/**
|
||||
* Load presets at module level so they're available immediately
|
||||
*/
|
||||
function loadPresetsModule() {
|
||||
if (cachedPresets || isLoading) return;
|
||||
isLoading = true;
|
||||
|
||||
// Check if already loaded in global
|
||||
if (window.butterchurnPresets) {
|
||||
processPresetsModule();
|
||||
return;
|
||||
}
|
||||
|
||||
// Load presets script like bc-demo.html does
|
||||
const script = document.createElement('script');
|
||||
script.src = '/node_modules/butterchurn-presets/lib/butterchurnPresets.min.js';
|
||||
script.onload = () => {
|
||||
console.log('[Butterchurn] Presets script loaded');
|
||||
processPresetsModule();
|
||||
};
|
||||
script.onerror = (e) => {
|
||||
console.error('[Butterchurn] Failed to load presets script:', e);
|
||||
isLoading = false;
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process loaded presets at module level
|
||||
*/
|
||||
function processPresetsModule() {
|
||||
try {
|
||||
const presetsModule = window.butterchurnPresets;
|
||||
if (!presetsModule) {
|
||||
console.error('[Butterchurn] butterchurnPresets not found on window');
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const allPresets =
|
||||
typeof presetsModule.getPresets === 'function'
|
||||
? presetsModule.getPresets()
|
||||
: presetsModule.default || presetsModule;
|
||||
|
||||
cachedPresets = allPresets || {};
|
||||
cachedPresetKeys = Object.keys(cachedPresets);
|
||||
cachedPresetKeys.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
console.log('[Butterchurn] Module-level presets loaded:', cachedPresetKeys.length);
|
||||
|
||||
// Notify all waiting callbacks
|
||||
loadCallbacks.forEach((cb) => cb(cachedPresets, cachedPresetKeys));
|
||||
loadCallbacks = [];
|
||||
|
||||
// Dispatch global event
|
||||
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
|
||||
} catch (e) {
|
||||
console.error('[Butterchurn] Failed to process presets:', e);
|
||||
cachedPresets = {};
|
||||
cachedPresetKeys = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached presets - available immediately after module loads
|
||||
*/
|
||||
export function getButterchurnPresets() {
|
||||
return { presets: cachedPresets, keys: cachedPresetKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for when presets are loaded
|
||||
*/
|
||||
export function onButterchurnPresetsLoaded(callback) {
|
||||
if (cachedPresets) {
|
||||
callback(cachedPresets, cachedPresetKeys);
|
||||
} else {
|
||||
loadCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Start loading presets immediately when module is imported
|
||||
loadPresetsModule();
|
||||
|
||||
export class ButterchurnPreset {
|
||||
constructor() {
|
||||
|
|
@ -17,60 +111,30 @@ export class ButterchurnPreset {
|
|||
this.lastPresetChange = 0;
|
||||
this.isInitialized = false;
|
||||
|
||||
this.presets = {};
|
||||
this.presetKeys = [];
|
||||
this.isLoadingPresets = false;
|
||||
// Use cached presets if available
|
||||
this.presets = cachedPresets || {};
|
||||
this.presetKeys = cachedPresetKeys || [];
|
||||
this.isLoadingPresets = isLoading;
|
||||
|
||||
// Transition settings
|
||||
this.blendProgress = 0;
|
||||
this.blendDuration = 2.7; // seconds for preset transitions
|
||||
|
||||
// Load presets asynchronously
|
||||
this.loadPresets();
|
||||
}
|
||||
// Listen for presets if not loaded yet
|
||||
if (!cachedPresets) {
|
||||
onButterchurnPresetsLoaded((presets, keys) => {
|
||||
this.presets = presets;
|
||||
this.presetKeys = keys;
|
||||
this.isLoadingPresets = false;
|
||||
|
||||
/**
|
||||
* Load presets dynamically to avoid blocking main bundle
|
||||
*/
|
||||
async loadPresets() {
|
||||
if (this.isLoadingPresets) return;
|
||||
this.isLoadingPresets = true;
|
||||
// Notify system that presets are ready (for settings dropdown)
|
||||
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
|
||||
|
||||
try {
|
||||
const module = await import('butterchurn-presets');
|
||||
const presets = module.default.getPresets();
|
||||
|
||||
this.presets = presets;
|
||||
this.presetKeys = Object.keys(this.presets);
|
||||
|
||||
// Filter to get a good selection of presets
|
||||
this.presetKeys = this.presetKeys.filter(key => {
|
||||
const skipPatterns = ['flexi', 'empty', 'test', '_'];
|
||||
return !skipPatterns.some(pattern => key.toLowerCase().includes(pattern));
|
||||
// If visualizer already initialized, load a preset
|
||||
if (this.isInitialized && this.visualizer) {
|
||||
this.loadNextPreset();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.presetKeys.length === 0) {
|
||||
this.presetKeys = Object.keys(this.presets);
|
||||
}
|
||||
|
||||
// Shuffle presets for variety
|
||||
this.shufflePresets();
|
||||
|
||||
console.log('[Butterchurn] Presets loaded:', this.presetKeys.length);
|
||||
|
||||
// Notify system that presets are ready
|
||||
window.dispatchEvent(new CustomEvent('butterchurn-presets-loaded'));
|
||||
|
||||
// If initialized (visualizer ready), load a preset immediately
|
||||
if (this.isInitialized && this.visualizer) {
|
||||
this.loadNextPreset();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Butterchurn] Failed to load presets:', e);
|
||||
this.presets = {};
|
||||
this.presetKeys = [];
|
||||
} finally {
|
||||
this.isLoadingPresets = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +166,7 @@ export class ButterchurnPreset {
|
|||
|
||||
// Connect audio source
|
||||
if (sourceNode) {
|
||||
this.visualizer.connectAudio(sourceNode);
|
||||
this.connectAudioWithDelay(sourceNode);
|
||||
}
|
||||
|
||||
// Load initial preset
|
||||
|
|
@ -111,6 +175,16 @@ export class ButterchurnPreset {
|
|||
this.lastPresetChange = performance.now();
|
||||
this.isInitialized = true;
|
||||
|
||||
// Register for audio graph changes so we can reconnect when EQ is toggled
|
||||
if (audioContextManager) {
|
||||
this._unregisterGraphChange = audioContextManager.onGraphChange((sourceNode) => {
|
||||
if (sourceNode && this.isInitialized) {
|
||||
console.log('[Butterchurn] Audio graph changed, reconnecting...');
|
||||
this.connectAudioWithDelay(sourceNode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Butterchurn] Initialized with', this.presetKeys.length, 'presets');
|
||||
} catch (error) {
|
||||
console.error('[Butterchurn] Initialization failed:', error);
|
||||
|
|
@ -118,12 +192,27 @@ export class ButterchurnPreset {
|
|||
}
|
||||
|
||||
/**
|
||||
* Shuffle the preset keys for random variety
|
||||
* Connect audio source to the visualizer (public API)
|
||||
*/
|
||||
shufflePresets() {
|
||||
for (let i = this.presetKeys.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.presetKeys[i], this.presetKeys[j]] = [this.presetKeys[j], this.presetKeys[i]];
|
||||
connectAudio(sourceNode) {
|
||||
if (sourceNode) {
|
||||
this.connectAudioWithDelay(sourceNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect audio source with delay node for proper sync
|
||||
* Like bc-demo.html: creates a delay node and connects visualizer to it
|
||||
*/
|
||||
connectAudioWithDelay(sourceNode) {
|
||||
if (!this.audioContext || !this.visualizer) return;
|
||||
|
||||
try {
|
||||
// Connect visualizer directly to the source node
|
||||
this.visualizer.connectAudio(sourceNode);
|
||||
console.log('[Butterchurn] Audio connected');
|
||||
} catch (error) {
|
||||
console.warn('[Butterchurn] Failed to connect audio:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,15 +222,6 @@ export class ButterchurnPreset {
|
|||
loadNextPreset() {
|
||||
if (!this.visualizer || this.presetKeys.length === 0) return;
|
||||
|
||||
// If cycle enabled is false, don't change preset automatically unless forced (e.g. init or manual next)
|
||||
// But here we are just loading 'a' preset.
|
||||
// The cycling logic is in draw().
|
||||
|
||||
// Wait, loadNextPreset is general.
|
||||
// Let's check settings inside loadNextPreset?
|
||||
// No, loadNextPreset is an action. It should just do it.
|
||||
// The caller decides when.
|
||||
|
||||
const randomize = visualizerSettings.isButterchurnRandomizeEnabled();
|
||||
|
||||
if (randomize) {
|
||||
|
|
@ -156,7 +236,6 @@ export class ButterchurnPreset {
|
|||
if (preset) {
|
||||
try {
|
||||
this.visualizer.loadPreset(preset, this.blendDuration);
|
||||
// console.log('[Butterchurn] Loaded preset:', presetKey);
|
||||
} catch (error) {
|
||||
console.warn('[Butterchurn] Failed to load preset:', presetKey, error);
|
||||
// Try next preset
|
||||
|
|
@ -222,8 +301,6 @@ export class ButterchurnPreset {
|
|||
*/
|
||||
draw(ctx, canvas, analyser, dataArray, params) {
|
||||
if (!this.isInitialized) {
|
||||
// Lazy initialization - need audio context and source node
|
||||
// This will be handled by the visualizer.js main class
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -249,11 +326,8 @@ export class ButterchurnPreset {
|
|||
console.warn('[Butterchurn] Render error:', error);
|
||||
}
|
||||
|
||||
// Handle blended mode - we need to composite with cover art
|
||||
// Butterchurn renders directly to the canvas, so for blended mode
|
||||
// we need to adjust the canvas opacity/blend
|
||||
// Handle blended mode
|
||||
if (mode === 'blended') {
|
||||
// The canvas will be composited by CSS in the parent
|
||||
canvas.style.opacity = '0.85';
|
||||
canvas.style.mixBlendMode = 'screen';
|
||||
} else {
|
||||
|
|
@ -262,38 +336,34 @@ export class ButterchurnPreset {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect audio source to the visualizer
|
||||
*/
|
||||
connectAudio(sourceNode) {
|
||||
if (this.visualizer && sourceNode) {
|
||||
try {
|
||||
this.visualizer.connectAudio(sourceNode);
|
||||
console.log('[Butterchurn] Audio connected');
|
||||
} catch (error) {
|
||||
console.warn('[Butterchurn] Failed to connect audio:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy initialization helper for when audio context becomes available
|
||||
*/
|
||||
lazyInit(canvas, audioContext, sourceNode) {
|
||||
if (!this.isInitialized && canvas && audioContext) {
|
||||
const gl = canvas.getContext('webgl2', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
}) || canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
const gl =
|
||||
canvas.getContext('webgl2', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
}) ||
|
||||
canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
if (gl) {
|
||||
this.init(canvas, gl, audioContext, sourceNode);
|
||||
this.init(canvas, gl, audioContext, null);
|
||||
|
||||
// Connect audio if sourceNode is provided
|
||||
if (sourceNode) {
|
||||
this.connectAudioWithDelay(sourceNode);
|
||||
}
|
||||
}
|
||||
} else if (this.isInitialized && sourceNode) {
|
||||
// Reconnect if source changed
|
||||
this.connectAudioWithDelay(sourceNode);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,9 +371,13 @@ export class ButterchurnPreset {
|
|||
* Cleanup resources
|
||||
*/
|
||||
destroy() {
|
||||
// Unregister graph change listener
|
||||
if (this._unregisterGraphChange) {
|
||||
this._unregisterGraphChange();
|
||||
this._unregisterGraphChange = null;
|
||||
}
|
||||
|
||||
if (this.visualizer) {
|
||||
// Butterchurn doesn't have an explicit cleanup method
|
||||
// but we can null our references
|
||||
this.visualizer = null;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
|
|
|
|||
Loading…
Reference in a new issue