feat: use Web Audio API for waveform generation

This commit is contained in:
aarthificial
2022-06-06 03:15:07 +02:00
parent a591683f93
commit ba3e16f04a
19 changed files with 343 additions and 137 deletions

161
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"preact": "^10.7.1"
"preact": "^10.7.1",
"strongly-typed-events": "^3.0.2"
},
"devDependencies": {
"@types/wicg-file-system-access": "^2020.9.5",
@@ -47,6 +48,97 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/ste-core": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-core/-/ste-core-3.0.2.tgz",
"integrity": "sha512-jX6MwOP78lt5AJsDouGrXEAd4ivYF/xLgzUDa5bpVfScIVhs2K5yG+ZxGJzpyEZFFseYJYhyLvsLDCLETW4W/w==",
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-events/-/ste-events-3.0.2.tgz",
"integrity": "sha512-lfNXJJLhDsZUyOLYkdldlFGzgWyI+FMza1x9VNzKKa6MV86yvUig6u6e8qOl0QMMXVreWqCf2jeFC3BczZwtcg==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-promise-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-events/-/ste-promise-events-3.0.2.tgz",
"integrity": "sha512-JUvHOYGzZgwyy6kg/FJH7cQKp179BgOqeKhlxS31gAjrzKpNLGmgMRnjW+/NPyOmuEBDpaA6uV3lbQ5rc3BDVg==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-promise-signals": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-signals/-/ste-promise-signals-3.0.2.tgz",
"integrity": "sha512-YbPJJFVxXEwvKXTguItJFhukz6kT3n0BsY/wzw9B4mKo6hQlxCMtYdR72r1WwthnMvk0NDI1gP4JV+f5rOHEvA==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-promise-simple-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-simple-events/-/ste-promise-simple-events-3.0.2.tgz",
"integrity": "sha512-DbIwA1Gz0sYk6ruUAew4M7i1p1U6ULAITy7a25/bAxxA7l2nMlk3Cd54CSNqGVdBHnrR8Nv4rC9e99LXs+BiFA==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-signals": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-signals/-/ste-signals-3.0.2.tgz",
"integrity": "sha512-bWDtxCBs7cq3bModkFGyTRzAGm9QSAlrGXV7xxKmgyc2VMkF/E79Y4k3CcMEVT7Odj1ieL8Fg8m21gL43sOLSg==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/ste-simple-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-simple-events/-/ste-simple-events-3.0.2.tgz",
"integrity": "sha512-NvHH+JX6SjpMtgmcAwqMAsJ6nyGdGp1/oaGTdK/RVQGIAL0bLKRnHAyiH+ygMJ3P2SPPUPApIjqoq1VGK2uH8A==",
"dependencies": {
"ste-core": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/strongly-typed-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/strongly-typed-events/-/strongly-typed-events-3.0.2.tgz",
"integrity": "sha512-9KKqQwqjXc6zbqi1iIFoAX+9jPoBIFcel43HCBWYskgEpg3g8R1J3hTzUH8GYfu1EOYwEIjL6Yw0KnceSdDjLw==",
"dependencies": {
"ste-core": "^3.0.2",
"ste-events": "^3.0.2",
"ste-promise-events": "^3.0.2",
"ste-promise-signals": "^3.0.2",
"ste-promise-simple-events": "^3.0.2",
"ste-signals": "^3.0.2",
"ste-simple-events": "^3.0.2"
},
"engines": {
"node": ">=4.2.4"
}
},
"node_modules/typescript": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
@@ -79,6 +171,73 @@
"integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==",
"dev": true
},
"ste-core": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-core/-/ste-core-3.0.2.tgz",
"integrity": "sha512-jX6MwOP78lt5AJsDouGrXEAd4ivYF/xLgzUDa5bpVfScIVhs2K5yG+ZxGJzpyEZFFseYJYhyLvsLDCLETW4W/w=="
},
"ste-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-events/-/ste-events-3.0.2.tgz",
"integrity": "sha512-lfNXJJLhDsZUyOLYkdldlFGzgWyI+FMza1x9VNzKKa6MV86yvUig6u6e8qOl0QMMXVreWqCf2jeFC3BczZwtcg==",
"requires": {
"ste-core": "^3.0.2"
}
},
"ste-promise-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-events/-/ste-promise-events-3.0.2.tgz",
"integrity": "sha512-JUvHOYGzZgwyy6kg/FJH7cQKp179BgOqeKhlxS31gAjrzKpNLGmgMRnjW+/NPyOmuEBDpaA6uV3lbQ5rc3BDVg==",
"requires": {
"ste-core": "^3.0.2"
}
},
"ste-promise-signals": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-signals/-/ste-promise-signals-3.0.2.tgz",
"integrity": "sha512-YbPJJFVxXEwvKXTguItJFhukz6kT3n0BsY/wzw9B4mKo6hQlxCMtYdR72r1WwthnMvk0NDI1gP4JV+f5rOHEvA==",
"requires": {
"ste-core": "^3.0.2"
}
},
"ste-promise-simple-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-promise-simple-events/-/ste-promise-simple-events-3.0.2.tgz",
"integrity": "sha512-DbIwA1Gz0sYk6ruUAew4M7i1p1U6ULAITy7a25/bAxxA7l2nMlk3Cd54CSNqGVdBHnrR8Nv4rC9e99LXs+BiFA==",
"requires": {
"ste-core": "^3.0.2"
}
},
"ste-signals": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-signals/-/ste-signals-3.0.2.tgz",
"integrity": "sha512-bWDtxCBs7cq3bModkFGyTRzAGm9QSAlrGXV7xxKmgyc2VMkF/E79Y4k3CcMEVT7Odj1ieL8Fg8m21gL43sOLSg==",
"requires": {
"ste-core": "^3.0.2"
}
},
"ste-simple-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/ste-simple-events/-/ste-simple-events-3.0.2.tgz",
"integrity": "sha512-NvHH+JX6SjpMtgmcAwqMAsJ6nyGdGp1/oaGTdK/RVQGIAL0bLKRnHAyiH+ygMJ3P2SPPUPApIjqoq1VGK2uH8A==",
"requires": {
"ste-core": "^3.0.2"
}
},
"strongly-typed-events": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/strongly-typed-events/-/strongly-typed-events-3.0.2.tgz",
"integrity": "sha512-9KKqQwqjXc6zbqi1iIFoAX+9jPoBIFcel43HCBWYskgEpg3g8R1J3hTzUH8GYfu1EOYwEIjL6Yw0KnceSdDjLw==",
"requires": {
"ste-core": "^3.0.2",
"ste-events": "^3.0.2",
"ste-promise-events": "^3.0.2",
"ste-promise-signals": "^3.0.2",
"ste-promise-simple-events": "^3.0.2",
"ste-signals": "^3.0.2",
"ste-simple-events": "^3.0.2"
}
},
"typescript": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",

