css-motion-library 0.0.0-stage → 1.0.1

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/src/player.js ADDED
@@ -0,0 +1,140 @@
1
+ /** A small video-like controller for CSS animations. No animation engine required. */
2
+ export function createMotionPlayer(root) {
3
+ if (root.motionPlayer) return root.motionPlayer;
4
+ const document = root.ownerDocument;
5
+ const window = document.defaultView;
6
+ const stage = root.querySelector('[data-motion-stage]');
7
+ const controls = root.querySelector('[data-motion-controls]');
8
+ const toggle = root.querySelector('[data-motion-toggle]');
9
+ const restart = root.querySelector('[data-motion-restart]');
10
+ const seekInput = root.querySelector('[data-motion-seek]');
11
+ const timeLabel = root.querySelector('[data-motion-time]');
12
+ const speedInput = root.querySelector('[data-motion-speed]');
13
+ const duration = Number(root.dataset.duration);
14
+ if (!stage || !controls || !toggle || !restart || !seekInput || !timeLabel || !Number.isFinite(duration) || !(duration > 0)) {
15
+ throw new TypeError('Motion player requires a stage, playback controls and a positive data-duration.');
16
+ }
17
+ // Unsupported browsers retain the static overview and hidden controls.
18
+ if (!stage.getAnimations || !window.matchMedia) return null;
19
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
20
+ const cleanup = [];
21
+ let frame = 0;
22
+ let userPaused = false;
23
+ let playing = false;
24
+ let destroyed = false;
25
+ let scrubbing = false;
26
+ let visible = !window.IntersectionObserver;
27
+ let hostVisible = true;
28
+ let reduced = false;
29
+ root.classList.add('is-ready');
30
+ const animations = stage.getAnimations({subtree: true});
31
+ const clock = animations.find(animation => animation.animationName === 'mvl-clock') || animations[0];
32
+ if (!clock) { root.classList.remove('is-ready'); return null; }
33
+ animations.forEach(animation => { animation.pause(); animation.currentTime = 0; });
34
+ controls.hidden = false;
35
+
36
+ const formatTime = seconds => Math.floor(seconds / 60) + ':' + String(Math.floor(seconds % 60)).padStart(2, '0');
37
+ const getTime = () => ((Number(clock.currentTime) || 0) % (duration * 1000)) / 1000;
38
+ function listen(target, name, callback) {
39
+ target.addEventListener(name, callback);
40
+ cleanup.push(() => target.removeEventListener(name, callback));
41
+ }
42
+ function draw() {
43
+ const seconds = getTime();
44
+ seekInput.value = String(seconds);
45
+ seekInput.setAttribute('aria-valuetext', `${formatTime(seconds)} of ${formatTime(duration)}`);
46
+ seekInput.style.setProperty('--progress', `${seconds / duration * 100}%`);
47
+ timeLabel.textContent = `${formatTime(seconds)} / ${formatTime(duration)}`;
48
+ }
49
+ function tick() {
50
+ draw();
51
+ if (playing && !destroyed) frame = window.requestAnimationFrame(tick);
52
+ }
53
+ function sync() {
54
+ if (destroyed) return;
55
+ reduced = reducedMotion.matches || document.documentElement.getAttribute('data-a11y-reduce-motion') === 'true';
56
+ const next = !userPaused && !reduced && visible && hostVisible && !document.hidden && !scrubbing;
57
+ root.classList.toggle('is-reduced', reduced);
58
+ root.dataset.state = reduced ? 'reduced' : next ? 'playing' : 'paused';
59
+ controls.hidden = reduced;
60
+ toggle.textContent = userPaused ? '▶ Play' : 'Ⅱ Pause';
61
+ toggle.setAttribute('aria-label', userPaused ? 'Play animation' : 'Pause animation');
62
+ if (next !== playing) {
63
+ playing = next;
64
+ window.cancelAnimationFrame(frame);
65
+ if (playing) {
66
+ const position = Number(clock.currentTime) || 0;
67
+ animations.forEach(animation => { animation.currentTime = position; animation.play(); });
68
+ frame = window.requestAnimationFrame(tick);
69
+ } else animations.forEach(animation => animation.pause());
70
+ }
71
+ draw();
72
+ }
73
+ function seek(seconds) {
74
+ if (destroyed || !Number.isFinite(Number(seconds))) return;
75
+ const position = Math.max(0, Math.min(duration - .001, Number(seconds))) * 1000;
76
+ animations.forEach(animation => { animation.currentTime = position; });
77
+ draw();
78
+ }
79
+ const api = {
80
+ play() { userPaused = false; sync(); },
81
+ pause() { userPaused = true; sync(); },
82
+ restart() { seek(0); userPaused = false; sync(); },
83
+ seek,
84
+ get currentTime() { return getTime(); },
85
+ get playing() { return playing; },
86
+ destroy() {
87
+ if (destroyed) return;
88
+ destroyed = true;
89
+ playing = false;
90
+ window.cancelAnimationFrame(frame);
91
+ animations.forEach(animation => animation.cancel());
92
+ cleanup.forEach(remove => remove());
93
+ root.classList.remove('is-ready', 'is-reduced');
94
+ delete root.dataset.state;
95
+ controls.hidden = true;
96
+ delete root.motionPlayer;
97
+ }
98
+ };
99
+ listen(toggle, 'click', () => { userPaused = !userPaused; sync(); });
100
+ listen(restart, 'click', () => api.restart());
101
+ listen(seekInput, 'input', () => seek(seekInput.value));
102
+ listen(seekInput, 'pointerdown', () => { scrubbing = true; sync(); });
103
+ listen(window, 'pointerup', () => { scrubbing = false; sync(); });
104
+ listen(window, 'pointercancel', () => { scrubbing = false; sync(); });
105
+ if (speedInput) listen(speedInput, 'change', () => {
106
+ const speed = Number(speedInput.value);
107
+ if (Number.isFinite(speed) && speed > 0 && speed <= 4) {
108
+ animations.forEach(animation => { animation.playbackRate = speed; });
109
+ }
110
+ });
111
+ listen(document, 'visibilitychange', sync);
112
+ if (reducedMotion.addEventListener) listen(reducedMotion, 'change', sync);
113
+ else {
114
+ reducedMotion.addListener(sync);
115
+ cleanup.push(() => reducedMotion.removeListener(sync));
116
+ }
117
+ if (window.MutationObserver) {
118
+ const observer = new window.MutationObserver(sync);
119
+ observer.observe(document.documentElement, {attributes: true, attributeFilter: ['data-a11y-reduce-motion']});
120
+ cleanup.push(() => observer.disconnect());
121
+ }
122
+ if (window.IntersectionObserver) {
123
+ const observer = new window.IntersectionObserver(entries => {
124
+ visible = entries[0].isIntersecting;
125
+ sync();
126
+ }, {threshold: .05});
127
+ observer.observe(root);
128
+ cleanup.push(() => observer.disconnect());
129
+ }
130
+ // Gallery hosts can suspend an offscreen iframe without resetting manual pause.
131
+ listen(window, 'message', event => {
132
+ if (event.origin !== window.location.origin || event.source !== window.parent) return;
133
+ if (event.data?.type !== 'css-motion:visibility' || typeof event.data.visible !== 'boolean') return;
134
+ hostVisible = event.data.visible;
135
+ sync();
136
+ });
137
+ root.motionPlayer = api;
138
+ sync();
139
+ return api;
140
+ }