animate_it 0.4.0 → 0.5.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.
@@ -1,15 +1,38 @@
1
+ require "json"
2
+
1
3
  module AnimateIt
4
+ class EmbedBuilder
5
+ attr_reader :composition
6
+
7
+ def initialize(view, composition, navigation: {})
8
+ @view = view
9
+ @composition = composition
10
+ @navigation = navigation
11
+ end
12
+
13
+ def chapter_navigation(**attributes, &block)
14
+ custom = block.present?
15
+ preset = if attributes.key?(:preset)
16
+ attributes.delete(:preset)
17
+ elsif !custom
18
+ @navigation.fetch(:preset, :pills)
19
+ end
20
+ mobile = if attributes.key?(:mobile)
21
+ attributes.delete(:mobile)
22
+ elsif !custom
23
+ @navigation[:mobile]
24
+ end
25
+ style = ["--animate-it-chapter-count: #{@composition.chapters.count}", attributes.delete(:style)].compact.join(";")
26
+ ChapterNavigationBuilder.new(@view, @composition, interactive: true, preset:, mobile:, frame: 0)
27
+ .render(**attributes, style:, &block)
28
+ end
29
+ end
30
+
2
31
  module EmbedHelper
3
32
  def animate_it_player(composition_id, title: nil, **attributes)
4
- AnimateIt.load_compositions!
5
- composition = AnimateIt.registry.fetch(composition_id)
6
- raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player?
7
-
8
- prefix = respond_to?(:request) && request ? request.script_name.to_s : ""
9
- source = "#{prefix}#{AnimateIt.config.mount_path}/public/compositions/" \
10
- "#{ERB::Util.url_encode(composition.id)}/player"
33
+ composition = public_animate_it_composition!(composition_id)
11
34
  defaults = {
12
- src: source,
35
+ src: animate_it_public_player_path(composition),
13
36
  title: title || composition.id,
14
37
  loading: "lazy",
15
38
  allow: "autoplay; fullscreen",
@@ -18,5 +41,174 @@ module AnimateIt
18
41
  }
19
42
  tag.iframe(**defaults, **attributes)
20
43
  end
