Compare commits

...

1 Commits

Author SHA1 Message Date
laema9 f3f9ec963a feat(sounds panel): group sounds by folder and allow starring favorites 2025-06-03 17:48:24 +02:00
+95 -52
View File
@@ -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 useEvent from '@src/useEvent.mjs';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio'; 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 { settingsMap, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx'; import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx'; import ImportSoundsButton from './ImportSoundsButton.jsx';
@@ -14,41 +19,61 @@ export function SoundsTab() {
const sounds = useStore(soundMap); const sounds = useStore(soundMap);
const { soundsFilter } = useSettings(); const { soundsFilter } = useSettings();
const [search, setSearch] = useState(''); 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 { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; 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(() => { const soundEntries = useMemo(() => {
if (!sounds) { if (!sounds) return [];
return []; return Object.entries(sounds)
}
let filtered = Object.entries(sounds)
.filter(([key]) => !key.startsWith('_')) .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') { if (soundsFilter === 'user') {
return filtered.filter(([_, { data }]) => !data.prebake); return soundEntries.filter(([_, { data }]) => !data.prebake);
} }
if (soundsFilter === 'drums') { 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') { 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') { 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') { if (soundsFilter === 'importSounds') {
return []; return [];
} }
return filtered; return soundEntries;
}, [sounds, soundsFilter, search]); }, [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(); const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => { useEvent('mouseup', () => {
const t = trigRef.current; const t = trigRef.current;
trigRef.current = undefined; trigRef.current = undefined;
@@ -56,6 +81,7 @@ export function SoundsTab() {
ref?.stop(getAudioContext().currentTime + 0.01); ref?.stop(getAudioContext().currentTime + 0.01);
}); });
}); });
return ( return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full text-foreground"> <div id="sounds-tab" className="px-4 flex flex-col w-full h-full text-foreground">
<Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} /> <Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
@@ -70,42 +96,60 @@ export function SoundsTab() {
synths: 'Synths', synths: 'Synths',
user: 'User', user: 'User',
importSounds: 'import-sounds', importSounds: 'import-sounds',
starred: '⭐',
}} }}
></ButtonGroup> ></ButtonGroup>
</div> </div>
<div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2"> <div className="min-h-0 max-h-full grow overflow-auto text-sm break-normal pb-2">
{soundEntries.map(([name, { data, onTrigger }]) => { {Object.entries(groupedByFolder).map(([folder, entries]) => (
return ( <details key={folder} className="mb-2">
<span <summary className="cursor-pointer font-semibold">{folder}</summary>
key={name} <div className="pl-4">
className="cursor-pointer hover:opacity-50" {entries.map(([name, { data, onTrigger }]) => (
onMouseDown={async () => { <div key={name} className="flex justify-between mr-5 items-center py-1">
const ctx = getAudioContext(); <span
const params = { className="cursor-pointer hover:opacity-50"
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined, onMouseDown={async () => {
s: name, const ctx = getAudioContext();
clip: 1, const params = {
release: 0.5, note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
sustain: 1, s: name,
duration: 0.5, clip: 1,
}; release: 0.5,
const time = ctx.currentTime + 0.05; sustain: 1,
const onended = () => trigRef.current?.node?.disconnect(); duration: 0.5,
trigRef.current = Promise.resolve(onTrigger(time, params, onended)); };
trigRef.current.then((ref) => { const time = ctx.currentTime + 0.05;
connectToDestination(ref?.node); 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})` : ''} {name} {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
</span> {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
); </span>
})} <button
{!soundEntries.length && soundsFilter === 'importSounds' ? ( className="ml-2 text-yellow-400 hover:text-yellow-600"
onClick={() => {
setStarred((prev) => {
const next = new Set(prev);
next.has(name) ? next.delete(name) : next.add(name);
return next;
});
}}
>
{starred.has(name) ? '★' : '☆'}
</button>
</div>
))}
</div>
</details>
))}
{!Object.keys(groupedByFolder).length && soundsFilter === 'importSounds' ? (
<div className="prose dark:prose-invert min-w-full pt-2 pb-8 px-4"> <div className="prose dark:prose-invert min-w-full pt-2 pb-8 px-4">
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} /> <ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
<p> <p>
@@ -148,10 +192,9 @@ export function SoundsTab() {
sample as it appears under the "user" tab. sample as it appears under the "user" tab.
</p> </p>
</div> </div>
) : ( ) : null}
''
)} {!Object.keys(groupedByFolder).length && soundsFilter !== 'importSounds'
{!soundEntries.length && soundsFilter !== 'importSounds'
? 'No custom sounds loaded in this pattern (yet).' ? 'No custom sounds loaded in this pattern (yet).'
: ''} : ''}
</div> </div>