@glissade/player 0.7.0-pre.0 → 0.8.0-pre.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/index.d.ts CHANGED
@@ -39,6 +39,20 @@ interface ScrollDriverOptions {
39
39
  /** User-controlled scalar: scroll progress 0..1 → t. Writes the playhead directly. */
40
40
  declare function scrollDriver(opts: ScrollDriverOptions): Driver;
41
41
  //#endregion
42
+ //#region src/reducedMotion.d.ts
43
+ type ReducedMotionMode = 'respect' | 'ignore' | ((doc: Timeline) => Timeline);
44
+ interface ReducedMotionPlan {
45
+ /** Whether to autoplay after applying the plan. */
46
+ autoplay: boolean;
47
+ /** Seek the playhead here before first paint (the poster frame). */
48
+ seekTo?: number;
49
+ /** Replace the bound timeline with this calmer alternative (the fn form). */
50
+ swapTo?: Timeline;
51
+ }
52
+ declare function planReducedMotion(mode: ReducedMotionMode | undefined, prefersReduced: boolean, doc: Timeline, duration: number, autoplayRequested: boolean): ReducedMotionPlan;
53
+ /** Live `prefers-reduced-motion: reduce` reading; false off-DOM. */
54
+ declare function mediaPrefersReducedMotion(): boolean;
55
+ //#endregion
42
56
  //#region src/player.d.ts
43
57
  type LoopMode = boolean | {
44
58
  mode: 'restart' | 'alternate';
@@ -48,6 +62,20 @@ interface PlayerOptions {
48
62
  loop?: LoopMode;
49
63
  rate?: number;
50
64
  autoplay?: boolean;
65
+ /**
66
+ * What playback does while the tab is hidden (§4.1, via the driver's
67
+ * `visibility`). `'pause'` (default) freezes and resumes where it left off —
68
+ * no wall-clock jump on return; `'run'` advances by the hidden duration.
69
+ */
70
+ background?: 'pause' | 'run';
71
+ /**
72
+ * prefers-reduced-motion policy (applied by mount(), §4.2). `'respect'`
73
+ * (default) holds the poster frame and suppresses autoplay; `'ignore'` opts
74
+ * out; a function returns a calmer alternative timeline.
75
+ */
76
+ reducedMotion?: ReducedMotionMode;
77
+ /** Override reduced-motion detection (defaults to the media query). */
78
+ prefersReducedMotion?: () => boolean;
51
79
  }
52
80
  interface PlayHandle {
53
81
  /** true = completed naturally, false = interrupted. Completion signal ONLY (§2). */
@@ -86,8 +114,21 @@ interface Player {
86
114
  pause(): void;
87
115
  /** Direct playhead write: pure, never fires marker callbacks (§4.2). */
88
116
  seek(t: number): void;
117
+ /**
118
+ * Rebind to a recompiled timeline (HMR, §4.3): swap duration/markers/targets
119
+ * in place and **preserve the current playhead** (clamped to the new
120
+ * duration) — no replay-to-frame. Playing state and registered marker/cue
121
+ * callbacks survive; range resets to the new full span.
122
+ */
123
+ swap(next: {
124
+ duration: number;
125
+ markers?: Marker[];
126
+ targets?: Iterable<string>;
127
+ }): void;
89
128
  /** Register a marker callback; fired only when continuous playback crosses it. */
90
129
  onMarker(name: string, cb: (marker: Marker) => void): () => void;
130
+ /** Like onMarker but matches a cue's `data.kind` (e.g. 'ad-break') across all names. */
131
+ onCue(kind: string, cb: (marker: Marker) => void): () => void;
91
132
  /**
92
133
  * Wire a machine to the host clock (v2 §A.5): step(now) on every tick, in
93
134
  * attach order, before evaluation — even while linear playback is paused.
@@ -104,8 +145,31 @@ interface Mounted {
104
145
  backend: Canvas2DBackend;
105
146
  /** Force a synchronous render of the current playhead time. */
106
147
  render(): void;
148
+ /**
149
+ * Hot-swap the bound scene and/or timeline (HMR, §4.3) while preserving the
150
+ * playhead: recompiles, rebinds the player (duration/markers/targets), and
151
+ * re-renders at the current time. A scene node a track targeted but the new
152
+ * scene no longer has simply stops being written (stale binding) — it keeps
153
+ * its last value rather than erroring.
154
+ */
155
+ swap(next: {
156
+ scene?: Scene;
157
+ timeline: Timeline;
158
+ }): void;
107
159
  dispose(): void;
108
160
  }
109
- declare function mount(scene: Scene, doc: Timeline, canvas: HTMLCanvasElement | OffscreenCanvas, opts?: PlayerOptions): Mounted;
161
+ declare function mount(initialScene: Scene, initialDoc: Timeline, canvas: HTMLCanvasElement | OffscreenCanvas, opts?: PlayerOptions): Mounted;
162
+ //#endregion
163
+ //#region src/hmr.d.ts
164
+ interface SceneModuleShape {
165
+ scene?: Scene;
166
+ timeline: Timeline;
167
+ }
168
+ /**
169
+ * Build the `import.meta.hot.accept` callback that hot-swaps `mounted` from a
170
+ * re-evaluated scene module. `rerun` maps the updated module namespace to the
171
+ * fresh `{ scene?, timeline }`.
172
+ */
173
+ declare function swapOnHmr(mounted: Mounted, initialTimeline: Timeline, rerun: (mod: Record<string, unknown>) => SceneModuleShape): (mod: Record<string, unknown> | undefined) => void;
110
174
  //#endregion
111
- export { type AttachedMachine, type Driver, type DriverContext, type InputDriver, type LoopMode, type Mounted, type PlayHandle, type Player, type PlayerInit, type PlayerOptions, type ScrollDriverOptions, TargetOverlapError, clockDriver, createPlayer, mount, scrollDriver };
175
+ export { type AttachedMachine, type Driver, type DriverContext, type InputDriver, type LoopMode, type Mounted, type PlayHandle, type Player, type PlayerInit, type PlayerOptions, type ReducedMotionMode, type ReducedMotionPlan, type SceneModuleShape, type ScrollDriverOptions, TargetOverlapError, clockDriver, createPlayer, mediaPrefersReducedMotion, mount, planReducedMotion, scrollDriver, swapOnHmr };
package/dist/index.js CHANGED
@@ -69,26 +69,35 @@ var TargetOverlapError = class extends Error {
69
69
  }
70
70
  };
71
71
  function createPlayer(init, opts = {}) {
72
- const { playhead, duration } = init;
73
- const markers = init.markers ?? [];
72
+ const { playhead } = init;
73
+ let liveDuration = init.duration;
74
+ let markers = init.markers ?? [];
74
75
  const driver = init.driver ?? clockDriver();
75
76
  const loop = opts.loop ?? false;
77
+ const background = opts.background ?? "pause";
78
+ const visibility = init.visibility ?? (() => "visible");
76
79
  const callbacks = /* @__PURE__ */ new Map();
80
+ const cueCallbacks = /* @__PURE__ */ new Map();
77
81
  let rate = opts.rate ?? 1;
78
82
  let playing = false;
79
83
  let driverRunning = false;
80
- const ownTargets = new Set(init.targets ?? []);
84
+ let ownTargets = new Set(init.targets ?? []);
81
85
  const machines = [];
82
86
  let base = 0;
83
87
  let elapsedOrigin = null;
84
88
  let lastElapsed = 0;
85
- let range = [0, duration];
89
+ let range = [0, liveDuration];
86
90
  let loopsDone = 0;
87
91
  let resolveFinished = null;
88
92
  function fireMarkers(from, to) {
89
- if (from === to || callbacks.size === 0) return;
93
+ if (from === to || callbacks.size === 0 && cueCallbacks.size === 0) return;
90
94
  const forward = to > from;
91
- for (const m of markers) if (forward ? m.t > from && m.t <= to : m.t >= to && m.t < from) for (const cb of callbacks.get(m.name) ?? []) cb(m);
95
+ for (const m of markers) {
96
+ if (!(forward ? m.t > from && m.t <= to : m.t >= to && m.t < from)) continue;
97
+ for (const cb of callbacks.get(m.name) ?? []) cb(m);
98
+ const kind = m.data?.kind;
99
+ if (typeof kind === "string") for (const cb of cueCallbacks.get(kind) ?? []) cb(m);
100
+ }
92
101
  }
93
102
  function settle(completed) {
94
103
  playing = false;
@@ -101,6 +110,11 @@ function createPlayer(init, opts = {}) {
101
110
  lastElapsed = elapsed;
102
111
  for (const m of machines) m.step(elapsed);
103
112
  if (!playing) return;
113
+ if (background === "pause" && visibility() === "hidden") {
114
+ base = playhead.peek();
115
+ elapsedOrigin = null;
116
+ return;
117
+ }
104
118
  elapsedOrigin ??= elapsed;
105
119
  const prev = playhead.peek();
106
120
  let t = base + (elapsed - elapsedOrigin) * rate;
@@ -137,13 +151,15 @@ function createPlayer(init, opts = {}) {
137
151
  if (driverRunning) return;
138
152
  driverRunning = true;
139
153
  driver.start(onElapsed, {
140
- duration,
141
- visibility: init.visibility ?? (() => "visible")
154
+ duration: liveDuration,
155
+ visibility
142
156
  });
143
157
  }
144
158
  return {
145
159
  playhead,
146
- duration,
160
+ get duration() {
161
+ return liveDuration;
162
+ },
147
163
  get playing() {
148
164
  return playing;
149
165
  },
@@ -157,7 +173,7 @@ function createPlayer(init, opts = {}) {
157
173
  },
158
174
  play(playOpts) {
159
175
  if (resolveFinished) settle(false);
160
- range = playOpts?.range ?? [0, duration];
176
+ range = playOpts?.range ?? [0, liveDuration];
161
177
  const t = playhead.peek();
162
178
  const [lo, hi] = range;
163
179
  base = rate >= 0 ? t >= hi || t < lo ? lo : t : t <= lo || t > hi ? hi : t;
@@ -176,10 +192,21 @@ function createPlayer(init, opts = {}) {
176
192
  settle(false);
177
193
  },
178
194
  seek(t) {
179
- base = Math.min(Math.max(t, 0), duration);
195
+ base = Math.min(Math.max(t, 0), liveDuration);
180
196
  elapsedOrigin = playing ? lastElapsed : null;
181
197
  playhead.set(base);
182
198
  },
199
+ swap(next) {
200
+ liveDuration = next.duration;
201
+ markers = next.markers ?? [];
202
+ ownTargets = new Set(next.targets ?? []);
203
+ const t = Math.min(playhead.peek(), liveDuration);
204
+ range = [0, liveDuration];
205
+ base = t;
206
+ elapsedOrigin = playing ? lastElapsed : null;
207
+ loopsDone = 0;
208
+ playhead.set(t);
209
+ },
183
210
  attach(machine) {
184
211
  if (machine.targets) {
185
212
  const overlaps = [];
@@ -203,6 +230,15 @@ function createPlayer(init, opts = {}) {
203
230
  set.add(cb);
204
231
  return () => set.delete(cb);
205
232
  },
233
+ onCue(kind, cb) {
234
+ let set = cueCallbacks.get(kind);
235
+ if (!set) {
236
+ set = /* @__PURE__ */ new Set();
237
+ cueCallbacks.set(kind, set);
238
+ }
239
+ set.add(cb);
240
+ return () => set.delete(cb);
241
+ },
206
242
  dispose() {
207
243
  if (playing) settle(false);
208
244
  machines.length = 0;
@@ -212,23 +248,44 @@ function createPlayer(init, opts = {}) {
212
248
  };
213
249
  }
214
250
  //#endregion
251
+ //#region src/reducedMotion.ts
252
+ function planReducedMotion(mode, prefersReduced, doc, duration, autoplayRequested) {
253
+ const m = mode ?? "respect";
254
+ if (m === "ignore" || !prefersReduced) return { autoplay: autoplayRequested };
255
+ if (typeof m === "function") return {
256
+ autoplay: autoplayRequested,
257
+ swapTo: m(doc)
258
+ };
259
+ return {
260
+ autoplay: false,
261
+ seekTo: doc.posterTime ?? duration
262
+ };
263
+ }
264
+ /** Live `prefers-reduced-motion: reduce` reading; false off-DOM. */
265
+ function mediaPrefersReducedMotion() {
266
+ return typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
267
+ }
268
+ //#endregion
215
269
  //#region src/mount.ts
216
270
  /**
217
271
  * mount() — the vanilla embedding primitive (DESIGN.md §4.3): wire a scene +
218
272
  * timeline + canvas into a rendering Player. Rendering is pulled: a playhead
219
273
  * invalidation schedules one rAF-coalesced render.
220
274
  */
221
- function mount(scene, doc, canvas, opts = {}) {
275
+ function mount(initialScene, initialDoc, canvas, opts = {}) {
276
+ let scene = initialScene;
277
+ let doc = initialDoc;
222
278
  const compiled = compileTimeline(doc);
223
279
  const backend = new Canvas2DBackend(canvas);
224
280
  scene.setTextMeasurer(backend);
281
+ const playhead = scene.playhead;
225
282
  const player = createPlayer({
226
- playhead: scene.playhead,
283
+ playhead,
227
284
  duration: compiled.duration,
228
285
  markers: compiled.markers,
229
286
  targets: compiled.tracks.keys()
230
287
  }, opts);
231
- const renderNow = () => backend.render(evaluate(scene, doc, scene.playhead.peek()));
288
+ const renderNow = () => backend.render(evaluate(scene, doc, playhead.peek()));
232
289
  let scheduled = false;
233
290
  const schedule = () => {
234
291
  if (scheduled) return;
@@ -238,19 +295,51 @@ function mount(scene, doc, canvas, opts = {}) {
238
295
  renderNow();
239
296
  });
240
297
  };
241
- const unsubscribe = scene.playhead.subscribe(schedule);
242
- if (typeof FontFace !== "undefined") for (const [family, ref] of Object.entries(doc.assets ?? {})) {
243
- if (ref.kind !== "font") continue;
244
- const face = new FontFace(family, `url(${ref.url})`);
245
- document.fonts.add(face);
246
- face.load().then(() => renderNow(), () => void 0);
298
+ const unsubscribe = playhead.subscribe(schedule);
299
+ const loadFonts = (forDoc) => {
300
+ if (typeof FontFace === "undefined") return;
301
+ for (const [family, ref] of Object.entries(forDoc.assets ?? {})) {
302
+ if (ref.kind !== "font") continue;
303
+ const face = new FontFace(family, `url(${ref.url})`);
304
+ document.fonts.add(face);
305
+ face.load().then(() => renderNow(), () => void 0);
306
+ }
307
+ };
308
+ loadFonts(doc);
309
+ const prefersReduced = (opts.prefersReducedMotion ?? mediaPrefersReducedMotion)();
310
+ const plan = planReducedMotion(opts.reducedMotion, prefersReduced, doc, compiled.duration, !!opts.autoplay);
311
+ if (plan.swapTo) {
312
+ doc = plan.swapTo;
313
+ const recompiled = compileTimeline(doc);
314
+ player.swap({
315
+ duration: recompiled.duration,
316
+ markers: recompiled.markers,
317
+ targets: recompiled.tracks.keys()
318
+ });
319
+ loadFonts(doc);
247
320
  }
321
+ if (plan.seekTo !== void 0) player.seek(plan.seekTo);
248
322
  renderNow();
249
- if (opts.autoplay) player.play();
323
+ if (plan.autoplay) player.play();
250
324
  return {
251
325
  player,
252
326
  backend,
253
327
  render: renderNow,
328
+ swap(next) {
329
+ if (next.scene) {
330
+ scene = next.scene;
331
+ scene.setTextMeasurer(backend);
332
+ }
333
+ doc = next.timeline;
334
+ const recompiled = compileTimeline(doc);
335
+ player.swap({
336
+ duration: recompiled.duration,
337
+ markers: recompiled.markers,
338
+ targets: recompiled.tracks.keys()
339
+ });
340
+ loadFonts(doc);
341
+ renderNow();
342
+ },
254
343
  dispose() {
255
344
  unsubscribe();
256
345
  player.dispose();
@@ -259,4 +348,40 @@ function mount(scene, doc, canvas, opts = {}) {
259
348
  };
260
349
  }
261
350
  //#endregion
262
- export { TargetOverlapError, clockDriver, createPlayer, mount, scrollDriver };
351
+ //#region src/hmr.ts
352
+ /**
353
+ * Vite HMR glue for a mounted embed (DESIGN.md §4.3). When the scene module is
354
+ * edited, the dev server hands us the re-evaluated module; we hot-swap the live
355
+ * Mounted without losing the playhead (see Mounted.swap / Player.swap). The
356
+ * consumer owns the dependency path (only they know it), so this returns the
357
+ * accept callback rather than calling `import.meta.hot.accept` itself:
358
+ *
359
+ * import * as sceneMod from './hero.scene';
360
+ * const mounted = mount(sceneMod.createScene(), sceneMod.timeline, canvas);
361
+ * import.meta.hot?.accept(
362
+ * './hero.scene',
363
+ * swapOnHmr(mounted, sceneMod.timeline, (m) => ({ scene: m.createScene(), timeline: m.timeline })),
364
+ * );
365
+ *
366
+ * It also warns when an edit removes a label the previous timeline declared —
367
+ * code that seeks to that label (`play({ range: [labels.x, …] })`) would break.
368
+ */
369
+ /**
370
+ * Build the `import.meta.hot.accept` callback that hot-swaps `mounted` from a
371
+ * re-evaluated scene module. `rerun` maps the updated module namespace to the
372
+ * fresh `{ scene?, timeline }`.
373
+ */
374
+ function swapOnHmr(mounted, initialTimeline, rerun) {
375
+ let prevLabels = Object.keys(compileTimeline(initialTimeline).labels);
376
+ return (mod) => {
377
+ if (mod == null) return;
378
+ const next = rerun(mod);
379
+ const nextLabels = Object.keys(compileTimeline(next.timeline).labels);
380
+ const dropped = prevLabels.filter((l) => !nextLabels.includes(l));
381
+ if (dropped.length > 0) console.warn(`[glissade] HMR: this edit removed label(s) ${dropped.map((l) => `'${l}'`).join(", ")} — anything seeking to them will no longer resolve`);
382
+ prevLabels = nextLabels;
383
+ mounted.swap(next);
384
+ };
385
+ }
386
+ //#endregion
387
+ export { TargetOverlapError, clockDriver, createPlayer, mediaPrefersReducedMotion, mount, planReducedMotion, scrollDriver, swapOnHmr };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/player",
3
- "version": "0.7.0-pre.0",
3
+ "version": "0.8.0-pre.0",
4
4
  "description": "glissade embed runtime: Player, Drivers (clock, scroll), mount().",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,9 +15,9 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@glissade/backend-canvas2d": "0.7.0-pre.0",
19
- "@glissade/core": "0.7.0-pre.0",
20
- "@glissade/scene": "0.7.0-pre.0"
18
+ "@glissade/backend-canvas2d": "0.8.0-pre.0",
19
+ "@glissade/core": "0.8.0-pre.0",
20
+ "@glissade/scene": "0.8.0-pre.0"
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",