diff --git a/demo/src/ExampleJSONScores.ts b/demo/src/ExampleJSONScores.ts index 83b8a95..bbd61f4 100644 --- a/demo/src/ExampleJSONScores.ts +++ b/demo/src/ExampleJSONScores.ts @@ -26,7 +26,7 @@ import * as RoxanneShiftedInfinite from '../../fixtures/roxanne-30s-preview-shif import * as TNGEngines from '../../fixtures/TNG-Crysknife007-16-899-s.wav'; import * as TNGJSON from '../../fixtures/TNG-Infinite-Idle-Engine.json'; -type ExampleJSON = { name: string; score: Score }; +type ExampleJSON = { slug: string; name: string; score: Score }; // Note: The double JSON.stringify/parsing is mostly for TypeScript, so it knows // that the incoming JSON is actually a Score. It's hard to tell TS that we @@ -34,10 +34,12 @@ type ExampleJSON = { name: string; score: Score }; const examples: ExampleJSON[] = [ { + slug: 'ratatat-forever', name: 'Ratatat forever', score: JSON.parse(JSON.stringify(RatatatLoop)).default, }, { + slug: 'tng-engines', name: 'Star Trek TNG Infinite Ambient Engine Noise', score: JSON.parse( JSON.stringify({ @@ -55,6 +57,7 @@ const examples: ExampleJSON[] = [ ).default, }, { + slug: 'roxanne-pitched-json', name: 'Roxanne, but pitched on every "Roxanne" (infinite JSON version)', score: JSON.parse(JSON.stringify(RoxanneShiftedInfinite)).default, }, diff --git a/demo/src/ExampleScripts.ts b/demo/src/ExampleScripts.ts index 0fb0dbf..228dbc2 100644 --- a/demo/src/ExampleScripts.ts +++ b/demo/src/ExampleScripts.ts @@ -41,12 +41,14 @@ export type CompiledPlaygroundScript = ( ) => Promise; export type ExampleScript = { + slug: string; name: string; script: PlaygroundScript; }; export const examples: ExampleScript[] = [ { + slug: 'tng-engines-scripted', name: 'Star Trek TNG Infinite Ambient Engine Noise (scripted)', script: ` const filePath = '${TNGEngines.default}'; @@ -161,6 +163,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'roxanne-pitched-scripted', name: 'Roxanne, but pitched on every "Roxanne" (infinite scripted version)', script: ` // These Globals are provided by this playground, and act as imported namespaces @@ -216,6 +219,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'play-10-to-20', name: 'Load and play seconds 10 through 20 of a single file', script: ` // These Globals are provided by this playground, and act as imported namespaces @@ -244,6 +248,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'play-then-loop', name: 'Play the first 10 seconds of a file, then loop a single 4 beat bar forever.', script: ` // These Globals are provided by this playground, and act as imported namespaces @@ -278,6 +283,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'fade-on-interaction', name: 'Play a track, then quickly fade out on user interaction', script: ` // These Globals are provided by this playground, and act as imported namespaces @@ -337,6 +343,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'swap-score-on-interaction', name: 'Loop and Pitch Shift a track, then swap it with a modified score on User Interaction', script: ` // These Globals are provided by this playground, and act as imported namespaces @@ -420,6 +427,7 @@ export const examples: ExampleScript[] = [ `, }, { + slug: 'pitchshift-derail', name: 'Pitchshift a piano down, with a voice over the top. 30 seconds in slow both down (derail!).', script: ` // These Globals are provided by this playground, and act as imported namespaces diff --git a/demo/src/components/App.tsx b/demo/src/components/App.tsx index 479744f..4b93fce 100644 --- a/demo/src/components/App.tsx +++ b/demo/src/components/App.tsx @@ -25,6 +25,7 @@ import { XAudioContext } from 'nf-player'; import * as React from 'react'; import styled from 'styled-components'; +import { type PanelSlug, parseHash, setRoute, subscribeRoute } from '../router'; import { CODEEditor } from './CODEEditor/CODEEditor'; import { JSONEditor } from './JSONEditor/JSONEditor'; import { DemoTheme } from './Theme'; @@ -42,11 +43,7 @@ const StyledApplication = styled.div` height: 100%; `; -enum Panels { - CODE, - JSON, - VISUALIZER, -} +const DEFAULT_PANEL: PanelSlug = 'code'; // https://webaudio.github.io/web-audio-api/#AnalyserNode-attributes const defaultAnalyserOptions = { @@ -56,8 +53,11 @@ const defaultAnalyserOptions = { maxDecibels: -30, }; +const initialRoute = parseHash(); + const initialAppState = { - panel: Panels.CODE, + panel: initialRoute.panel ?? DEFAULT_PANEL, + exampleSlug: initialRoute.exampleSlug, player: new SmartPlayer(), analyser: XAudioContext().createAnalyser(), }; @@ -68,7 +68,26 @@ type AppProps = unknown; export class App extends React.Component { readonly state: AppState = initialAppState; - switchPanel(to: Panels) { + private unsubscribeRoute: (() => void) | undefined; + + componentDidMount() { + // The mounted panel's editor will write its resolved slug back via + // onExampleChange, which populates the URL on first load. + this.unsubscribeRoute = subscribeRoute((route) => { + const nextPanel = route.panel ?? DEFAULT_PANEL; + if (nextPanel !== this.state.panel) { + this.switchPanel(nextPanel, route.exampleSlug); + } else if (route.exampleSlug !== this.state.exampleSlug) { + this.setState({ exampleSlug: route.exampleSlug }); + } + }); + } + + componentWillUnmount() { + this.unsubscribeRoute?.(); + } + + switchPanel(to: PanelSlug, exampleSlug?: string) { if (this.state.player.playing) { this.state.player.playing = false; } @@ -78,7 +97,7 @@ export class App extends React.Component { let nextRenderer; let analyser; - if (to === Panels.VISUALIZER) { + if (to === 'visualizer') { const context = XAudioContext(); analyser = new AnalyserNode(context, defaultAnalyserOptions); analyser.connect(context.destination); @@ -96,9 +115,12 @@ export class App extends React.Component { const nextPlayer = new SmartPlayer(nextRenderer); const nextState = { panel: to, + exampleSlug, player: nextPlayer, }; + setRoute({ panel: to, exampleSlug }); + // typescript type guard if (analyser !== undefined) { this.setState({ @@ -110,6 +132,15 @@ export class App extends React.Component { } } + handlePanelButton = (to: PanelSlug) => { + this.switchPanel(to, undefined); + }; + + handleExampleChange = (slug: string | undefined) => { + this.setState({ exampleSlug: slug }); + setRoute({ panel: this.state.panel, exampleSlug: slug }); + }; + componentDidUpdate() { // Exposed on `window` so the playground scripts can drive the player. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -117,27 +148,44 @@ export class App extends React.Component { } render() { - const { panel, player, analyser } = this.state; + const { panel, player, analyser, exampleSlug } = this.state; return ( - - - - {panel === Panels.JSON && } - {panel === Panels.CODE && } - {panel === Panels.VISUALIZER && ( - + {panel === 'json' && ( + + )} + {panel === 'code' && ( + + )} + {panel === 'visualizer' && ( + )} diff --git a/demo/src/components/CODEEditor/CODEEditor.tsx b/demo/src/components/CODEEditor/CODEEditor.tsx index 6b6ab6b..c539b4d 100644 --- a/demo/src/components/CODEEditor/CODEEditor.tsx +++ b/demo/src/components/CODEEditor/CODEEditor.tsx @@ -41,6 +41,8 @@ import { type Props = { player: SmartPlayer; + exampleSlug: string | undefined; + onExampleChange: (slug: string | undefined) => void; }; type State = { @@ -48,6 +50,9 @@ type State = { loading: boolean; }; +const resolveExample = (slug: string | undefined): ExampleScript => + (slug && examples.find((e) => e.slug === slug)) || examples[0]; + const initialState: State = { example: examples[0], loading: false, @@ -68,12 +73,35 @@ export class CODEEditor extends React.Component { private getEditorValue: () => string = () => ''; componentDidMount() { + const example = resolveExample(this.props.exampleSlug); this.setState({ example: { - name: examples[0].name, - script: this.processExample(examples[0]), + slug: example.slug, + name: example.name, + script: this.processExample(example), }, }); + if (example.slug !== this.props.exampleSlug) { + this.props.onExampleChange(example.slug); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.exampleSlug !== this.props.exampleSlug) { + const example = resolveExample(this.props.exampleSlug); + if (example.slug !== this.state.example?.slug) { + this.setState({ + example: { + slug: example.slug, + name: example.name, + script: this.processExample(example), + }, + }); + } + if (example.slug !== this.props.exampleSlug) { + this.props.onExampleChange(example.slug); + } + } } processExample(example: ExampleScript) { @@ -102,12 +130,13 @@ export class CODEEditor extends React.Component { onExampleSelect = async (event: React.ChangeEvent) => { const example = examples.find( - (example) => example.name === event.target.value, + (example) => example.slug === event.target.value, ); if (example) { this.setState({ example: { + slug: example.slug, name: example.name, script: this.processExample(example), }, @@ -118,13 +147,14 @@ export class CODEEditor extends React.Component { }); } - const { player } = this.props; + const { player, onExampleChange } = this.props; if (player.playing) { player.playing = false; } player.renderTime = TimeInstant.ZERO; + onExampleChange(example?.slug); }; handlePlayPause = () => { @@ -176,10 +206,10 @@ export class CODEEditor extends React.Component { Examples: {examples.map((example) => ( - ))} diff --git a/demo/src/components/WaveVisualizer/WaveVisualizer.tsx b/demo/src/components/WaveVisualizer/WaveVisualizer.tsx index c188937..96e5e45 100644 --- a/demo/src/components/WaveVisualizer/WaveVisualizer.tsx +++ b/demo/src/components/WaveVisualizer/WaveVisualizer.tsx @@ -37,6 +37,8 @@ import { FrequencyMonitor } from './FrequencyMonitor'; type Props = { player: SmartPlayer; analyser: AnalyserNode; + exampleSlug: string | undefined; + onExampleChange: (slug: string | undefined) => void; }; const initialState = { @@ -49,26 +51,51 @@ type State = Readonly<{ loading: boolean; }>; +const resolveExample = (slug: string | undefined): ExampleJSON => + (slug && examples.find((e) => e.slug === slug)) || examples[0]; + // Based on JSONEditor/JSONEditor export class WaveVisualizer extends React.Component { readonly state = initialState; private getEditorValue: () => string = () => ''; + componentDidMount() { + const example = resolveExample(this.props.exampleSlug); + this.setState({ example }); + if (example.slug !== this.props.exampleSlug) { + this.props.onExampleChange(example.slug); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.exampleSlug !== this.props.exampleSlug) { + const example = resolveExample(this.props.exampleSlug); + if (example.slug !== this.state.example?.slug) { + this.setState({ example }); + } + if (example.slug !== this.props.exampleSlug) { + this.props.onExampleChange(example.slug); + } + } + } + onExampleSelect = async (event: React.ChangeEvent) => { const example = examples.find( - (example) => example.name === event.target.value, + (example) => example.slug === event.target.value, ); this.setState({ example: example, }); - const { player } = this.props; + const { player, onExampleChange } = this.props; if (player.playing) { player.playing = false; } + + onExampleChange(example?.slug); }; handlePlayPause = async () => { @@ -98,6 +125,7 @@ export class WaveVisualizer extends React.Component { if (editorJSON !== exampleJSON) { this.setState({ example: { + slug: 'user-edited', name: 'User edited', score: editorValue, }, @@ -126,10 +154,10 @@ export class WaveVisualizer extends React.Component { Examples: