@effect-motion/react 0.2.0 → 0.3.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/dist/Player.d.ts CHANGED
@@ -1,11 +1,14 @@
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;
1
+ import type * as Scope from "effect/Scope";
2
+ import type * as Runner from "effect-motion/Runner";
3
+ import * as Scene from "effect-motion/Scene";
4
+ export type PlayerProps = {
5
+ prebufferedFrames?: number;
6
+ autoPlay?: boolean;
7
+ fps?: number;
8
+ isInfinite?: boolean;
9
+ defaultRepeatMode?: boolean;
10
+ bufferCapacity?: number;
11
+ settings?: Partial<Runner.Settings>;
12
+ scene: Scene.Scene<never, Runner.Runner | Scope.Scope>;
13
+ };
14
+ export declare const Player: ({ scene, fps: fpsProp, prebufferedFrames, autoPlay, isInfinite, defaultRepeatMode, bufferCapacity, settings, }: PlayerProps) => import("react").JSX.Element;
package/dist/Player.js CHANGED
@@ -1,136 +1,566 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as Engine from "@effect-motion/thorvg/Engine";
4
+ import * as Session from "@effect-motion/thorvg/Session";
5
+ import { Cause, Context, Data, Effect, ManagedRuntime, Schedule, Semaphore, } from "effect";
6
+ import * as Layer from "effect/Layer";
7
+ import * as CanvasExporter from "effect-motion/CanvasExporter";
8
+ import * as Fonts from "effect-motion/Fonts";
9
+ import * as Images from "effect-motion/Images";
10
+ import * as Renderer from "effect-motion/Renderer";
11
+ import * as Scene from "effect-motion/Scene";
12
+ import * as Time from "effect-motion/Time";
13
+ import { useEffect, useEffectEvent, useRef, useState, } from "react";
14
+ const thorLayer = Engine.browserLayer("https://unpkg.com/@thorvg/webcanvas@1.0.8/dist/thorvg.wasm");
15
+ class PlayerError extends Data.TaggedError("PlayerError") {
16
+ static of(message) {
17
+ return (cause) => new PlayerError({ message, cause });
18
+ }
19
+ }
2
20
  /**
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
21
+ * Softened device-pixel-ratio, thorvg.web's formula: interpolate 75% of the
22
+ * way from 1 to the native dpr — visually indistinguishable from full dpr at
23
+ * a fraction of the rasterized pixels. Read per render call so moving the
24
+ * window across monitors picks up the new ratio.
8
25
  */
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
- };
26
+ const calculateDpr = () => typeof window === "undefined" ? 1 : 1 + (window.devicePixelRatio - 1) * 0.75;
27
+ // const layer = Layer.con
28
+ // const runtime = ManagedRuntime.make()
29
+ const PlayerScene = Context.Service("PlayerScene");
48
30
  /**
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.
31
+ * Frame-data buffer keyed by absolute frame index, retaining at most
32
+ * `capacity` of the most-recently-pulled frames. Frames are pulled from the
33
+ * scene monotonically forward (never re-derivable at a random index — the
34
+ * scene is a forward-only stream), so the ring drops the oldest frames once
35
+ * the window is full. A finite scene that fits within `capacity` behaves like
36
+ * a plain array (nothing is ever evicted). Seeking below the retained window
37
+ * clamps to `oldest` — the earliest frame still in memory.
53
38
  */
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;
39
+ class FrameRing {
40
+ capacity;
41
+ // ponytail: fixed-capacity ring keyed by absolute index. capacity=Infinity
42
+ // keeps everything (finite scenes that fit); a finite cap bounds memory for
43
+ // long/infinite scenes at the cost of losing far-back seek. Bump the cap if
44
+ // deep backward seeking on huge scenes ever matters.
45
+ slots;
46
+ /** total frames pulled from the scene so far (monotonic) */
47
+ pulled = 0;
48
+ constructor(capacity) {
49
+ this.capacity = capacity;
50
+ this.slots = Number.isFinite(capacity) ? new Array(capacity) : [];
51
+ }
52
+ /** oldest absolute index still retained */
53
+ get oldest() {
54
+ return Number.isFinite(this.capacity)
55
+ ? Math.max(0, this.pulled - this.capacity)
56
+ : 0;
57
+ }
58
+ has(index) {
59
+ return index >= this.oldest && index < this.pulled;
60
+ }
61
+ get(index) {
62
+ if (!this.has(index)) {
63
+ return undefined;
64
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";
65
+ return Number.isFinite(this.capacity)
66
+ ? this.slots[index % this.capacity]
67
+ : this.slots[index];
68
+ }
69
+ /** append the next frame at the edge, evicting the oldest if full */
70
+ push(frame) {
71
+ if (Number.isFinite(this.capacity)) {
72
+ this.slots[this.pulled % this.capacity] = frame;
75
73
  }
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;
74
+ else {
75
+ this.slots[this.pulled] = frame;
82
76
  }
83
- if (event.key === " ") {
84
- event.preventDefault();
85
- player.toggle();
77
+ this.pulled++;
78
+ }
79
+ }
80
+ const useScene = (scene, options) => {
81
+ // loop is read live from optsRef inside the play loop, not destructured here
82
+ const { fps, prebufferedFrames, autoPlay, isInfinite, settings } = options;
83
+ const canvasRef = useRef(null);
84
+ const [currentFrame, setCurrentFrame] = useState(0);
85
+ const [bufferedFrames, setBufferedFrames] = useState(0);
86
+ // null until the scene stream ends (never, for infinite scenes)
87
+ const [totalFrames, setTotalFrames] = useState(null);
88
+ const [isPlaying, setIsPlaying] = useState(false);
89
+ // repeat mode is player state seeded from the prop (an initial value); the
90
+ // user toggles it live via the repeat button
91
+ const [loop, setLoop] = useState(options.loop);
92
+ // latest option values (incl. live loop), read inside the long-lived Effect
93
+ // service without re-creating the runtime (which would re-run the scene)
94
+ const optsRef = useRef({ ...options, loop });
95
+ optsRef.current = { ...options, loop };
96
+ // One runtime per mount, DISPOSED on unmount: disposal closes the session
97
+ // (canvas deleted, fonts released — refcounted, so siblings keep theirs)
98
+ // and releases the engine (a browser no-op: the wasm module is a page
99
+ // singleton, design D2; two players share one module via the idempotent
100
+ // acquire). Held in a ref, not state, so a strict-mode remount can
101
+ // recreate it after the first cleanup disposed it.
102
+ const makeRuntime = () => ManagedRuntime.make(Layer.effectContext(Effect.gen(function* () {
103
+ const runningScene = yield* Scene.run(scene, {
104
+ ...settings,
105
+ frameRate: fps,
106
+ });
107
+ let currentFrame = 0;
108
+ let isPlaying = false;
109
+ // total frame count once the stream ends; null while unknown
110
+ let totalFramesValue = null;
111
+ const setTotal = (n) => {
112
+ totalFramesValue = n;
113
+ setTotalFrames(n);
114
+ };
115
+ const updateCurrentFrame = (frameIndex) => {
116
+ currentFrame = frameIndex;
117
+ setCurrentFrame(frameIndex);
118
+ };
119
+ const updateIsPlaying = (newIsPlaying) => {
120
+ isPlaying = newIsPlaying;
121
+ setIsPlaying(isPlaying);
122
+ };
123
+ const ring = new FrameRing(options.bufferCapacity);
124
+ const loadFrameBuffer = (requested = ring.pulled) => Effect.gen(function* () {
125
+ // clamp a seek that fell off the back of the ring to the
126
+ // oldest retained frame (can't replay a forward-only stream)
127
+ const index = Math.max(requested, ring.oldest);
128
+ const existing = ring.get(index);
129
+ if (existing) {
130
+ return { frame: existing, index };
131
+ }
132
+ if (runningScene.done && index >= ring.pulled) {
133
+ // finite scene fully buffered — publish the total once
134
+ setTotal(ring.pulled);
135
+ return null;
136
+ }
137
+ // pull forward until the requested index is at the edge
138
+ while (true) {
139
+ const frame = yield* Scene.step(runningScene);
140
+ if (!frame) {
141
+ // stream ended: this is the total frame count
142
+ setTotal(ring.pulled);
143
+ break;
144
+ }
145
+ ring.push(frame);
146
+ setBufferedFrames(ring.pulled);
147
+ if (index <= ring.pulled - 1) {
148
+ break;
149
+ }
150
+ }
151
+ const edge = Math.min(index, ring.pulled - 1);
152
+ return {
153
+ frame: ring.get(edge),
154
+ index: edge,
155
+ };
156
+ }).pipe(Effect.mapError(PlayerError.of("Error getting frame buffer")));
157
+ const renderSemaphore = yield* Semaphore.make(1);
158
+ const render = (frameIndex) => renderSemaphore.withPermitsIfAvailable(1)(Effect.gen(function* () {
159
+ if (!canvasRef.current) {
160
+ return;
161
+ }
162
+ const framebuffer = yield* loadFrameBuffer(frameIndex);
163
+ if (!framebuffer) {
164
+ return;
165
+ }
166
+ // use the resolved index, not the requested one: a seek/advance
167
+ // past the buffered edge resolves to the edge (clamped in
168
+ // loadFrameBuffer), and the playhead must reflect what's shown
169
+ updateCurrentFrame(framebuffer.index);
170
+ const renderBuffer = yield* Renderer.render(framebuffer.frame, {
171
+ dpr: calculateDpr(),
172
+ });
173
+ yield* CanvasExporter.toCanvas(renderBuffer, canvasRef.current);
174
+ }).pipe(Effect.mapError(PlayerError.of("Error exporting canvas"))));
175
+ const play = Effect.suspend(() => {
176
+ if (isPlaying)
177
+ return Effect.void;
178
+ // replaying from the end of a finished finite scene: rewind
179
+ const total = totalFramesValue;
180
+ if (total !== null && currentFrame >= total - 1) {
181
+ updateCurrentFrame(0);
182
+ }
183
+ isPlaying = true;
184
+ updateIsPlaying(true);
185
+ // Real-time playback clock. Each tick advances by however many
186
+ // whole frames of wall-clock have elapsed since the last tick
187
+ // (accumulator), so playback keeps real time even when a render
188
+ // is slow — intermediate frames are dropped, only the latest is
189
+ // rendered. The driver ticks a bit faster than the frame period
190
+ // so we never systematically miss a frame boundary; the
191
+ // accumulator decides whether a frame is actually due.
192
+ const frameMs = 1000 / fps;
193
+ let lastTick = null;
194
+ let acc = 0;
195
+ const tick = Effect.gen(function* () {
196
+ // wall-clock is intentional here: it drives real playback
197
+ // speed — not scene time, so the determinism rule that bans
198
+ // wall-clock in scenes doesn't apply.
199
+ const now = performance.now();
200
+ if (lastTick === null) {
201
+ lastTick = now;
202
+ return;
203
+ }
204
+ const elapsed = now - lastTick;
205
+ lastTick = now;
206
+ acc += elapsed;
207
+ const advance = Math.floor(acc / frameMs);
208
+ if (advance <= 0) {
209
+ return;
210
+ }
211
+ acc -= advance * frameMs;
212
+ let next = currentFrame + advance;
213
+ const total = totalFramesValue;
214
+ if (total !== null && next > total - 1) {
215
+ // past the end of a finite scene: wrap when looping,
216
+ // else clamp to the last frame (a big `advance` from a
217
+ // slow tick must not overshoot into the unbuffered void,
218
+ // which would render nothing and wedge the playhead)
219
+ next =
220
+ optsRef.current.loop && !optsRef.current.isInfinite
221
+ ? next % total
222
+ : total - 1;
223
+ }
224
+ yield* render(next);
225
+ });
226
+ // small fixed spacing (quarter-frame) so the accumulator sees
227
+ // each frame boundary; the tick itself no-ops until a frame is due
228
+ const loop = Effect.repeat({
229
+ schedule: Schedule.spaced(Math.max(1, frameMs / 4)),
230
+ // keep playing until we reach the last buffered frame of a
231
+ // finished scene — unless looping (then wrap forever). A scene
232
+ // that is `done` but not yet at its last frame must keep going.
233
+ while: () => {
234
+ if (!isPlaying)
235
+ return false;
236
+ const total = totalFramesValue;
237
+ if (total === null)
238
+ return true; // still buffering
239
+ if (optsRef.current.loop && !optsRef.current.isInfinite) {
240
+ return true;
241
+ }
242
+ return currentFrame < total - 1;
243
+ },
244
+ });
245
+ return loop(tick).pipe(Effect.ensuring(Effect.sync(() => updateIsPlaying(false))));
246
+ });
247
+ const pause = Effect.sync(() => {
248
+ isPlaying = false;
249
+ updateIsPlaying(false);
250
+ });
251
+ return Context.make(PlayerScene, {
252
+ play,
253
+ pause,
254
+ frameIndex: Effect.sync(() => currentFrame),
255
+ render,
256
+ load: (frameIndex) => loadFrameBuffer(frameIndex),
257
+ });
258
+ })).pipe(
259
+ // per-mount session: the canvas (sized by the render path) plus the
260
+ // scene's declared fonts and images, held until the runtime is
261
+ // disposed. Session.make awaits font/image settlement, so nothing
262
+ // renders (and the player can't report ready) before assets settle.
263
+ Layer.provideMerge(Session.layer({
264
+ width: 1,
265
+ height: 1,
266
+ fonts: Fonts.urlMap(scene),
267
+ images: Images.urlMap(scene),
268
+ })), Layer.provideMerge(thorLayer)));
269
+ const runtimeRef = useRef(null);
270
+ const getRuntime = () => {
271
+ if (runtimeRef.current === null) {
272
+ runtimeRef.current = makeRuntime();
86
273
  }
87
- else if (event.key === "ArrowRight") {
88
- event.preventDefault();
89
- player.pause();
90
- player.seek(player.frame + 1);
274
+ return runtimeRef.current;
275
+ };
276
+ useEffect(() => () => {
277
+ // dispose interrupts our own in-flight fibers (play loop, prebuffer);
278
+ // its promise then rejects with that interruption — expected teardown,
279
+ // never actionable from a cleanup callback
280
+ runtimeRef.current?.dispose().catch(() => undefined);
281
+ runtimeRef.current = null;
282
+ }, []);
283
+ // Disposing the runtime on unmount INTERRUPTS in-flight fibers (the play
284
+ // loop, prebuffering). These handlers are fire-and-forget, so a rejecting
285
+ // runPromise would surface as an unhandled rejection on every navigation
286
+ // away from a playing scene. runPromiseExit never rejects — failures come
287
+ // back as Exit values. Two teardown shapes are silenced: interruption-only
288
+ // causes (dispose cancelling our own fibers — under strict mode this
289
+ // happens on EVERY mount, whose first-generation runtime is disposed with
290
+ // render/prebuffer still in flight), and failures from a runtime that is
291
+ // no longer current (a stale generation's teardown noise). Everything else
292
+ // is a real player error and is reported.
293
+ const runReported = (effect) => {
294
+ const runtime = getRuntime();
295
+ return runtime.runPromiseExit(effect).then((exit) => {
296
+ if (exit._tag !== "Failure") {
297
+ return;
298
+ }
299
+ if (Cause.hasInterruptsOnly(exit.cause)) {
300
+ return;
301
+ }
302
+ if (runtimeRef.current !== runtime) {
303
+ return;
304
+ }
305
+ console.error("effect-motion player:", String(exit.cause), exit.cause);
306
+ });
307
+ };
308
+ const render = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.render(frameIndex)), Effect.scoped)));
309
+ const load = useEffectEvent((frameIndex) => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.load(frameIndex)), Effect.scoped)));
310
+ const play = useEffectEvent(() => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.play), Effect.scoped)));
311
+ const pause = useEffectEvent(() => runReported(Effect.service(PlayerScene).pipe(Effect.flatMap((e) => e.pause), Effect.scoped)));
312
+ // prebuffer + autoplay once the runtime exists. For an infinite scene we
313
+ // can't buffer to the end, so cap at the requested prebuffer count.
314
+ // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount; render/load/play are stable useEffectEvents and the options only matter at startup
315
+ useEffect(() => {
316
+ render(0);
317
+ load(isInfinite ? prebufferedFrames : Math.max(prebufferedFrames, 0));
318
+ if (autoPlay) {
319
+ play();
91
320
  }
92
- else if (event.key === "ArrowLeft") {
93
- event.preventDefault();
94
- player.pause();
95
- player.seek(player.frame - 1);
321
+ }, []);
322
+ // scene time in seconds of the current frame, and total when known
323
+ const currentTime = Time.frameToMillis(currentFrame, fps) / 1000;
324
+ const totalTime = totalFrames !== null
325
+ ? Time.frameToMillis(totalFrames - 1, fps) / 1000
326
+ : null;
327
+ return {
328
+ ref: canvasRef,
329
+ currentFrame,
330
+ bufferedFrames,
331
+ totalFrames,
332
+ currentTime,
333
+ totalTime,
334
+ play,
335
+ pause,
336
+ seek: render,
337
+ isPlaying,
338
+ loop,
339
+ setLoop,
340
+ load,
341
+ };
342
+ };
343
+ // keep-everything cap for an infinite scene: enough for ~30s of backward seek
344
+ // at 60fps, ~1MB of frame data. Bump via bufferCapacity for deeper scrubbing.
345
+ const INFINITE_BUFFER_CAP = 1800;
346
+ export const Player = ({ scene, fps: fpsProp = 60,
347
+ // default: prebuffer everything (Infinity) so the total time / progress bar
348
+ // are known up front; an infinite scene falls back to a finite window below.
349
+ prebufferedFrames = Number.POSITIVE_INFINITY, autoPlay = false, isInfinite = false,
350
+ // initial repeat mode; the player owns it after mount (toggle button)
351
+ defaultRepeatMode = false, bufferCapacity, settings, }) => {
352
+ // one effective rate for both the scene run and the playback clock
353
+ const fps = settings?.frameRate ?? fpsProp;
354
+ const { ref, currentFrame, bufferedFrames, totalFrames, currentTime, totalTime, play, pause, seek, isPlaying, loop, setLoop, } = useScene(scene, {
355
+ fps,
356
+ settings,
357
+ // an infinite scene can never buffer to the end — window it (60 frames
358
+ // if the caller left prebufferedFrames unbounded)
359
+ prebufferedFrames: isInfinite && !Number.isFinite(prebufferedFrames)
360
+ ? 60
361
+ : prebufferedFrames,
362
+ autoPlay,
363
+ isInfinite,
364
+ loop: defaultRepeatMode,
365
+ // finite scenes keep everything (so far-back seek works); infinite
366
+ // scenes are windowed so memory can't grow forever
367
+ bufferCapacity: bufferCapacity ??
368
+ (isInfinite ? INFINITE_BUFFER_CAP : Number.POSITIVE_INFINITY),
369
+ });
370
+ // progress denominator: the total when known, else the buffered edge
371
+ const denominator = (totalFrames ?? bufferedFrames) - 1;
372
+ // ---- chrome state (hover/scrub only — playback state lives in useScene) ----
373
+ const [hovering, setHovering] = useState(false);
374
+ const [scrubbing, setScrubbing] = useState(false);
375
+ const [barHover, setBarHover] = useState(false);
376
+ const [bigPlayHover, setBigPlayHover] = useState(false);
377
+ // frame under the cursor while hovering the scrubber (for the time chip)
378
+ const [hoverFrame, setHoverFrame] = useState(0);
379
+ const barRef = useRef(null);
380
+ const controlsVisible = hovering || scrubbing || !isPlaying;
381
+ const frameAtPointer = (clientX) => {
382
+ const bar = barRef.current;
383
+ if (!bar || denominator <= 0) {
384
+ return 0;
96
385
  }
386
+ const rect = bar.getBoundingClientRect();
387
+ const frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
388
+ return Math.round(frac * denominator);
97
389
  };
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] }));
390
+ const playedFrac = denominator > 0 ? currentFrame / denominator : 0;
391
+ const bufferedFrac = denominator > 0 ? Math.min(1, (bufferedFrames - 1) / denominator) : 0;
392
+ // the chip tracks the cursor while hovering, the playhead while dragging
393
+ const chipFrame = scrubbing ? currentFrame : hoverFrame;
394
+ const chipFrac = denominator > 0 ? chipFrame / denominator : 0;
395
+ const barActive = barHover || scrubbing;
396
+ return (_jsxs("div", { style: S.player, onMouseEnter: () => setHovering(true), onMouseLeave: () => setHovering(false), children: [_jsx("canvas", { ref: ref, style: S.canvas, onClick: () => (isPlaying ? pause() : play()) }), !isPlaying && (_jsx("button", { type: "button", "aria-label": "Play", style: {
397
+ ...S.bigPlay,
398
+ background: bigPlayHover ? ACCENT : "rgba(0, 0, 0, 0.65)",
399
+ }, onMouseEnter: () => setBigPlayHover(true), onMouseLeave: () => setBigPlayHover(false), onClick: () => play(), children: _jsx(PlayIcon, { size: 26 }) })), _jsx("div", { style: { ...S.gradient, opacity: controlsVisible ? 1 : 0 } }), _jsxs("div", { style: {
400
+ ...S.controls,
401
+ opacity: controlsVisible ? 1 : 0,
402
+ pointerEvents: controlsVisible ? "auto" : "none",
403
+ }, children: [_jsx("button", { type: "button", "aria-label": isPlaying ? "Pause" : "Play", style: S.iconButton, onClick: () => (isPlaying ? pause() : play()), children: isPlaying ? _jsx(PauseIcon, {}) : _jsx(PlayIcon, {}) }), !isInfinite && (_jsxs("div", { ref: barRef, role: "slider", "aria-label": "Seek", "aria-valuemin": 0, "aria-valuemax": Math.max(0, denominator), "aria-valuenow": currentFrame, tabIndex: 0, style: S.scrub, onPointerDown: (e) => {
404
+ e.currentTarget.setPointerCapture(e.pointerId);
405
+ setScrubbing(true);
406
+ seek(frameAtPointer(e.clientX));
407
+ }, onPointerMove: (e) => {
408
+ setHoverFrame(frameAtPointer(e.clientX));
409
+ if (scrubbing) {
410
+ seek(frameAtPointer(e.clientX));
411
+ }
412
+ }, onPointerUp: () => setScrubbing(false), onMouseEnter: () => setBarHover(true), onMouseLeave: () => setBarHover(false), onKeyDown: (e) => {
413
+ // arrow keys step one second of frames
414
+ if (e.key === "ArrowRight") {
415
+ seek(Math.min(denominator, currentFrame + fps));
416
+ }
417
+ else if (e.key === "ArrowLeft") {
418
+ seek(Math.max(0, currentFrame - fps));
419
+ }
420
+ }, children: [_jsxs("div", { style: { ...S.track, height: barActive ? 6 : 4 }, children: [_jsx("div", { style: {
421
+ ...S.trackFill,
422
+ width: `${bufferedFrac * 100}%`,
423
+ background: "rgba(255, 255, 255, 0.35)",
424
+ } }), _jsx("div", { style: {
425
+ ...S.trackFill,
426
+ width: `${playedFrac * 100}%`,
427
+ background: ACCENT,
428
+ } })] }), barActive && (_jsxs(_Fragment, { children: [_jsx("div", { style: { ...S.thumb, left: `${playedFrac * 100}%` } }), _jsx("div", { style: { ...S.chip, left: `${chipFrac * 100}%` }, children: formatTime(Time.frameToMillis(chipFrame, fps) / 1000) })] }))] })), _jsxs("span", { style: S.time, children: [formatTime(currentTime), totalTime !== null ? ` / ${formatTime(totalTime)}` : ""] }), !isInfinite && (_jsx("button", { type: "button", "aria-label": "Repeat", "aria-pressed": loop, title: loop ? "Repeat on" : "Repeat off", style: {
429
+ ...S.iconButton,
430
+ color: loop ? ACCENT : "rgba(255, 255, 255, 0.7)",
431
+ }, onClick: () => setLoop((v) => !v), children: _jsx(RepeatIcon, {}) }))] })] }));
432
+ };
433
+ // ---------------------------------------------------------------------------
434
+ // chrome: Vimeo-style player skin. Self-contained by design — inline style
435
+ // objects and inline SVG icons only, no stylesheet/tailwind/image deps.
436
+ // ---------------------------------------------------------------------------
437
+ const ACCENT = "#00adef"; // Vimeo blue
438
+ const formatTime = (seconds) => {
439
+ const s = Math.max(0, Math.floor(seconds));
440
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
441
+ };
442
+ const PlayIcon = ({ size = 18 }) => (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M7 4.5v15l13-7.5z" }) }));
443
+ const PauseIcon = ({ size = 18 }) => (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M6.5 4.5h4v15h-4zM13.5 4.5h4v15h-4z" }) }));
444
+ // looping arrows (stroke icon — reads as "repeat" at small sizes)
445
+ const RepeatIcon = ({ size = 18 }) => (_jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M17 2l4 4-4 4" }), _jsx("path", { d: "M3 11V9a4 4 0 0 1 4-4h14" }), _jsx("path", { d: "M7 22l-4-4 4-4" }), _jsx("path", { d: "M21 13v2a4 4 0 0 1-4 4H3" })] }));
446
+ const S = {
447
+ player: {
448
+ position: "relative",
449
+ width: "100%",
450
+ background: "#000",
451
+ borderRadius: 8,
452
+ overflow: "hidden",
453
+ lineHeight: 1,
454
+ userSelect: "none",
455
+ WebkitUserSelect: "none",
456
+ fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
457
+ },
458
+ canvas: {
459
+ display: "block",
460
+ width: "100%",
461
+ height: "auto",
462
+ cursor: "pointer",
463
+ },
464
+ bigPlay: {
465
+ position: "absolute",
466
+ top: "50%",
467
+ left: "50%",
468
+ transform: "translate(-50%, -50%)",
469
+ width: 66,
470
+ height: 46,
471
+ border: "none",
472
+ borderRadius: 8,
473
+ color: "#fff",
474
+ display: "flex",
475
+ alignItems: "center",
476
+ justifyContent: "center",
477
+ cursor: "pointer",
478
+ transition: "background 120ms ease",
479
+ },
480
+ gradient: {
481
+ position: "absolute",
482
+ left: 0,
483
+ right: 0,
484
+ bottom: 0,
485
+ height: 72,
486
+ background: "linear-gradient(transparent, rgba(0, 0, 0, 0.6))",
487
+ pointerEvents: "none",
488
+ transition: "opacity 200ms ease",
489
+ },
490
+ controls: {
491
+ position: "absolute",
492
+ left: 0,
493
+ right: 0,
494
+ bottom: 0,
495
+ display: "flex",
496
+ alignItems: "center",
497
+ gap: 10,
498
+ padding: "0 10px 8px 6px",
499
+ transition: "opacity 200ms ease",
500
+ },
501
+ iconButton: {
502
+ border: "none",
503
+ background: "transparent",
504
+ padding: 4,
505
+ margin: 0,
506
+ color: "#fff",
507
+ display: "flex",
508
+ alignItems: "center",
509
+ cursor: "pointer",
510
+ },
511
+ scrub: {
512
+ position: "relative",
513
+ flex: 1,
514
+ display: "flex",
515
+ alignItems: "center",
516
+ height: 16,
517
+ cursor: "pointer",
518
+ touchAction: "none",
519
+ },
520
+ track: {
521
+ position: "relative",
522
+ width: "100%",
523
+ borderRadius: 2,
524
+ background: "rgba(255, 255, 255, 0.2)",
525
+ overflow: "hidden",
526
+ transition: "height 100ms ease",
527
+ },
528
+ trackFill: {
529
+ position: "absolute",
530
+ left: 0,
531
+ top: 0,
532
+ bottom: 0,
533
+ borderRadius: 2,
534
+ },
535
+ thumb: {
536
+ position: "absolute",
537
+ top: "50%",
538
+ width: 12,
539
+ height: 12,
540
+ borderRadius: "50%",
541
+ background: "#fff",
542
+ transform: "translate(-50%, -50%)",
543
+ boxShadow: "0 0 4px rgba(0, 0, 0, 0.4)",
544
+ pointerEvents: "none",
545
+ },
546
+ chip: {
547
+ position: "absolute",
548
+ bottom: 20,
549
+ transform: "translateX(-50%)",
550
+ background: "rgba(0, 0, 0, 0.85)",
551
+ color: "#fff",
552
+ fontSize: 11,
553
+ fontVariantNumeric: "tabular-nums",
554
+ padding: "4px 6px",
555
+ borderRadius: 4,
556
+ whiteSpace: "nowrap",
557
+ pointerEvents: "none",
558
+ },
559
+ time: {
560
+ // pushes itself + the repeat button to the right of the scrubber
561
+ marginLeft: "auto",
562
+ color: "#fff",
563
+ fontSize: 12,
564
+ fontVariantNumeric: "tabular-nums",
565
+ },
136
566
  };
package/dist/index.d.ts CHANGED
@@ -1,2 +1 @@
1
- export { Player, type PlayerProps } from "./Player";
2
- export { type AnyScene, type Player as PlayerHandle, type PlayerFrame, type PlayerStatus, type UsePlayerOptions, usePlayer, } from "./usePlayer";
1
+ export { Player, type PlayerProps } from "./Player.js";
package/dist/index.js CHANGED
@@ -1,2 +1 @@
1
- export { Player } from "./Player";
2
- export { usePlayer, } from "./usePlayer";
1
+ export { Player } from "./Player.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-motion/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "React bindings for effect-motion: usePlayer hook and Player component",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,10 @@
22
22
  ".": {
23
23
  "types": "./dist/index.d.ts",
24
24
  "default": "./dist/index.js"
25
+ },
26
+ "./*": {
27
+ "types": "./dist/*.d.ts",
28
+ "default": "./dist/*.js"
25
29
  }
26
30
  },
27
31
  "files": [
@@ -31,17 +35,19 @@
31
35
  "access": "public"
32
36
  },
33
37
  "dependencies": {
34
- "effect-motion": "^0.2.0"
38
+ "@effect-motion/thorvg": "^0.1.0",
39
+ "effect-motion": "^0.3.0"
35
40
  },
36
41
  "peerDependencies": {
37
- "effect": ">=4.0.0-beta.94",
42
+ "effect": ">=4.0.0-beta.98",
38
43
  "react": ">=18"
39
44
  },
40
45
  "devDependencies": {
41
46
  "@testing-library/react": "^16.3.0",
47
+ "@types/node": "^26.1.1",
42
48
  "@types/react": "^19.2.0",
43
49
  "@types/react-dom": "^19.2.0",
44
- "effect": "4.0.0-beta.94",
50
+ "effect": "4.0.0-beta.98",
45
51
  "happy-dom": "^20.10.6",
46
52
  "react": "^19.2.0",
47
53
  "react-dom": "^19.2.0",
@@ -51,7 +57,7 @@
51
57
  "scripts": {
52
58
  "build": "tsc -p tsconfig.build.json",
53
59
  "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
54
- "test": "vitest run",
60
+ "test": "vitest run --passWithNoTests",
55
61
  "check": "tsc --noEmit"
56
62
  }
57
63
  }
@@ -1,46 +0,0 @@
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;
package/dist/usePlayer.js DELETED
@@ -1,218 +0,0 @@
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
- };