diff --git a/website/src/repl/components/panel/SoundsTab.jsx b/website/src/repl/components/panel/SoundsTab.jsx index a976eb3d2..140f6e200 100644 --- a/website/src/repl/components/panel/SoundsTab.jsx +++ b/website/src/repl/components/panel/SoundsTab.jsx @@ -1,7 +1,12 @@ +// A small UI update to improve the "Sounds" section +// — better organization with folder grouping, the ability to star ⭐ favorite sounds, +// and a slightly cleaner interface. Next step: making it easier to preview individual sounds within folders, +// since right now you only see the total count without an easy way to listen to them one by one. + import useEvent from '@src/useEvent.mjs'; import { useStore } from '@nanostores/react'; import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; import { settingsMap, useSettings } from '../../../settings.mjs'; import { ButtonGroup } from './Forms.jsx'; import ImportSoundsButton from './ImportSoundsButton.jsx'; @@ -14,41 +19,61 @@ export function SoundsTab() { const sounds = useStore(soundMap); const { soundsFilter } = useSettings(); const [search, setSearch] = useState(''); + + // Adds persistent favorites using localStorage. Enhances user experience by allowing them to mark and recall sounds. + // (better way than using localStorage?) + const [starred, setStarred] = useState(() => new Set(JSON.parse(localStorage.getItem('starredSounds') || '[]'))); + const { BASE_URL } = import.meta.env; const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; + useEffect(() => { + localStorage.setItem('starredSounds', JSON.stringify(Array.from(starred))); + }, [starred]); // Automatically saves favorites when updated + const soundEntries = useMemo(() => { - if (!sounds) { - return []; - } - - let filtered = Object.entries(sounds) + if (!sounds) return []; + return Object.entries(sounds) .filter(([key]) => !key.startsWith('_')) - .sort((a, b) => a[0].localeCompare(b[0])) - .filter(([name]) => name.toLowerCase().includes(search.toLowerCase())); + .filter(([name]) => name.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => a[0].localeCompare(b[0])); + }, [sounds, search]); + const filteredEntries = useMemo(() => { if (soundsFilter === 'user') { - return filtered.filter(([_, { data }]) => !data.prebake); + return soundEntries.filter(([_, { data }]) => !data.prebake); } if (soundsFilter === 'drums') { - return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines'); + return soundEntries.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines'); } if (soundsFilter === 'samples') { - return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines'); + return soundEntries.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines'); } if (soundsFilter === 'synths') { - return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type)); + return soundEntries.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type)); + } + if (soundsFilter === 'starred') { + return soundEntries.filter(([name]) => starred.has(name)); } if (soundsFilter === 'importSounds') { return []; } - return filtered; - }, [sounds, soundsFilter, search]); + return soundEntries; + }, [soundEntries, soundsFilter, starred]); + + // Organizes sound list into collapsible folders for better navigation and visual clarity. + const groupedByFolder = useMemo(() => { + const groups = {}; + for (const [name, { data, onTrigger }] of filteredEntries) { + const folder = data.folder || name.split('(')[0]; + if (!groups[folder]) groups[folder] = []; + groups[folder].push([name, { data, onTrigger }]); + } + return groups; + }, [filteredEntries]); - // holds mutable ref to current triggered sound const trigRef = useRef(); - // stop current sound on mouseup useEvent('mouseup', () => { const t = trigRef.current; trigRef.current = undefined; @@ -56,6 +81,7 @@ export function SoundsTab() { ref?.stop(getAudioContext().currentTime + 0.01); }); }); + return (
setSearch(v)} /> @@ -70,42 +96,60 @@ export function SoundsTab() { synths: 'Synths', user: 'User', importSounds: 'import-sounds', + starred: '⭐', }} >
-
- {soundEntries.map(([name, { data, onTrigger }]) => { - return ( - { - const ctx = getAudioContext(); - const params = { - note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, - s: name, - clip: 1, - release: 0.5, - sustain: 1, - duration: 0.5, - }; - const time = ctx.currentTime + 0.05; - const onended = () => trigRef.current?.node?.disconnect(); - trigRef.current = Promise.resolve(onTrigger(time, params, onended)); - trigRef.current.then((ref) => { - connectToDestination(ref?.node); - }); - }} - > - {' '} - {name} - {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''} - {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''} - - ); - })} - {!soundEntries.length && soundsFilter === 'importSounds' ? ( +
+ {Object.entries(groupedByFolder).map(([folder, entries]) => ( +
+ {folder} +
+ {entries.map(([name, { data, onTrigger }]) => ( +
+ { + const ctx = getAudioContext(); + const params = { + note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, + s: name, + clip: 1, + release: 0.5, + sustain: 1, + duration: 0.5, + }; + const time = ctx.currentTime + 0.05; + const onended = () => trigRef.current?.node?.disconnect(); + trigRef.current = Promise.resolve(onTrigger(time, params, onended)); + trigRef.current.then((ref) => { + connectToDestination(ref?.node); + }); + }} + > + {name} {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''} + {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''} + + +
+ ))} +
+
+ ))} + + {!Object.keys(groupedByFolder).length && soundsFilter === 'importSounds' ? (
settingsMap.setKey('soundsFilter', 'user')} />

@@ -148,10 +192,9 @@ export function SoundsTab() { sample as it appears under the "user" tab.

- ) : ( - '' - )} - {!soundEntries.length && soundsFilter !== 'importSounds' + ) : null} + + {!Object.keys(groupedByFolder).length && soundsFilter !== 'importSounds' ? 'No custom sounds loaded in this pattern (yet).' : ''}