44
+
45
+ def animate_it_embed(
46
+ composition_id,
47
+ poster:,
48
+ variants: [],
49
+ navigation: { preset: :pills },
50
+ load_when_visible: 0.25,
51
+ play_when_visible: 2.0 / 3,
52
+ pause_offscreen: true,
53
+ reduced_motion: :poster,
54
+ autoplay: true,
55
+ title: nil,
56
+ **attributes,
57
+ &block
58
+ )
59
+ composition = public_animate_it_composition!(composition_id)
60
+ navigation = navigation == false ? false : (navigation || {}).to_h.deep_symbolize_keys
61
+ resolved_variants = resolve_animate_it_variants(composition, poster, variants)
62
+ validate_animate_it_variant_chapters!(resolved_variants)
63
+ manifest = animate_it_embed_manifest(
64
+ title: title || composition.id,
65
+ variants: resolved_variants,
66
+ load_when_visible:,
67
+ play_when_visible:,
68
+ pause_offscreen:,
69
+ reduced_motion:,
70
+ autoplay:
71
+ )
72
+ builder = EmbedBuilder.new(self, composition, navigation: navigation || {})
73
+ navigation_html = if navigation
74
+ block ? capture(builder, &block) : builder.chapter_navigation(class: "animate-it-embed__navigation")
75
+ end
76
+ classes = ["animate-it-embed", attributes.delete(:class)].compact.join(" ")
77
+ data = (attributes.delete(:data) || {}).merge(animate_it_embed: true)
78
+
79
+ safe_join(
80
+ [
81
+ stylesheet_link_tag(animate_it_embed_asset_path("embed.css"), data: { animate_it_embed_asset: "style" }),
82
+ javascript_include_tag(
83
+ animate_it_embed_asset_path("embed.js"), defer: true, data: { animate_it_embed_asset: "script" }
84
+ ),
85
+ tag.public_send("animate-it-embed", **attributes, class: classes, data:) do
86
+ safe_join([
87
+ navigation_html,
88
+ animate_it_embed_viewport(resolved_variants, title || composition.id),
89
+ tag.script(ERB::Util.json_escape(JSON.generate(manifest)).html_safe,
90
+ type: "application/json", data: { animate_it_embed_manifest: true })
91
+ ].compact)
92
+ end
93
+ ]
94
+ )
95
+ end
96
+
97
+ private
98
+
99
+ def public_animate_it_composition!(composition_id)
100
+ AnimateIt.load_compositions!
101
+ composition = AnimateIt.registry.fetch(composition_id)
102
+ raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player?
103
+
104
+ composition
105
+ end
106
+
107
+ def animate_it_mount_prefix
108
+ respond_to?(:request) && request ? request.script_name.to_s : ""
109
+ end
110
+
111
+ def animate_it_public_player_path(composition)
112
+ id = ERB::Util.url_encode(composition.id)
113
+ "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/public/compositions/#{id}/player"
114
+ end
115
+
116
+ def animate_it_embed_asset_path(filename)
117
+ version = ERB::Util.url_encode(AnimateIt::VERSION)
118
+ "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/assets/#{version}/#{filename}"
119
+ end
120
+
121
+ def resolve_animate_it_variants(composition, poster, variants)
122
+ raise ArgumentError, "animate_it_embed requires a poster" if poster.blank?
123
+
124
+ primary = animate_it_variant_hash(composition, poster:, media: nil)
125
+ responsive = Array(variants).map do |variant|
126
+ attributes = variant.to_h.deep_symbolize_keys
127
+ target = public_animate_it_composition!(attributes.fetch(:composition))
128
+ media = attributes.fetch(:media).to_s
129
+ raise ArgumentError, "AnimateIt variant media query must not be blank" if media.blank?
130
+
131
+ animate_it_variant_hash(target, poster: attributes.fetch(:poster), media:)
132
+ end
133
+ duplicate_media = responsive.group_by { |variant| variant.fetch("media") }.select { |_media, group| group.many? }.keys
134
+ raise ArgumentError, "AnimateIt variant media queries must be unique: #{duplicate_media.join(", ")}" if duplicate_media.any?
135
+
136
+ [primary, *responsive]
137
+ end
138
+
139
+ def animate_it_variant_hash(composition, poster:, media:)
140
+ raise ArgumentError, "AnimateIt variant poster must not be blank" if poster.blank?
141
+
142
+ manifest = composition.player_manifest.as_json
143
+ {
144
+ "media" => media,
145
+ "composition" => manifest,
146
+ "poster" => poster.to_s,
147
+ "source" => animate_it_public_player_path(composition)
148
+ }
149
+ end
150
+
151
+ def validate_animate_it_variant_chapters!(variants)
152
+ expected = variants.first.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") }
153
+ variants.drop(1).each do |variant|
154
+ actual = variant.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") }
155
+ next if actual == expected
156
+
157
+ raise ArgumentError, "AnimateIt responsive variants must expose the same ordered chapter names and labels"
158
+ end
159
+ end
160
+
161
+ def animate_it_embed_manifest(title:, variants:, load_when_visible:, play_when_visible:, pause_offscreen:, reduced_motion:, autoplay:)
162
+ load_ratio = Float(load_when_visible)
163
+ play_ratio = Float(play_when_visible)
164
+ unless load_ratio.between?(0, 1) && play_ratio.between?(0, 1)
165
+ raise ArgumentError, "AnimateIt visibility thresholds must be between 0 and 1"
166
+ end
167
+ raise ArgumentError, "AnimateIt reduced_motion must be :poster" unless reduced_motion.to_s == "poster"
168
+
169
+ {
170
+ "version" => 1,
171
+ "title" => title,
172
+ "variants" => variants,
173
+ "options" => {
174
+ "loadWhenVisible" => load_ratio,
175
+ "playWhenVisible" => play_ratio,
176
+ "pauseOffscreen" => pause_offscreen == true,
177
+ "reducedMotion" => reduced_motion.to_s,
178
+ "autoplay" => autoplay == true,
179
+ "readyTimeout" => 5000,
180
+ "crossfadeDuration" => 120
181
+ }
182
+ }
183
+ rescue TypeError, ArgumentError => e
184
+ raise e if e.message.start_with?("AnimateIt")
185
+
186
+ raise ArgumentError, "AnimateIt visibility thresholds must be numbers between 0 and 1"
187
+ end
188
+
189
+ def animate_it_embed_viewport(variants, title)
190
+ primary = variants.first
191
+ sources = variants.drop(1).map do |variant|
192
+ tag.source(media: variant.fetch("media"), srcset: variant.fetch("poster"))
193
+ end
194
+ poster = tag.picture(
195
+ safe_join([*sources, image_tag(primary.fetch("poster"), alt: title, loading: "eager")]),
196
+ class: "animate-it-embed__poster", data: { animate_it_embed_poster: true }
197
+ )
198
+ viewport = tag.div(class: "animate-it-embed__viewport", data: { animate_it_embed_viewport: true }) do
199
+ safe_join(
200
+ [
201
+ poster,
202
+ tag.div(tag.div("", class: "animate-it-embed__frame", data: { animate_it_embed_frame: true }),
203
+ class: "animate-it-embed__shell", data: { animate_it_embed_shell: true }),
204
+ tag.button(
205
+ "Play", type: "button", class: "animate-it-embed__control", hidden: true,
206
+ data: { animate_it_embed_control: true }, aria: { label: "Play animation", pressed: "false" }
207
+ )
208
+ ]
209
+ )
210
+ end
211
+ safe_join([viewport, tag.noscript(image_tag(primary.fetch("poster"), alt: title))])
212
+ end
21
213
  end
