import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
import { logger } from '@strudel.cycles/core';
import { useEvent, cx } from '@strudel.cycles/react';
// import { cx } from '@strudel.cycles/react';
import { nanoid } from 'nanoid';
import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Reference } from './Reference';
import { themes } from './themes.mjs';
import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
import { getAudioContext, soundMap } from '@strudel.cycles/webaudio';
import { useStore } from '@nanostores/react';
import { FilesTab } from './FilesTab';
const TAURI = window.__TAURI__;
export function Footer({ context }) {
const footerContent = useRef();
const [log, setLog] = useState([]);
const { activeFooter, isZen, panelPosition } = useSettings();
useLayoutEffect(() => {
if (footerContent.current && activeFooter === 'console') {
// scroll log box to bottom when log changes
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
}
}, [log, activeFooter]);
useLayoutEffect(() => {
if (!footerContent.current) {
} else if (activeFooter === 'console') {
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
} else {
footerContent.current.scrollTop = 0;
}
}, [activeFooter]);
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const FooterTab = ({ children, name, label }) => (
<>
setActiveFooter(name)}
className={cx(
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
activeFooter === name ? 'border-foreground' : 'border-transparent',
)}
>
{label || name}
{activeFooter === name && <>{children}>}
>
);
if (isZen) {
return null;
}
const isActive = activeFooter !== '';
let positions = {
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
};
return (
{TAURI && }
{activeFooter !== '' && (
setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
)}
{activeFooter !== '' && (
{activeFooter === 'intro' && }
{activeFooter === 'console' && }
{activeFooter === 'sounds' && }
{activeFooter === 'reference' && }
{activeFooter === 'settings' && }
{activeFooter === 'files' && }
)}
);
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '$1 ');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1$2 ',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '$1 ');
return replacedText;
}
function WelcomeTab() {
return (
🌀 welcome
You have found strudel , a new live coding platform to write dynamic music
pieces in the browser! It is free and open-source and made for beginners and experts alike. To get started:
1. hit play - 2. change something -{' '}
3. hit update
If you don't like what you hear, try shuffle !
To learn more about what this all means, check out the{' '}
interactive tutorial
. Also feel free to join the{' '}
tidalcycles discord channel
{' '}
to ask any questions, give feedback or just say hello.
about
strudel is a JavaScript version of{' '}
tidalcycles
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
github
. Please consider to{' '}
support this project
{' '}
to ensure ongoing development 💖
);
}
function ConsoleTab({ log }) {
return (
{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔══██╗██╔════╝██║
███████╗ ██║ ██████╔╝██║ ██║██║ ██║█████╗ ██║
╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║██╔══╝ ██║
███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗███████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}
{log.map((l, i) => {
const message = linkify(l.message);
return (
{l.count ? ` (${l.count})` : ''}
);
})}
);
}
const getSamples = (samples) =>
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const soundEntries = useMemo(() => {
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
if (!sounds) {
return [];
}
if (soundsFilter === 'user') {
return filtered.filter(([key, { data }]) => !data.prebake);
}
if (soundsFilter === 'drums') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
}
if (soundsFilter === 'samples') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
}
if (soundsFilter === 'synths') {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
return filtered;
}, [sounds, soundsFilter]);
// holds mutable ref to current triggered sound
const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
trigRef.current = undefined;
t?.then((ref) => {
ref?.stop(getAudioContext().currentTime + 0.01);
});
});
return (
settingsMap.setKey('soundsFilter', value)}
items={{
samples: 'samples',
drums: 'drum-machines',
synths: 'Synths',
user: 'User',
}}
>
{soundEntries.map(([name, { data, onTrigger }]) => (
{
const ctx = getAudioContext();
const params = {
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
s: name,
clip: 1,
release: 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) => {
ref?.node.connect(ctx.destination);
});
}}
>
{' '}
{name}
{data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
{data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
))}
{!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
);
}
function Checkbox({ label, value, onChange }) {
return (
{' ' + label}
);
}
function ButtonGroup({ value, onChange, items }) {
return (
{Object.entries(items).map(([key, label], i, arr) => (
onChange(key)}
className={cx(
'px-2 border-b h-8',
// i === 0 && 'rounded-l-md',
// i === arr.length - 1 && 'rounded-r-md',
// value === key ? 'bg-background' : 'bg-lineHighlight',
value === key ? 'border-foreground' : 'border-transparent',
)}
>
{label.toLowerCase()}
))}
);
}
function SelectInput({ value, options, onChange }) {
return (
onChange(e.target.value)}
>
{Object.entries(options).map(([k, label]) => (
{label}
))}
);
}
function NumberSlider({ value, onChange, step = 1, ...rest }) {
return (
onChange(Number(e.target.value))}
{...rest}
/>
onChange(Number(e.target.value))}
/>
);
}
function FormItem({ label, children }) {
return (
{label}
{children}
);
}
const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
const fontFamilyOptions = {
monospace: 'monospace',
BigBlueTerminal: 'BigBlueTerminal',
x3270: 'x3270',
PressStart: 'PressStart2P',
galactico: 'galactico',
'we-come-in-peace': 'we-come-in-peace',
FiraCode: 'FiraCode',
'FiraCode-SemiBold': 'FiraCode SemiBold',
teletext: 'teletext',
mode7: 'mode7',
};
function SettingsTab({ scheduler }) {
const {
theme,
keybindings,
isLineNumbersDisplayed,
isActiveLineHighlighted,
isAutoCompletionEnabled,
isTooltipEnabled,
isLineWrappingEnabled,
fontSize,
fontFamily,
panelPosition,
} = useSettings();
return (
{/*
{
scheduler.setCps(scheduler.cps - 0.1);
}}
>
slower
{
scheduler.setCps(scheduler.cps + 0.1);
}}
>
faster
*/}
settingsMap.setKey('theme', theme)} />
settingsMap.setKey('fontFamily', fontFamily)}
/>
settingsMap.setKey('fontSize', fontSize)}
min={10}
max={40}
step={2}
/>
settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
>
settingsMap.setKey('panelPosition', value)}
items={{ bottom: 'Bottom', right: 'Right' }}
>
settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
value={isLineNumbersDisplayed}
/>
settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
value={isActiveLineHighlighted}
/>
settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
value={isAutoCompletionEnabled}
/>
settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
value={isTooltipEnabled}
/>
settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
value={isLineWrappingEnabled}
/>
Try clicking the logo in the top left!
{
if (confirm('Sure?')) {
settingsMap.set(defaultSettings);
}
}}
>
restore default settings
);
}