@effect-motion/react 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/Player.d.ts +11 -0
- package/dist/Player.js +136 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/usePlayer.d.ts +46 -0
- package/dist/usePlayer.js +218 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @effect-motion/react
|
|
2
|
+
|
|
3
|
+
React bindings for [effect-motion](https://www.npmjs.com/package/effect-motion): a `<Player>` component and a `usePlayer` hook for playing scenes in the browser.
|
|
4
|
+
|
|
5
|
+
Because scenes are deterministic and finite, the player runs a scene once, collects every frame, and plays them back — so seeking in either direction is free.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
`effect` and `effect-motion` are peer dependencies — install them alongside:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @effect-motion/react effect-motion effect
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Play a scene
|
|
16
|
+
|
|
17
|
+
`<Player>` runs the scene and gives you transport controls — play/pause and a scrubbable progress bar:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { Player } from "@effect-motion/react";
|
|
21
|
+
import { scene } from "./my-scene";
|
|
22
|
+
|
|
23
|
+
export function App() {
|
|
24
|
+
return <Player scene={scene} width={500} height={300} autoPlay />;
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Prefer your own UI? `usePlayer(scene, options)` exposes the same state and controls (`status`, `frame`, `progress`, `play`, `pause`, `seek`, …) without any chrome.
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
Full docs, concepts, and live examples: **https://github.com/julia-script/effect-motion**
|
package/dist/Player.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type AnyScene, type UsePlayerOptions } from "./usePlayer";
|
|
2
|
+
export interface PlayerProps extends UsePlayerOptions {
|
|
3
|
+
readonly scene: AnyScene;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A scene player: metadata-sized SVG viewport and a transport bar with
|
|
7
|
+
* play/pause, a scrubber clamped to the buffered range, a time readout,
|
|
8
|
+
* and a loop toggle. Focus the player for keyboard control: Space toggles
|
|
9
|
+
* playback, arrow keys step one frame.
|
|
10
|
+
*/
|
|
11
|
+
export declare const Player: ({ scene, ...options }: PlayerProps) => import("react").JSX.Element;
|
package/dist/Player.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* biome-ignore-all lint/a11y/noNoninteractiveTabindex: the player root is a
|
|
4
|
+
* deliberate focus target for transport shortcuts (space/arrows), like the
|
|
5
|
+
* native <video> element
|
|
6
|
+
* biome-ignore-all lint/a11y/noStaticElementInteractions: keyboard transport
|
|
7
|
+
* is scoped to the focused player root
|
|
8
|
+
*/
|
|
9
|
+
import { Layer } from "effect";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import { Svg } from "effect-motion";
|
|
12
|
+
import { useEffect, useRef } from "react";
|
|
13
|
+
import { usePlayer, } from "./usePlayer";
|
|
14
|
+
const layers = Svg.layer.pipe(Layer.provideMerge(Svg.shapesLayer));
|
|
15
|
+
// everything in the SVG DOM sink and its entity renderers is synchronous
|
|
16
|
+
const renderFrame = (frame, target) => Effect.runSync(Effect.gen(function* () {
|
|
17
|
+
const renderer = yield* Svg.SvgDomRenderer.Context;
|
|
18
|
+
// no size in the config: the sink falls back to frame metadata
|
|
19
|
+
yield* renderer.render(frame, { target });
|
|
20
|
+
}).pipe(Effect.provide(layers)));
|
|
21
|
+
const iconProps = {
|
|
22
|
+
width: 14,
|
|
23
|
+
height: 14,
|
|
24
|
+
viewBox: "0 0 16 16",
|
|
25
|
+
fill: "currentColor",
|
|
26
|
+
};
|
|
27
|
+
// icons are decorative: the owning buttons carry the accessible labels
|
|
28
|
+
const PlayIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4.5 2.5v11l9-5.5z" }) }));
|
|
29
|
+
const PauseIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4 2.5h3v11H4zM9 2.5h3v11H9z" }) }));
|
|
30
|
+
const LoopIcon = () => (_jsx("svg", { ...iconProps, "aria-hidden": "true", children: _jsx("path", { d: "M4 5h6V3l3.5 3L10 9V7H5v3H3V6a1 1 0 0 1 1-1zM12 11H6v2l-3.5-3L6 7v2h5V6h2v4a1 1 0 0 1-1 1z" }) }));
|
|
31
|
+
const buttonStyle = (active) => ({
|
|
32
|
+
display: "inline-flex",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
justifyContent: "center",
|
|
35
|
+
width: 28,
|
|
36
|
+
height: 28,
|
|
37
|
+
padding: 0,
|
|
38
|
+
border: "none",
|
|
39
|
+
borderRadius: 6,
|
|
40
|
+
background: "transparent",
|
|
41
|
+
color: active ? "#8b9cff" : "#d6d6de",
|
|
42
|
+
cursor: "pointer",
|
|
43
|
+
});
|
|
44
|
+
const formatTime = (frames, frameRate) => {
|
|
45
|
+
const seconds = Math.floor(frames / frameRate);
|
|
46
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A scene player: metadata-sized SVG viewport and a transport bar with
|
|
50
|
+
* play/pause, a scrubber clamped to the buffered range, a time readout,
|
|
51
|
+
* and a loop toggle. Focus the player for keyboard control: Space toggles
|
|
52
|
+
* playback, arrow keys step one frame.
|
|
53
|
+
*/
|
|
54
|
+
export const Player = ({ scene, ...options }) => {
|
|
55
|
+
const player = usePlayer(scene, options);
|
|
56
|
+
const viewportRef = useRef(null);
|
|
57
|
+
// scene resolution: frame metadata once available, else explicit props
|
|
58
|
+
const sceneWidth = player.currentFrame?.width ?? options.width;
|
|
59
|
+
const sceneHeight = player.currentFrame?.height ?? options.height;
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const target = viewportRef.current;
|
|
62
|
+
if (target === null || player.currentFrame === null) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
renderFrame(player.currentFrame, target);
|
|
66
|
+
// ponytail: post-process the sink's root for responsive scaling —
|
|
67
|
+
// viewBox + CSS size lets the fixed-pixel SVG fill the viewport box;
|
|
68
|
+
// move viewBox into the sink if another consumer needs scaling
|
|
69
|
+
const svg = target.querySelector("svg");
|
|
70
|
+
if (svg !== null) {
|
|
71
|
+
svg.setAttribute("viewBox", `0 0 ${player.currentFrame.width} ${player.currentFrame.height}`);
|
|
72
|
+
svg.style.width = "100%";
|
|
73
|
+
svg.style.height = "100%";
|
|
74
|
+
svg.style.display = "block";
|
|
75
|
+
}
|
|
76
|
+
}, [player.currentFrame]);
|
|
77
|
+
const handleKeyDown = (event) => {
|
|
78
|
+
// buttons and the scrubber already handle these keys natively
|
|
79
|
+
const tag = event.target.tagName;
|
|
80
|
+
if (tag === "BUTTON" || tag === "INPUT") {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (event.key === " ") {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
player.toggle();
|
|
86
|
+
}
|
|
87
|
+
else if (event.key === "ArrowRight") {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
player.pause();
|
|
90
|
+
player.seek(player.frame + 1);
|
|
91
|
+
}
|
|
92
|
+
else if (event.key === "ArrowLeft") {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
player.pause();
|
|
95
|
+
player.seek(player.frame - 1);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return (_jsxs("div", { tabIndex: 0, onKeyDown: handleKeyDown, style: {
|
|
99
|
+
display: "flex",
|
|
100
|
+
flexDirection: "column",
|
|
101
|
+
// fill the container like a video element; the aspect ratio
|
|
102
|
+
// below keeps the scene's proportions at any width
|
|
103
|
+
width: "100%",
|
|
104
|
+
background: "#101014",
|
|
105
|
+
borderRadius: 10,
|
|
106
|
+
overflow: "hidden",
|
|
107
|
+
}, children: [_jsxs("div", { style: {
|
|
108
|
+
position: "relative",
|
|
109
|
+
width: "100%",
|
|
110
|
+
aspectRatio: sceneWidth !== undefined && sceneHeight !== undefined
|
|
111
|
+
? `${sceneWidth} / ${sceneHeight}`
|
|
112
|
+
: undefined,
|
|
113
|
+
minHeight: sceneHeight === undefined ? 120 : undefined,
|
|
114
|
+
}, children: [_jsx("div", { ref: viewportRef, style: { width: "100%", height: "100%" } }), player.status === "loading" ? (_jsx("div", { style: {
|
|
115
|
+
position: "absolute",
|
|
116
|
+
inset: 0,
|
|
117
|
+
display: "flex",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
justifyContent: "center",
|
|
120
|
+
color: "#6b6b76",
|
|
121
|
+
fontSize: 13,
|
|
122
|
+
}, children: "Loading\u2026" })) : null] }), _jsxs("div", { style: {
|
|
123
|
+
display: "flex",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
gap: 8,
|
|
126
|
+
padding: "6px 10px",
|
|
127
|
+
background: "#1a1a20",
|
|
128
|
+
}, children: [_jsx("button", { type: "button", onClick: player.toggle, disabled: player.status !== "ready", "aria-label": player.playing ? "Pause" : "Play", style: buttonStyle(false), children: player.playing ? _jsx(PauseIcon, {}) : _jsx(PlayIcon, {}) }), _jsx("input", { type: "range", "aria-label": "Progress", min: 0, max: Math.max(0, (player.totalFrames ?? player.bufferedFrames) - 1), step: 1, value: player.frame, onChange: (event) => player.seek(Number(event.currentTarget.value)), disabled: player.status !== "ready", style: { flex: 1, accentColor: "#8b9cff", margin: 0 } }), _jsx("span", { style: {
|
|
129
|
+
color: "#b9b9c3",
|
|
130
|
+
fontSize: 12,
|
|
131
|
+
fontVariantNumeric: "tabular-nums",
|
|
132
|
+
whiteSpace: "nowrap",
|
|
133
|
+
}, children: player.totalFrames !== null
|
|
134
|
+
? `${formatTime(player.frame, player.frameRate)} / ${formatTime(player.totalFrames, player.frameRate)}`
|
|
135
|
+
: formatTime(player.frame, player.frameRate) }), _jsx("button", { type: "button", onClick: () => player.setLoop(!player.loop), "aria-label": "Loop", "aria-pressed": player.loop, style: buttonStyle(player.loop), children: _jsx(LoopIcon, {}) })] }), player.status === "error" ? (_jsxs("div", { role: "alert", style: { color: "#ff8a8a", padding: "6px 10px" }, children: ["Scene failed: ", String(player.error)] })) : null] }));
|
|
136
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Entity, Scene } from "effect-motion";
|
|
2
|
+
export type PlayerStatus = "loading" | "ready" | "error";
|
|
3
|
+
/** A scene as produced by `Scene.make`, requirements erased. */
|
|
4
|
+
export type AnyScene = Scene.Scene<unknown, unknown, Entity.AnyEntity>;
|
|
5
|
+
export type PlayerFrame = Scene.Frame<Entity.AnyEntity>;
|
|
6
|
+
export interface UsePlayerOptions {
|
|
7
|
+
readonly seed?: number | string | undefined;
|
|
8
|
+
/** frames per second of both the scene runner and the playback clock */
|
|
9
|
+
readonly frameRate?: number | undefined;
|
|
10
|
+
/** scene resolution, forwarded to the runner and stamped on frames */
|
|
11
|
+
readonly width?: number | undefined;
|
|
12
|
+
readonly height?: number | undefined;
|
|
13
|
+
/** start playing as soon as the first frame is buffered */
|
|
14
|
+
readonly autoPlay?: boolean | undefined;
|
|
15
|
+
}
|
|
16
|
+
export interface Player {
|
|
17
|
+
readonly status: PlayerStatus;
|
|
18
|
+
/** the failure value when `status` is "error" */
|
|
19
|
+
readonly error: unknown;
|
|
20
|
+
/** the frame to show right now (null while loading/error) */
|
|
21
|
+
readonly currentFrame: PlayerFrame | null;
|
|
22
|
+
/** current frame index */
|
|
23
|
+
readonly frame: number;
|
|
24
|
+
/** frames pulled from the scene so far */
|
|
25
|
+
readonly bufferedFrames: number;
|
|
26
|
+
/** null until the scene's stream completes (never, for infinite scenes) */
|
|
27
|
+
readonly totalFrames: number | null;
|
|
28
|
+
/** 0..1 — against totalFrames when known, else the buffered edge */
|
|
29
|
+
readonly progress: number;
|
|
30
|
+
readonly playing: boolean;
|
|
31
|
+
readonly loop: boolean;
|
|
32
|
+
readonly frameRate: number;
|
|
33
|
+
readonly play: () => void;
|
|
34
|
+
readonly pause: () => void;
|
|
35
|
+
readonly toggle: () => void;
|
|
36
|
+
readonly seek: (frame: number) => void;
|
|
37
|
+
readonly setLoop: (loop: boolean) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Prepare a scene for playback: pull frames from the scene's stream with a
|
|
41
|
+
* read-ahead buffer and play them back on a rAF clock. Playback starts as
|
|
42
|
+
* soon as the first frame is buffered, so long and infinite scenes play
|
|
43
|
+
* without waiting for completion. Played frames are retained, so backward
|
|
44
|
+
* seeking is free; forward seeking clamps to the buffered edge.
|
|
45
|
+
*/
|
|
46
|
+
export declare const usePlayer: (scene: AnyScene, options?: UsePlayerOptions) => Player;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Pull from "effect/Pull";
|
|
3
|
+
import * as Stream from "effect/Stream";
|
|
4
|
+
import { Fonts, Scene } from "effect-motion";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
/**
|
|
7
|
+
* Prepare a scene for playback: pull frames from the scene's stream with a
|
|
8
|
+
* read-ahead buffer and play them back on a rAF clock. Playback starts as
|
|
9
|
+
* soon as the first frame is buffered, so long and infinite scenes play
|
|
10
|
+
* without waiting for completion. Played frames are retained, so backward
|
|
11
|
+
* seeking is free; forward seeking clamps to the buffered edge.
|
|
12
|
+
*/
|
|
13
|
+
export const usePlayer = (scene, options = {}) => {
|
|
14
|
+
const { seed, frameRate = 60, width, height, autoPlay = false } = options;
|
|
15
|
+
// ponytail: the buffer is append-only and unbounded — infinite scenes
|
|
16
|
+
// grow it forever; swap in a ring buffer with a re-run-from-0 story if
|
|
17
|
+
// memory ever matters. buffer[i] never changes once present, so reading
|
|
18
|
+
// it during render is safe.
|
|
19
|
+
const bufferRef = useRef([]);
|
|
20
|
+
const [bufferedFrames, setBufferedFrames] = useState(0);
|
|
21
|
+
const [totalFrames, setTotalFrames] = useState(null);
|
|
22
|
+
const [error, setError] = useState(null);
|
|
23
|
+
const [frame, setFrame] = useState(0);
|
|
24
|
+
const [playing, setPlaying] = useState(false);
|
|
25
|
+
const [loop, setLoop] = useState(false);
|
|
26
|
+
const [fontsReady, setFontsReady] = useState(false);
|
|
27
|
+
// font preload: load the scene's declared url fonts (Fonts annotation)
|
|
28
|
+
// alongside initial buffering. Fonts cannot affect frame data, so this
|
|
29
|
+
// only gates `status` — a failed load warns and proceeds with the
|
|
30
|
+
// browser's normal fallback.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const entries = Fonts.get(scene).filter((f) => f.src.url !== undefined);
|
|
33
|
+
if (entries.length === 0 ||
|
|
34
|
+
typeof FontFace === "undefined" ||
|
|
35
|
+
typeof document === "undefined" ||
|
|
36
|
+
document.fonts === undefined) {
|
|
37
|
+
setFontsReady(true);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setFontsReady(false);
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
const loads = entries.map((f) => {
|
|
43
|
+
const face = new FontFace(f.family, `url(${f.src.url})`, {
|
|
44
|
+
...(f.weight !== undefined && { weight: String(f.weight) }),
|
|
45
|
+
...(f.style !== undefined && { style: f.style }),
|
|
46
|
+
});
|
|
47
|
+
document.fonts.add(face);
|
|
48
|
+
return face.load().then(undefined, (err) => {
|
|
49
|
+
console.warn(`effect-motion: font "${f.family}" failed to load`, err);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
Promise.all(loads).then(() => {
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setFontsReady(true);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return () => {
|
|
58
|
+
cancelled = true;
|
|
59
|
+
};
|
|
60
|
+
}, [scene]);
|
|
61
|
+
// latest-value refs keep the fill loop and the rAF clock out of effect
|
|
62
|
+
// deps: neither should restart on every frame or buffer growth
|
|
63
|
+
const autoPlayRef = useRef(autoPlay);
|
|
64
|
+
autoPlayRef.current = autoPlay;
|
|
65
|
+
const frameRef = useRef(frame);
|
|
66
|
+
frameRef.current = frame;
|
|
67
|
+
const totalRef = useRef(totalFrames);
|
|
68
|
+
totalRef.current = totalFrames;
|
|
69
|
+
const loopRef = useRef(loop);
|
|
70
|
+
loopRef.current = loop;
|
|
71
|
+
// fill loop: pull frames ahead of the playhead until the stream ends
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
bufferRef.current = [];
|
|
75
|
+
frameRef.current = 0;
|
|
76
|
+
setBufferedFrames(0);
|
|
77
|
+
setTotalFrames(null);
|
|
78
|
+
setError(null);
|
|
79
|
+
setFrame(0);
|
|
80
|
+
setPlaying(false);
|
|
81
|
+
const readAhead = 2 * frameRate;
|
|
82
|
+
const frames = Scene.stream(scene, {
|
|
83
|
+
frameRate,
|
|
84
|
+
...(seed !== undefined && { seed }),
|
|
85
|
+
...(width !== undefined && { width }),
|
|
86
|
+
...(height !== undefined && { height }),
|
|
87
|
+
});
|
|
88
|
+
const fill = Effect.gen(function* () {
|
|
89
|
+
const pull = yield* Stream.toPull(frames);
|
|
90
|
+
let first = true;
|
|
91
|
+
while (true) {
|
|
92
|
+
if (bufferRef.current.length - frameRef.current >= readAhead) {
|
|
93
|
+
// ponytail: 50ms poll instead of demand signalling — the
|
|
94
|
+
// condition changes at most once per played frame; wire a
|
|
95
|
+
// real latch if the wakeups ever show up in a profile
|
|
96
|
+
yield* Effect.sleep(50);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const chunk = yield* pull;
|
|
100
|
+
bufferRef.current.push(...chunk);
|
|
101
|
+
setBufferedFrames(bufferRef.current.length);
|
|
102
|
+
if (first) {
|
|
103
|
+
first = false;
|
|
104
|
+
if (autoPlayRef.current) {
|
|
105
|
+
setPlaying(true);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}).pipe(
|
|
110
|
+
// the pull signals stream end as a Done failure: the scene is finite
|
|
111
|
+
Pull.catchDone(() => Effect.sync(() => setTotalFrames(bufferRef.current.length))), Effect.scoped);
|
|
112
|
+
Effect.runPromise(fill, { signal: controller.signal }).then(undefined, (err) => {
|
|
113
|
+
// aborted means unmount/re-run: no state updates after that
|
|
114
|
+
if (!controller.signal.aborted) {
|
|
115
|
+
setError(err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return () => controller.abort();
|
|
119
|
+
}, [scene, seed, frameRate, width, height]);
|
|
120
|
+
// playback clock: advance the index at frameRate while playing, clamped
|
|
121
|
+
// to the buffered edge — playing at the live edge waits for frames
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!playing) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const frameMs = 1000 / frameRate;
|
|
127
|
+
let raf = 0;
|
|
128
|
+
let last = null;
|
|
129
|
+
let acc = 0;
|
|
130
|
+
const tick = (now) => {
|
|
131
|
+
if (last !== null) {
|
|
132
|
+
acc += now - last;
|
|
133
|
+
const advance = Math.floor(acc / frameMs);
|
|
134
|
+
if (advance > 0) {
|
|
135
|
+
acc -= advance * frameMs;
|
|
136
|
+
setFrame((f) => {
|
|
137
|
+
const buffered = bufferRef.current.length;
|
|
138
|
+
if (buffered === 0) {
|
|
139
|
+
return f;
|
|
140
|
+
}
|
|
141
|
+
let next = f + advance;
|
|
142
|
+
const total = totalRef.current;
|
|
143
|
+
if (total !== null && loopRef.current && next > total - 1) {
|
|
144
|
+
next = next % total;
|
|
145
|
+
}
|
|
146
|
+
return Math.min(next, buffered - 1);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
last = now;
|
|
151
|
+
raf = requestAnimationFrame(tick);
|
|
152
|
+
};
|
|
153
|
+
raf = requestAnimationFrame(tick);
|
|
154
|
+
return () => cancelAnimationFrame(raf);
|
|
155
|
+
}, [playing, frameRate]);
|
|
156
|
+
// auto-pause on the last frame of a completed stream (loop wraps instead)
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (playing && !loop && totalFrames !== null && frame >= totalFrames - 1) {
|
|
159
|
+
setPlaying(false);
|
|
160
|
+
}
|
|
161
|
+
}, [playing, loop, totalFrames, frame]);
|
|
162
|
+
const play = useCallback(() => {
|
|
163
|
+
if (bufferRef.current.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// replay: sitting on the last frame of a finished scene means start over
|
|
167
|
+
setFrame((f) => {
|
|
168
|
+
const total = totalRef.current;
|
|
169
|
+
return total !== null && f >= total - 1 ? 0 : f;
|
|
170
|
+
});
|
|
171
|
+
setPlaying(true);
|
|
172
|
+
}, []);
|
|
173
|
+
const pause = useCallback(() => {
|
|
174
|
+
setPlaying(false);
|
|
175
|
+
}, []);
|
|
176
|
+
const toggle = useCallback(() => {
|
|
177
|
+
if (playing) {
|
|
178
|
+
pause();
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
play();
|
|
182
|
+
}
|
|
183
|
+
}, [playing, play, pause]);
|
|
184
|
+
const seek = useCallback((target) => {
|
|
185
|
+
const buffered = bufferRef.current.length;
|
|
186
|
+
if (buffered === 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
setFrame(Math.min(Math.max(0, Math.floor(target)), buffered - 1));
|
|
190
|
+
}, []);
|
|
191
|
+
const status = error !== null
|
|
192
|
+
? "error"
|
|
193
|
+
: bufferedFrames > 0 && fontsReady
|
|
194
|
+
? "ready"
|
|
195
|
+
: "loading";
|
|
196
|
+
const denominator = (totalFrames ?? bufferedFrames) - 1;
|
|
197
|
+
return {
|
|
198
|
+
status,
|
|
199
|
+
error,
|
|
200
|
+
currentFrame: bufferRef.current[frame] ?? null,
|
|
201
|
+
frame,
|
|
202
|
+
bufferedFrames,
|
|
203
|
+
totalFrames,
|
|
204
|
+
progress: denominator > 0
|
|
205
|
+
? Math.min(frame / denominator, 1)
|
|
206
|
+
: totalFrames !== null
|
|
207
|
+
? 1
|
|
208
|
+
: 0,
|
|
209
|
+
playing,
|
|
210
|
+
loop,
|
|
211
|
+
frameRate,
|
|
212
|
+
play,
|
|
213
|
+
pause,
|
|
214
|
+
toggle,
|
|
215
|
+
seek,
|
|
216
|
+
setLoop,
|
|
217
|
+
};
|
|
218
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effect-motion/react",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "React bindings for effect-motion: usePlayer hook and Player component",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/julia-script/effect-motion.git",
|
|
10
|
+
"directory": "packages/react"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/julia-script/effect-motion#readme",
|
|
13
|
+
"bugs": "https://github.com/julia-script/effect-motion/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"effect",
|
|
16
|
+
"motion",
|
|
17
|
+
"react",
|
|
18
|
+
"animation",
|
|
19
|
+
"motion-graphics"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"effect-motion": "^0.2.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"effect": ">=4.0.0-beta.94",
|
|
38
|
+
"react": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@testing-library/react": "^16.3.0",
|
|
42
|
+
"@types/react": "^19.2.0",
|
|
43
|
+
"@types/react-dom": "^19.2.0",
|
|
44
|
+
"effect": "4.0.0-beta.94",
|
|
45
|
+
"happy-dom": "^20.10.6",
|
|
46
|
+
"react": "^19.2.0",
|
|
47
|
+
"react-dom": "^19.2.0",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^4.1.10"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
|
+
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"check": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|