22
214
  end
@@ -0,0 +1,338 @@
1
+ (function (global) {
2
+ "use strict";
3
+
4
+ if (!global.customElements || global.customElements.get("animate-it-embed")) return;
5
+
6
+ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
7
+
8
+ class AnimateItEmbed extends HTMLElement {
9
+ connectedCallback() {
10
+ if (this.connected) return;
11
+ this.connected = true;
12
+ this.manifest = JSON.parse(this.querySelector("[data-animate-it-embed-manifest]").textContent);
13
+ this.options = this.manifest.options;
14
+ this.viewport = this.querySelector("[data-animate-it-embed-viewport]");
15
+ this.frame = this.querySelector("[data-animate-it-embed-frame]");
16
+ this.control = this.querySelector("[data-animate-it-embed-control]");
17
+ this.chapterControls = Array.from(this.querySelectorAll("[data-animate-it-chapter]"));
18
+ this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)");
19
+ this.userPaused = false;
20
+ this.userStarted = this.options.autoplay;
21
+ this.visibleRatio = 0;
22
+ this.playerReady = false;
23
+ this.currentChapter = null;
24
+ this.playing = false;
25
+ this.boundMessage = this.receiveMessage.bind(this);
26
+ this.boundVisibility = this.syncPlayback.bind(this);
27
+ this.boundVariantChange = this.variantChanged.bind(this);
28
+ this.boundReducedMotion = this.reducedMotionChanged.bind(this);
29
+ this.boundControl = this.toggle.bind(this);
30
+ this.boundResize = this.scaleFrame.bind(this);
31
+ global.addEventListener("message", this.boundMessage);
32
+ document.addEventListener("visibilitychange", this.boundVisibility);
33
+ this.control.addEventListener("click", this.boundControl);
34
+ this.reducedMotion.addEventListener("change", this.boundReducedMotion);
35
+ this.chapterControls.forEach((control) => {
36
+ control.addEventListener("click", () => this.seekChapter(control.dataset.animateItChapter));
37
+ });
38
+ if ("ResizeObserver" in global) {
39
+ this.resizeObserver = new global.ResizeObserver(this.boundResize);
40
+ this.resizeObserver.observe(this.viewport);
41
+ } else {
42
+ global.addEventListener("resize", this.boundResize);
43
+ }
44
+ this.setupVariants();
45
+ this.setupVisibility();
46
+ this.applyReducedMotion();
47
+ }
48
+
49
+ disconnectedCallback() {
50
+ global.removeEventListener("message", this.boundMessage);
51
+ document.removeEventListener("visibilitychange", this.boundVisibility);
52
+ this.control && this.control.removeEventListener("click", this.boundControl);
53
+ this.reducedMotion && this.reducedMotion.removeEventListener("change", this.boundReducedMotion);
54
+ this.resizeObserver && this.resizeObserver.disconnect();
55
+ global.removeEventListener("resize", this.boundResize);
56
+ this.intersectionObserver && this.intersectionObserver.disconnect();
57
+ (this.variantQueries || []).forEach((entry) => entry.query.removeEventListener("change", this.boundVariantChange));
58
+ this.cancelLoad();
59
+ this.clearReadyTimer();
60
+ this.removePlayer();
61
+ this.connected = false;
62
+ }
63
+
64
+ setupVariants() {
65
+ this.variantQueries = this.manifest.variants.filter((variant) => variant.media).map((variant) => ({
66
+ variant: variant,
67
+ query: global.matchMedia(variant.media)
68
+ }));
69
+ this.variantQueries.forEach((entry) => entry.query.addEventListener("change", this.boundVariantChange));
70
+ this.activateVariant(this.selectedVariant());
71
+ }
72
+
73
+ selectedVariant() {
74
+ const matched = this.variantQueries.find((entry) => entry.query.matches);
75
+ return matched ? matched.variant : this.manifest.variants[0];
76
+ }
77
+
78
+ variantChanged() {
79
+ const variant = this.selectedVariant();
80
+ if (this.variant && variant.source === this.variant.source) return;
81
+ this.activateVariant(variant);
82
+ }
83
+
84
+ activateVariant(variant) {
85
+ const resumeChapter = this.currentChapter;
86
+ this.cancelLoad();
87
+ this.clearReadyTimer();
88
+ this.removePlayer();
89
+ this.variant = variant;
90
+ this.resumeChapter = resumeChapter;
91
+ this.playerReady = false;
92
+ this.dataset.playerReady = "false";
93
+ const composition = variant.composition;
94
+ this.style.setProperty("--animate-it-crossfade-duration", `${this.options.crossfadeDuration}ms`);
95
+ this.viewport.style.aspectRatio = `${composition.width} / ${composition.height}`;
96
+ this.scaleFrame();
97
+ this.updateChapters(0, null);
98
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad();
99
+ }
100
+
101
+ setupVisibility() {
102
+ if (!global.IntersectionObserver) {
103
+ this.visibleRatio = 1;
104
+ if (!this.reducedMotion.matches) this.scheduleLoad();
105
+ return;
106
+ }
107
+ const thresholds = Array.from(new Set([0, this.options.loadWhenVisible, this.options.playWhenVisible, 1])).sort();
108
+ this.intersectionObserver = new IntersectionObserver((entries) => {
109
+ const entry = entries[entries.length - 1];
110
+ this.visibleRatio = entry && entry.isIntersecting && entry.boundingClientRect.height > 0 ?
111
+ entry.intersectionRect.height / entry.boundingClientRect.height : 0;
112
+ if (this.visibleRatio >= this.options.loadWhenVisible && !this.iframe && !this.reducedMotion.matches) this.scheduleLoad();
113
+ this.syncPlayback();
114
+ }, { threshold: thresholds });
115
+ this.intersectionObserver.observe(this.viewport);
116
+ }
117
+
118
+ applyReducedMotion() {
119
+ this.dataset.reducedMotion = this.reducedMotion.matches ? "true" : "false";
120
+ if (this.reducedMotion.matches) {
121
+ this.cancelLoad();
122
+ this.removePlayer();
123
+ }
124
+ }
125
+
126
+ reducedMotionChanged() {
127
+ this.applyReducedMotion();
128
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad();
129
+ }
130
+
131
+ scheduleLoad() {
132
+ if (this.iframe || this.loadHandle) return;
133
+ const load = () => {
134
+ this.loadHandle = null;
135
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.mountPlayer();
136
+ };
137
+ if ("requestIdleCallback" in global) this.loadHandle = global.requestIdleCallback(load, { timeout: 800 });
138
+ else this.loadHandle = global.setTimeout(load, 0);
139
+ }
140
+
141
+ cancelLoad() {
142
+ if (!this.loadHandle) return;
143
+ if ("cancelIdleCallback" in global) global.cancelIdleCallback(this.loadHandle);
144
+ else global.clearTimeout(this.loadHandle);
145
+ this.loadHandle = null;
146
+ }
147
+
148
+ mountPlayer() {
149
+ if (this.iframe) return;
150
+ const composition = this.variant.composition;
151
+ const separator = this.variant.source.includes("?") ? "&" : "?";
152
+ const iframe = document.createElement("iframe");
153
+ iframe.src = `${this.variant.source}${separator}embedded=1&host_navigation=${this.chapterControls.length ? "1" : "0"}`;
154
+ iframe.title = this.manifest.title;
155
+ iframe.loading = "eager";
156
+ iframe.tabIndex = -1;
157
+ iframe.setAttribute("aria-hidden", "true");
158
+ iframe.setAttribute("allow", "autoplay");
159
+ iframe.width = composition.width;
160
+ iframe.height = composition.height;
161
+ iframe.addEventListener("load", () => {
162
+ if (iframe !== this.iframe) return;
163
+ if (!iframe.contentDocument || !iframe.contentDocument.querySelector("[data-animate-it-tracks]")) {
164
+ this.fail(new Error("AnimateIt player returned an invalid document"));
165
+ return;
166
+ }
167
+ this.readyTimer = global.setTimeout(() => this.degradedReady(), this.options.readyTimeout);
168
+ }, { once: true });
169
+ iframe.addEventListener("error", () => this.fail(new Error("AnimateIt player failed to load")), { once: true });
170
+ this.iframe = iframe;
171
+ this.frame.replaceChildren(iframe);
172
+ this.scaleFrame();
173
+ }
174
+
175
+ removePlayer() {
176
+ if (this.iframe) {
177
+ this.send("pause");
178
+ this.iframe.remove();
179
+ }
180
+ this.iframe = null;
181
+ this.playerReady = false;
182
+ this.playing = false;
183
+ if (this.control) this.control.hidden = true;
184
+ }
185
+
186
+ scaleFrame() {
187
+ if (!this.variant || !this.viewport) return;
188
+ const composition = this.variant.composition;
189
+ const scale = this.viewport.clientWidth / composition.width;
190
+ this.frame.style.width = `${composition.width}px`;
191
+ this.frame.style.height = `${composition.height}px`;
192
+ this.frame.style.transform = `scale(${scale})`;
193
+ }
194
+
195
+ receiveMessage(event) {
196
+ if (!this.iframe || event.source !== this.iframe.contentWindow || event.origin !== global.location.origin) return;
197
+ const message = event.data || {};
198
+ if (message.namespace !== "animate-it" || !message.event) return;
199
+ const detail = message.detail || {};
200
+ if (message.event === "ready") this.ready(detail);
201
+ else if (message.event === "framechange") this.frameChanged(detail);
202
+ else if (message.event === "chapterchange") this.chapterChanged(detail);
203
+ else if (message.event === "play") this.setPlaying(true);
204
+ else if (message.event === "pause" || message.event === "ended") this.setPlaying(false);
205
+ else if (message.event === "error") this.fail(new Error(detail.message || "AnimateIt player error"));
206
+ this.dispatchEvent(new CustomEvent(`animateit:${message.event}`, { detail }));
207
+ }
208
+
209
+ ready(detail) {
210
+ this.clearReadyTimer();
211
+ this.playerReady = true;
212
+ this.dataset.playerReady = "true";
213
+ this.dataset.playerReadiness = "complete";
214
+ this.control.hidden = false;
215
+ if (this.resumeChapter) this.send("seekChapter", { chapter: this.resumeChapter });
216
+ this.updateChapters(detail.frame || 0, detail.chapter);
217
+ this.syncPlayback();
218
+ }
219
+
220
+ degradedReady() {
221
+ if (!this.iframe || this.playerReady) return;
222
+ this.playerReady = true;
223
+ this.dataset.playerReady = "true";
224
+ this.dataset.playerReadiness = "degraded";
225
+ this.control.hidden = false;
226
+ this.syncPlayback();
227
+ this.dispatchEvent(new CustomEvent("animateit:degradedready"));
228
+ }
229
+
230
+ fail(error) {
231
+ this.clearReadyTimer();
232
+ this.dataset.playerReady = "false";
233
+ this.dataset.playerError = "true";
234
+ this.playerReady = false;
235
+ this.control.hidden = true;
236
+ this.dispatchEvent(new CustomEvent("animateit:error", { detail: { message: error.message } }));
237
+ }
238
+
239
+ clearReadyTimer() {
240
+ if (this.readyTimer) global.clearTimeout(this.readyTimer);
241
+ this.readyTimer = null;
242
+ }
243
+
244
+ frameChanged(detail) {
245
+ this.lastFrame = Number(detail.frame) || 0;
246
+ this.updateChapters(this.lastFrame, detail.chapter);
247
+ }
248
+
249
+ chapterChanged(detail) {
250
+ this.currentChapter = detail.chapter || null;
251
+ this.updateChapters(Number(detail.frame) || 0, this.currentChapter);
252
+ }
253
+
254
+ updateChapters(frame, namedChapter) {
255
+ const chapters = this.variant.composition.chapters;
256
+ let currentIndex = -1;
257
+ for (let index = chapters.length - 1; index >= 0; index -= 1) {
258
+ if (frame >= chapters[index].startFrame) { currentIndex = index; break; }
259
+ }
260
+ if (namedChapter) currentIndex = chapters.findIndex((chapter) => chapter.name === namedChapter);
261
+ const current = currentIndex >= 0 ? chapters[currentIndex] : null;
262
+ this.currentChapter = current && current.name;
263
+ const progress = current ? (current.durationFrames === 1 ? 1 :
264
+ clamp((frame - current.startFrame) / (current.durationFrames - 1), 0, 1)) : 0;
265
+ this.chapterControls.forEach((control) => {
266
+ const index = chapters.findIndex((chapter) => chapter.name === control.dataset.animateItChapter);
267
+ const state = currentIndex < 0 || index > currentIndex ? "upcoming" : (index < currentIndex ? "completed" : "current");
268
+ const position = currentIndex < 0 ? "hidden" :
269
+ (index === currentIndex ? "current" : (index === currentIndex - 1 ? "previous" : (index === currentIndex + 1 ? "next" : "hidden")));
270
+ control.dataset.chapterState = state;
271
+ control.dataset.chapterPosition = position;
272
+ control.style.setProperty("--animate-it-chapter-progress", String(state === "completed" ? 1 : (state === "current" ? progress : 0)));
273
+ control.style.setProperty("--animate-it-chapter-active", state === "current" ? "1" : "0");
274
+ control.style.setProperty("--animate-it-chapter-complete", state === "completed" ? "1" : "0");
275
+ if (state === "current") control.setAttribute("aria-current", "step");
276
+ else control.removeAttribute("aria-current");
277
+ });
278
+ }
279
+
280
+ seekChapter(name) {
281
+ if (!this.playerReady) { this.resumeChapter = name; return; }
282
+ this.send("seekChapter", { chapter: name });
283
+ }
284
+
285
+ seek(frame) {
286
+ this.send("seek", { frame: Number(frame) || 0 });
287
+ }
288
+
289
+ play() {
290
+ this.userStarted = true;
291
+ this.userPaused = false;
292
+ this.send("play");
293
+ }
294
+
295
+ pause() {
296
+ this.userPaused = true;
297
+ this.send("pause");
298
+ }
299
+
300
+ playingState() {
301
+ return this.playing;
302
+ }
303
+
304
+ currentFrame() {
305
+ return this.lastFrame || 0;
306
+ }
307
+
308
+ toggle() {
309
+ if (!this.playerReady) return;
310
+ this.userStarted = true;
311
+ this.userPaused = this.playing;
312
+ this.send(this.playing ? "pause" : "play");
313
+ }
314
+
315
+ syncPlayback() {
316
+ if (!this.playerReady) return;
317
+ const inViewport = this.visibleRatio >= this.options.playWhenVisible;
318
+ const shouldPlay = this.userStarted && !this.userPaused && !document.hidden &&
319
+ (inViewport || !this.options.pauseOffscreen);
320
+ if (shouldPlay && !this.playing) this.send("play");
321
+ else if (!shouldPlay && this.playing) this.send("pause");
322
+ }
323
+
324
+ send(command, detail) {
325
+ if (!this.iframe || !this.iframe.contentWindow) return;
326
+ this.iframe.contentWindow.postMessage(Object.assign({ namespace: "animate-it", command }, detail || {}), global.location.origin);
327
+ }
328
+
329
+ setPlaying(playing) {
330
+ this.playing = playing;
331
+ this.control.textContent = playing ? "Pause" : "Play";
332
+ this.control.setAttribute("aria-label", playing ? "Pause animation" : "Play animation");
333
+ this.control.setAttribute("aria-pressed", playing ? "true" : "false");
334
+ }
335
+ }
336
+
337
+ global.customElements.define("animate-it-embed", AnimateItEmbed);
338
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,13 @@
1
+ module AnimateIt
2
+ module EmbedRuntime
3
+ module_function
4
+
5
+ def javascript
6
+ @javascript ||= File.read(File.expand_path("embed_runtime/embed.js", __dir__)).freeze
7
+ end
8
+
9
+ def stylesheet
10
+ EmbedStyles.source
11
+ end
12
+ end
13
+ end