View File

@@ -10,7 +10,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"preact": "^10.7.1"
"preact": "^10.7.1",
"strongly-typed-events": "^3.0.2"
},
"devDependencies": {
"@types/wicg-file-system-access": "^2020.9.5",

View File

@@ -29,7 +29,7 @@ export function PlaybackControls() {
player.toggleAudio();
break;
case 'l':
player.updateState({loop: !state.loop});
player.toggleLoop();
break;
}
},
@@ -48,7 +48,7 @@ export function PlaybackControls() {
{value: 2, text: 'x2'},
]}
value={state.speed}
onChange={speed => player.updateState({speed})}
onChange={speed => player.setSpeed(speed)}
/>
<IconCheckbox
id={'audio'}
@@ -78,7 +78,7 @@ export function PlaybackControls() {
iconOn={IconType.repeat}
iconOff={IconType.repeat}
checked={state.loop}
onChange={value => player.updateState({loop: value})}
onChange={() => player.toggleLoop()}
/>
<Framerate
render={framerate => (

View File

@@ -3,7 +3,7 @@ import styles from './Sidebar.module.scss';
import type {PlayerRenderEvent} from '@motion-canvas/core/player/Player';
import {IconType} from '../controls';
import {Tabs} from '../tabs/Tabs';
import {usePlayer, usePlayerState} from '../../hooks';
import { useEventEffect, usePlayer, usePlayerState } from "../../hooks";
import {useCallback, useEffect, useMemo, useState} from 'preact/hooks';
import {Thread} from '@motion-canvas/core/threading';
import {GeneratorHelper} from '@motion-canvas/core/helpers';
@@ -53,23 +53,22 @@ function Rendering() {
];
}, [width, height]);
const handleRender = useCallback(async ({frame, data}: PlayerRenderEvent) => {
try {
const name = frame.toString().padStart(6, '0');
await fetch(`/render/frame${name}.png`, {
method: 'POST',
body: data,
});
} catch (e) {
console.error(e);
player.toggleRendering(false);
}
}, []);
useEffect(() => {
player.RenderChanged.subscribe(handleRender);
return () => player.RenderChanged.unsubscribe(handleRender);
}, [handleRender]);
useEventEffect(
player.FrameRendered,
async ({frame, data}: PlayerRenderEvent) => {
try {
const name = frame.toString().padStart(6, '0');
await fetch(`/render/frame${name}.png`, {
method: 'POST',
body: data,
});
} catch (e) {
console.error(e);
player.toggleRendering(false);
}
},
[]
);
return (
<div className={styles.pane}>
@@ -82,9 +81,7 @@ function Rendering() {
type={'number'}
value={state.startFrame}
onChange={event => {
player.updateState({
startFrame: parseInt((event.target as HTMLInputElement).value),
});
player.setRange(parseInt((event.target as HTMLInputElement).value));
}}
/>
<Input
@@ -94,9 +91,7 @@ function Rendering() {
value={Math.min(state.duration, state.endFrame)}
onChange={event => {
const value = parseInt((event.target as HTMLInputElement).value);
player.updateState({
endFrame: value >= state.duration ? Infinity : value,
});
player.setRange(undefined, value);
}}
/>
</Group>

View File

@@ -2,55 +2,56 @@ import styles from './Timeline.module.scss';
import {useContext, useLayoutEffect, useMemo, useRef} from 'preact/hooks';
import {TimelineContext} from './TimelineContext';
import {usePlayer} from '../../hooks';
import {usePlayer, useEventState} from '../../hooks';
const HEIGHT = 48;
export function AudioTrack() {
const ref = useRef<HTMLCanvasElement>();
const player = usePlayer();
const {project, audio} = usePlayer();
const context = useMemo(() => ref.current?.getContext('2d'), [ref.current]);
const {viewLength, startFrame, endFrame, duration, density} =
useContext(TimelineContext);
const audioData = useEventState(audio.DataChanged, () => audio.getData());
useLayoutEffect(() => {
if (!context) return;
context.clearRect(0, 0, viewLength, HEIGHT * 2);
if (!audioData) return;
const audio = player.audio.meta;
const samplesPerSeconds = audio.sample_rate / audio.samples_per_pixel;
const step = Math.ceil(density / 256);
const start = Math.floor(
(player.project.framesToSeconds(startFrame) + player.audio.offset) *
samplesPerSeconds,
);
const end = Math.floor(
(player.project.framesToSeconds(endFrame) + player.audio.offset) *
samplesPerSeconds,
);
context.clearRect(0, 0, viewLength, 64);
context.strokeStyle = 'white';
context.lineWidth = 1;
context.beginPath();
context.moveTo(0, HEIGHT);
const start =
audio.toRelativeTime(project.framesToSeconds(startFrame)) *
audioData.sampleRate;
const end =
audio.toRelativeTime(project.framesToSeconds(endFrame)) *
audioData.sampleRate;
const flooredStart = ~~start;
const padding = flooredStart - start;
const length = end - start;
for (let offset = 0; offset < length; offset += step) {
const sample = (start + offset) * 2;
if (sample >= audio.data.length) break;
const step = Math.ceil(density);
for (let offset = 0; offset < length; offset += step * 2) {
const sample = flooredStart + offset;
if (sample >= audioData.peaks.length) break;
context.lineTo(
(offset / length) * viewLength,
(audio.data[sample] / 32767) * HEIGHT + HEIGHT,
((padding + offset) / length) * viewLength,
(audioData.peaks[sample] / audioData.absoluteMax) * HEIGHT + HEIGHT,
);
context.lineTo(
((offset + 0.5) / length) * viewLength,
(audio.data[sample + 1] / 32767) * HEIGHT + HEIGHT,
((padding + offset + step) / length) * viewLength,
(audioData.peaks[sample + 1] / audioData.absoluteMax) * HEIGHT + HEIGHT,
);
}
context.stroke();
}, [context, density, viewLength, startFrame, endFrame]);
}, [context, audioData, density, viewLength, startFrame, endFrame]);
const style = useMemo(
() => ({
@@ -65,7 +66,7 @@ export function AudioTrack() {
<canvas
style={style}
width={viewLength}
height={64}
height={HEIGHT * 2}
ref={ref}
className={styles.audioTrack}
/>

View File

@@ -1,19 +1,13 @@
import type {Scene} from '@motion-canvas/core/Scene';
import {useEffect, useState} from 'preact/hooks';
import {Label} from './Label';
import {useEventState} from '../../hooks';
interface LabelGroupProps {
scene: Scene;
}
export function LabelGroup({scene}: LabelGroupProps) {
const [events, setEvents] = useState(scene.timeEvents);
useEffect(() => {
setEvents(scene.timeEvents);
scene.TimeEventsChanged.subscribe(setEvents);
return () => scene.TimeEventsChanged.unsubscribe(setEvents);
}, [scene]);
const events = useEventState(scene.TimeEventsChanged, () => scene.timeEvents);
return (
<>

View File

@@ -14,17 +14,7 @@ export function RangeTrack() {
const [end, setEnd] = useState(state.endFrame);
const onDrop = useCallback(() => {
let startFrame = Math.max(0, Math.floor(start));
let endFrame =
end >= state.duration
? Infinity
: Math.min(state.duration, Math.floor(end));
if (startFrame > endFrame) {
[startFrame, endFrame] = [endFrame, startFrame];
}
player.updateState({startFrame, endFrame});
player.setRange(Math.floor(start), Math.floor(end));
}, [start, end, state.duration]);
const [handleDragStart] = useDrag(
@@ -79,16 +69,15 @@ export function RangeTrack() {
style={{
flexDirection: start > end ? 'row-reverse' : 'row',
left: `${(Math.max(0, normalizedStart) / state.duration) * 100}%`,
right: `${100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100}%`,
right: `${
100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100
}%`,
}}
className={styles.range}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault();
player.updateState({
startFrame: 0,
endFrame: Infinity,
});
player.setRange(0, Infinity);
} else {
handleDrag(event);
}
@@ -99,6 +88,7 @@ export function RangeTrack() {
className={styles.handle}
type={IconType.dragIndicator}
/>
<div class={styles.handleSpacer}/>
<Icon
onMouseDown={handleDragEnd}
onDblClick={console.log}

View File

@@ -25,10 +25,7 @@ export function SceneTrack() {
onMouseUp={event => {
if (event.button === 1) {
event.stopPropagation();
player.updateState({
startFrame: scene.firstFrame,
endFrame: scene.lastFrame - 1,
});
player.setRange(scene.firstFrame, scene.lastFrame - 1);
}
}}
>

View File

@@ -223,10 +223,6 @@
cursor: pointer;
padding: 8px 2px;
&:first-child {
margin-right: auto;
}
&:after {
background-color: rgba(255, 255, 255, 0);
}
@@ -243,6 +239,12 @@
margin-top: 4px;
}
.handleSpacer {
flex-grow: 1;
flex-shrink: 1;
}
.audioTrack {
opacity: 0.16;
margin: 24px 0;
}

View File

@@ -45,16 +45,15 @@ export function Timeline() {
const state = useMemo<TimelineState>(() => {
const fullLength = rect.width * scale;
const power = Math.pow(
2,
Math.round(Math.log2(duration / scale / rect.width)),
);
const density = Math.max(1, Math.floor(128 * power));
const density = Math.pow(2, Math.round(Math.log2(duration / fullLength)));
const segmentDensity = Math.max(1, Math.floor(128 * density));
const startFrame =
Math.floor(((offset / fullLength) * duration) / density) * density;
Math.floor(((offset / fullLength) * duration) / segmentDensity) *
segmentDensity;
const endFrame =
Math.ceil((((offset + rect.width) / fullLength) * duration) / density) *
density;
Math.ceil(
(((offset + rect.width) / fullLength) * duration) / segmentDensity,
) * segmentDensity;
return {
scale,
@@ -64,6 +63,7 @@ export function Timeline() {
startFrame,
endFrame,
density,
segmentDensity,
duration,
};
}, [rect.width, scale, duration, offset]);
@@ -127,12 +127,18 @@ export function Timeline() {
const pointer = offset + event.x - rect.x;
const newTrackSize = rect.width * newScale;
const maxOffset = newTrackSize - rect.width;
const newOffset = clamp(0, maxOffset, offset - pointer + pointer * ratio);
const newOffset = clamp(
0,
maxOffset,
offset - pointer + pointer * ratio,
);
containerRef.current.scrollLeft = newOffset;
setScale(newScale);
setOffset(newOffset);
playheadRef.current.style.left = `${event.x - rect.x + newOffset}px`;
playheadRef.current.style.left = `${
event.x - rect.x + newOffset
}px`;
}}
onMouseUp={event => {
if (event.button === 0) {
@@ -155,7 +161,7 @@ export function Timeline() {
<TimestampTrack />
<SceneTrack />
<LabelTrack />
{player.audio && <AudioTrack />}
<AudioTrack />
</div>
<div ref={playheadRef} className={styles.playheadPreview} />
<Playhead />

View File

@@ -1,13 +1,41 @@
import {createContext} from 'preact';
export interface TimelineState {
/**
* Length of the entire timeline in pixels
*/
fullLength: number;
/**
* Length of the visible area in pixels.
*/
viewLength: number;
/**
* The left offset of the view in pixels.
*/
offset: number;
/**
* How zoomed in the timeline is.
*/
scale: number;
/**
* First frame covered by the infinite scroll.
*/
startFrame: number;
/**
* Last frame covered by the infinite scroll.
*/
endFrame: number;
/**
* Frames per pixel rounded to the closest power of two.
*/
density: number;
/**
* Frames per timeline segment.
*/
segmentDensity: number;
/**
* Animation duration in frames.
*/
duration: number;
}
@@ -17,6 +45,7 @@ const TimelineContext = createContext<TimelineState>({
offset: 0,
scale: 1,
density: 1,
segmentDensity: 1,
duration: 0,
endFrame: 0,
startFrame: 0,

View File

@@ -4,19 +4,19 @@ import {useContext, useMemo} from 'preact/hooks';
import {TimelineContext} from './TimelineContext';
export function TimestampTrack() {
const {fullLength, startFrame, endFrame, density, duration} =
const {fullLength, startFrame, endFrame, segmentDensity, duration} =
useContext(TimelineContext);
const timestamps = useMemo(() => {
const timestamps = [];
for (let i = startFrame; i < endFrame; i += density) {
for (let i = startFrame; i < endFrame; i += segmentDensity) {
timestamps.push({
time: i,
style: {left: `${(i / duration) * fullLength}px`},
});
}
return timestamps;
}, [startFrame, endFrame, duration, fullLength, density]);
}, [startFrame, endFrame, duration, fullLength, segmentDensity]);
return (
<div className={styles.timestampTrack}>

View File

@@ -2,6 +2,7 @@ import styles from './Viewport.module.scss';
import {
useDocumentEvent,
useDrag,
useEventEffect,
usePlayer,
useSize,
useStorage,
@@ -61,8 +62,9 @@ export function View() {
return () => konvaContainer.remove();
}, [viewportRef.current]);
useEffect(() => {
const animation = () =>
useEventEffect(
player.Reloaded,
() =>
overlayRef.current.animate(
[
{
@@ -78,11 +80,9 @@ export function View() {
{
duration: 300,
},
);
player.Reloaded.subscribe(animation);
return () => player.Reloaded.unsubscribe(animation);
});
),
[],
);
useDocumentEvent(
'keydown',
@@ -107,12 +107,12 @@ export function View() {
case "'":
setState({...state, grid: !state.grid});
break;
case "ArrowUp":
case 'ArrowUp':
if (node?.parent) {
setNode(node.parent);
}
break;
case "ArrowDown":
case 'ArrowDown':
if (node?.children?.length) {
setNode(node.children.at(-1));
}

View File

@@ -1,8 +1,10 @@
export * from './useStorage';
export * from './useDocumentEvent';
export * from './useDrag';
export * from './useEventEffect';
export * from './useEventState';
export * from './usePlayer';
export * from './usePlayerState';
export * from './usePlayerTime';
export * from './useDocumentEvent';
export * from './useSize';
export * from './useDrag';
export * from './useScenes';
export * from './useSize';
export * from './useStorage';

View File

@@ -0,0 +1,13 @@
import {ISubscribable} from 'strongly-typed-events';
import {Inputs, useEffect} from 'preact/hooks';
export function useEventEffect<THandler extends Function>(
subscribable: ISubscribable<THandler>,
callback: THandler,
inputs: Inputs,
): void {
useEffect(() => {
subscribable.subscribe(callback);
return () => subscribable.unsubscribe(callback);
}, inputs);
}

View File

@@ -0,0 +1,16 @@
import {ISubscribable} from 'strongly-typed-events';
import {useEffect, useState} from 'preact/hooks';
export function useEventState<Type>(
subscribable: ISubscribable<(args: Type) => void>,
getState: () => Type,
): Type {
const [state, setState] = useState(getState());
useEffect(() => {
setState(getState());
subscribable.subscribe(setState);
return () => subscribable.unsubscribe(setState);
}, [subscribable]);
return state;
}

View File

@@ -1,15 +1,20 @@
import { PlayerState } from "@motion-canvas/core/player/Player";
import { usePlayer } from "./usePlayer";
import { useEffect, useState } from "preact/hooks";
import {PlayerState} from '@motion-canvas/core/player/Player';
import {usePlayer} from './usePlayer';
import {useEventState} from './useEventState';
const player = usePlayer();
const storageKey = `${player.project.name()}-player-state`;
const savedState = localStorage.getItem(storageKey);
if (savedState) {
const state = JSON.parse(savedState) as PlayerState;
player.updateState(state);
}
player.StateChanged.subscribe(state => {
localStorage.setItem(storageKey, JSON.stringify(state));
});
export function usePlayerState(): PlayerState {
const player = usePlayer();
const [state, setState] = useState<PlayerState>(player.getState());
useEffect(() => {
setState(player.getState());
player.StateChanged.subscribe(setState);
return () => player.StateChanged.unsubscribe(setState);
}, [player]);
return state;
return useEventState(player.StateChanged, () => player.getState());
}

View File

@@ -1,15 +1,8 @@
import {PlayerTime} from '@motion-canvas/core/player/Player';
import {usePlayer} from './usePlayer';
import {useEffect, useState} from 'preact/hooks';
import {useEventState} from './useEventState';
export function usePlayerTime(): PlayerTime {
const player = usePlayer();
const [state, setState] = useState<PlayerTime>(player.getTime());
useEffect(() => {
setState(player.getTime());
player.TimeChanged.subscribe(setState);
return () => player.TimeChanged.unsubscribe(setState);
}, [player]);
return state;
return useEventState(player.TimeChanged, () => player.getTime());
}

View File

@@ -1,23 +1,26 @@
import {useCallback, useMemo, useState} from 'preact/hooks';
import {usePlayer} from './usePlayer';
export function useStorage<T>(
id: string,
initialState: T = null,
): [T, (newState: T) => void] {
const name = usePlayer().project.name();
const key = `${name}-${id}`;
const savedState = useMemo(() => {
const savedState = localStorage.getItem(id);
const savedState = localStorage.getItem(key);
return savedState ? JSON.parse(savedState) : initialState;
}, [id]);
}, [key]);
const [state, setState] = useState<T>(savedState);
const updateState = useCallback(
(newState: T) => {
if (id) {
localStorage.setItem(id, JSON.stringify(newState));
if (key) {
localStorage.setItem(key, JSON.stringify(newState));
}
setState(newState);
},
[setState, id],
[setState, key],
);
return [state, updateState];