refactor butterchurn state to prevent rerender flicker on cycle

This commit is contained in:
jeffvli
2026-01-20 17:58:58 -08:00
parent a5940a9124
commit 9318d00a5b
2 changed files with 181 additions and 152 deletions
@@ -5,7 +5,12 @@ import styles from './visualizer.module.css';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal'; import { openVisualizerSettingsModal } from '/@/renderer/features/player/utils/open-visualizer-settings-modal';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary'; import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store'; import {
subscribeButterchurnPreset,
useButterchurnSettings,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store';
import { import {
useFullScreenPlayerStore, useFullScreenPlayerStore,
useFullScreenPlayerStoreActions, useFullScreenPlayerStoreActions,
@@ -39,7 +44,7 @@ const VisualizerInner = () => {
const cycleStartTimeRef = useRef<number | undefined>(undefined); const cycleStartTimeRef = useRef<number | undefined>(undefined);
const pauseTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); const pauseTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const initialPresetLoadedRef = useRef(false); const initialPresetLoadedRef = useRef(false);
const butterchurnSettings = useSettingsStore((store) => store.visualizer.butterchurn); const butterchurnSettings = useButterchurnSettings();
const opacity = useSettingsStore((store) => store.visualizer.butterchurn.opacity); const opacity = useSettingsStore((store) => store.visualizer.butterchurn.opacity);
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const playerStatus = usePlayerStatus(); const playerStatus = usePlayerStatus();
@@ -249,10 +254,9 @@ const VisualizerInner = () => {
const presetNames = Object.keys(presets); const presetNames = Object.keys(presets);
if (presetNames.length > 0) { if (presetNames.length > 0) {
const currentPreset = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
const presetName = const presetName =
butterchurnSettings.currentPreset && presets[butterchurnSettings.currentPreset] currentPreset && presets[currentPreset] ? currentPreset : presetNames[0];
? butterchurnSettings.currentPreset
: presetNames[0];
const preset = presets[presetName]; const preset = presets[presetName];
if (preset) { if (preset) {
@@ -261,53 +265,14 @@ const VisualizerInner = () => {
initialPresetLoadedRef.current = true; initialPresetLoadedRef.current = true;
} }
} }
}, [ }, [isVisualizerReady, butterchurnSettings.blendTime, librariesLoaded]);
isVisualizerReady,
butterchurnSettings.currentPreset,
butterchurnSettings.blendTime,
librariesLoaded,
]);
// Update preset when currentPreset or blendTime changes (but not when cycling)
const isCyclingRef = useRef(false); const isCyclingRef = useRef(false);
useEffect(() => {
const visualizer = visualizerRef.current;
if (
!visualizer ||
!butterchurnSettings.currentPreset ||
!initialPresetLoadedRef.current ||
!librariesLoaded
)
return;
// Skip if we're currently cycling (to avoid reloading preset)
if (isCyclingRef.current) {
isCyclingRef.current = false;
return;
}
const presets = butterchurnPresetsRef.current;
if (!presets) return;
const preset = presets[butterchurnSettings.currentPreset];
if (preset) {
visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);
// Reset cycle timer when preset changes manually
cycleStartTimeRef.current = Date.now();
}
}, [butterchurnSettings.currentPreset, butterchurnSettings.blendTime, librariesLoaded]);
// Handle preset cycling // Handle preset cycling
useEffect(() => { useEffect(() => {
const visualizer = visualizerRef.current; const visualizer = visualizerRef.current;
if ( if (!visualizer || !butterchurnSettings.cyclePresets || !librariesLoaded) {
!visualizer ||
!butterchurnSettings.cyclePresets ||
!initialPresetLoadedRef.current ||
!librariesLoaded
) {
// Clear cycle timer if cycling is disabled or visualizer not ready
if (cycleTimerRef.current) { if (cycleTimerRef.current) {
clearInterval(cycleTimerRef.current); clearInterval(cycleTimerRef.current);
cycleTimerRef.current = undefined; cycleTimerRef.current = undefined;
@@ -316,6 +281,7 @@ const VisualizerInner = () => {
} }
const presets = butterchurnPresetsRef.current; const presets = butterchurnPresetsRef.current;
if (!presets) return; if (!presets) return;
const allPresetNames = Object.keys(presets); const allPresetNames = Object.keys(presets);
@@ -342,7 +308,8 @@ const VisualizerInner = () => {
const currentVisualizer = visualizerRef.current; const currentVisualizer = visualizerRef.current;
if (!currentVisualizer) return; if (!currentVisualizer) return;
const currentPresetName = butterchurnSettings.currentPreset; const currentPresetName =
useSettingsStore.getState().visualizer.butterchurn.currentPreset;
let nextPresetName: string; let nextPresetName: string;
if (butterchurnSettings.randomizeNextPreset) { if (butterchurnSettings.randomizeNextPreset) {
@@ -365,16 +332,12 @@ const VisualizerInner = () => {
const nextPreset = presets[nextPresetName]; const nextPreset = presets[nextPresetName];
if (nextPreset) { if (nextPreset) {
// Get current settings to ensure we use the latest blendTime
const currentSettings = useSettingsStore.getState().visualizer.butterchurn; const currentSettings = useSettingsStore.getState().visualizer.butterchurn;
// Mark that we're cycling to prevent the preset change effect from running
isCyclingRef.current = true; isCyclingRef.current = true;
// Load the preset with blending
currentVisualizer.loadPreset(nextPreset, currentSettings.blendTime || 0.0); currentVisualizer.loadPreset(nextPreset, currentSettings.blendTime || 0.0);
// Update currentPreset in settings
setSettings({ setSettings({
visualizer: { visualizer: {
butterchurn: { butterchurn: {
@@ -387,7 +350,6 @@ const VisualizerInner = () => {
} }
}; };
// Check every second if it's time to cycle
cycleTimerRef.current = setInterval(() => { cycleTimerRef.current = setInterval(() => {
if (cycleStartTimeRef.current === undefined) { if (cycleStartTimeRef.current === undefined) {
cycleStartTimeRef.current = Date.now(); cycleStartTimeRef.current = Date.now();
@@ -413,7 +375,6 @@ const VisualizerInner = () => {
butterchurnSettings.selectedPresets, butterchurnSettings.selectedPresets,
butterchurnSettings.ignoredPresets, butterchurnSettings.ignoredPresets,
butterchurnSettings.randomizeNextPreset, butterchurnSettings.randomizeNextPreset,
butterchurnSettings.currentPreset,
setSettings, setSettings,
librariesLoaded, librariesLoaded,
]); ]);
@@ -453,8 +414,40 @@ const VisualizerInner = () => {
}; };
}, [isVisualizerReady, butterchurnSettings.maxFPS]); }, [isVisualizerReady, butterchurnSettings.maxFPS]);
// Render container when playing (for initialization) or when visualizer exists // Handle preset changes via subscriber
// Canvas must always be rendered when container is rendered so refs are available useEffect(() => {
const unsubscribe = subscribeButterchurnPreset((presetName) => {
const visualizer = visualizerRef.current;
if (
!visualizer ||
!isVisualizerReady ||
!librariesLoaded ||
!presetName ||
!initialPresetLoadedRef.current
) {
return;
}
if (isCyclingRef.current) {
isCyclingRef.current = false;
return;
}
const presets = butterchurnPresetsRef.current;
if (!presets) return;
const preset = presets[presetName];
if (preset && typeof preset === 'object') {
visualizer.loadPreset(preset, butterchurnSettings.blendTime || 0.0);
cycleStartTimeRef.current = Date.now();
}
});
return () => {
unsubscribe();
};
}, [isVisualizerReady, librariesLoaded, butterchurnSettings.blendTime]);
const shouldRenderContainer = isPlaying || isVisualizerReady; const shouldRenderContainer = isPlaying || isVisualizerReady;
if (!shouldRenderContainer) { if (!shouldRenderContainer) {
@@ -468,20 +461,25 @@ const VisualizerInner = () => {
style={{ opacity: isVisualizerReady ? opacity : 0 }} style={{ opacity: isVisualizerReady ? opacity : 0 }}
> >
<canvas className={styles.canvas} ref={canvasRef} /> <canvas className={styles.canvas} ref={canvasRef} />
{isVisualizerReady && butterchurnSettings.currentPreset && ( {isVisualizerReady && <CurrentPresetDisplay />}
<Text className={styles['preset-overlay']} isNoSelect size="sm">
{butterchurnSettings.currentPreset}
</Text>
)}
</div> </div>
); );
}; };
const CurrentPresetDisplay = () => {
const currentPreset = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
return (
<Text className={styles['preset-overlay']} isNoSelect size="sm">
{currentPreset}
</Text>
);
};
export const Visualizer = () => { export const Visualizer = () => {
const { visualizerExpanded } = useFullScreenPlayerStore(); const { visualizerExpanded } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions(); const { setStore } = useFullScreenPlayerStoreActions();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const butterchurnSettings = useSettingsStore((store) => store.visualizer.butterchurn); const butterchurnSettings = useButterchurnSettings();
const [presetsLoaded, setPresetsLoaded] = useState(false); const [presetsLoaded, setPresetsLoaded] = useState(false);
const butterchurnPresetsRef = useRef<any>(null); const butterchurnPresetsRef = useRef<any>(null);
@@ -542,7 +540,7 @@ export const Visualizer = () => {
const presetList = getPresetList(); const presetList = getPresetList();
if (presetList.length === 0) return; if (presetList.length === 0) return;
const currentPresetName = butterchurnSettings.currentPreset; const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1; const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
const nextIndex = const nextIndex =
currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0; currentIndex >= 0 && currentIndex < presetList.length - 1 ? currentIndex + 1 : 0;
@@ -563,7 +561,7 @@ export const Visualizer = () => {
const presetList = getPresetList(); const presetList = getPresetList();
if (presetList.length === 0) return; if (presetList.length === 0) return;
const currentPresetName = butterchurnSettings.currentPreset; const currentPresetName = useSettingsStore.getState().visualizer.butterchurn.currentPreset;
const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1; const currentIndex = currentPresetName ? presetList.indexOf(currentPresetName) : -1;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1; const prevIndex = currentIndex > 0 ? currentIndex - 1 : presetList.length - 1;
const prevPresetName = presetList[prevIndex]; const prevPresetName = presetList[prevIndex];
+120 -89
View File
@@ -3,7 +3,7 @@ import mergeWith from 'lodash/mergeWith';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { generatePath } from 'react-router'; import { generatePath } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer'; import { immer } from 'zustand/middleware/immer';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
@@ -1631,98 +1631,102 @@ const initialState: SettingsState = {
export const useSettingsStore = createWithEqualityFn<SettingsSlice>()( export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
persist( persist(
devtools( devtools(
immer((set) => ({ subscribeWithSelector(
actions: { immer((set) => ({
reset: () => { actions: {
localStorage.removeItem('store_settings'); reset: () => {
window.location.reload(); localStorage.removeItem('store_settings');
}, window.location.reload();
resetSampleRate: () => { },
set((state) => { resetSampleRate: () => {
state.playback.mpvProperties.audioSampleRateHz = 0; set((state) => {
}); state.playback.mpvProperties.audioSampleRateHz = 0;
}, });
setArtistItems: (items) => { },
set((state) => { setArtistItems: (items) => {
state.general.artistItems = items; set((state) => {
}); state.general.artistItems = items;
}, });
setArtistReleaseTypeItems: (items: SortableItem<ArtistReleaseTypeItem>[]) => { },
set((state) => { setArtistReleaseTypeItems: (
state.general.artistReleaseTypeItems = items; items: SortableItem<ArtistReleaseTypeItem>[],
}); ) => {
}, set((state) => {
setGenreBehavior: (target: GenreTarget) => { state.general.artistReleaseTypeItems = items;
set((state) => { });
state.general.genreTarget = target; },
}); setGenreBehavior: (target: GenreTarget) => {
}, set((state) => {
setHomeItems: (items: SortableItem<HomeItem>[]) => { state.general.genreTarget = target;
set((state) => { });
state.general.homeItems = items; },
}); setHomeItems: (items: SortableItem<HomeItem>[]) => {
}, set((state) => {
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => { state.general.homeItems = items;
set((state) => { });
const listState = state.lists[type]; },
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => {
set((state) => {
const listState = state.lists[type];
if (listState && data.table) { if (listState && data.table) {
Object.assign(listState.table, data.table); Object.assign(listState.table, data.table);
delete data.table; delete data.table;
} }
if (listState && data.grid) { if (listState && data.grid) {
Object.assign(listState.grid, data.grid); Object.assign(listState.grid, data.grid);
delete data.grid; delete data.grid;
} }
if (listState) { if (listState) {
Object.assign(listState, data); Object.assign(listState, data);
} }
}); });
},
setPlaybackFilters: (filters: PlayerFilter[]) => {
set((state) => {
state.playback.filters = filters;
});
},
setSettings: (data) => {
set((state) => {
deepMergeIntoState(state, data);
});
},
setSidebarItems: (items: SidebarItemType[]) => {
set((state) => {
state.general.sidebarItems = items;
});
},
setTable: (type: ItemListKey, data: DataTableProps) => {
set((state) => {
const listState = state.lists[type];
if (listState) {
listState.table = data;
}
});
},
setTranscodingConfig: (config) => {
set((state) => {
state.playback.transcode = config;
});
},
toggleMediaSession: () => {
set((state) => {
state.playback.mediaSession = !state.playback.mediaSession;
});
},
toggleSidebarCollapseShare: () => {
set((state) => {
state.general.sidebarCollapseShared =
!state.general.sidebarCollapseShared;
});
},
}, },
setPlaybackFilters: (filters: PlayerFilter[]) => { ...initialState,
set((state) => { })),
state.playback.filters = filters; ),
});
},
setSettings: (data) => {
set((state) => {
deepMergeIntoState(state, data);
});
},
setSidebarItems: (items: SidebarItemType[]) => {
set((state) => {
state.general.sidebarItems = items;
});
},
setTable: (type: ItemListKey, data: DataTableProps) => {
set((state) => {
const listState = state.lists[type];
if (listState) {
listState.table = data;
}
});
},
setTranscodingConfig: (config) => {
set((state) => {
state.playback.transcode = config;
});
},
toggleMediaSession: () => {
set((state) => {
state.playback.mediaSession = !state.playback.mediaSession;
});
},
toggleSidebarCollapseShare: () => {
set((state) => {
state.general.sidebarCollapseShared =
!state.general.sidebarCollapseShared;
});
},
},
...initialState,
})),
{ name: 'store_settings' }, { name: 'store_settings' },
), ),
{ {
@@ -2175,3 +2179,30 @@ export const useShowVisualizerInSidebar = () =>
export const useAutoDJSettings = () => useSettingsStore((store) => store.autoDJ, shallow); export const useAutoDJSettings = () => useSettingsStore((store) => store.autoDJ, shallow);
export const useVisualizerSettings = () => useSettingsStore((store) => store.visualizer, shallow); export const useVisualizerSettings = () => useSettingsStore((store) => store.visualizer, shallow);
export const subscribeButterchurnPreset = (
onChange: (preset: string | undefined, prevPreset: string | undefined) => void,
) => {
return useSettingsStore.subscribe(
(state) => state.visualizer.butterchurn.currentPreset,
(preset, prevPreset) => {
onChange(preset, prevPreset);
},
);
};
export const useButterchurnSettings = () => {
return useSettingsStore((store) => {
return {
blendTime: store.visualizer.butterchurn.blendTime,
cyclePresets: store.visualizer.butterchurn.cyclePresets,
cycleTime: store.visualizer.butterchurn.cycleTime,
ignoredPresets: store.visualizer.butterchurn.ignoredPresets,
includeAllPresets: store.visualizer.butterchurn.includeAllPresets,
maxFPS: store.visualizer.butterchurn.maxFPS,
opacity: store.visualizer.butterchurn.opacity,
randomizeNextPreset: store.visualizer.butterchurn.randomizeNextPreset,
selectedPresets: store.visualizer.butterchurn.selectedPresets,
};
}, shallow);
};