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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +84 -4
- data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
- data/app/controllers/animate_it/frames_controller.rb +3 -0
- data/app/controllers/animate_it/public_players_controller.rb +5 -1
- data/app/views/animate_it/frames/player.html.haml +12 -1
- data/config/routes.rb +5 -0
- data/lib/animate_it/chapter_navigation.rb +160 -0
- data/lib/animate_it/chapters.rb +95 -0
- data/lib/animate_it/composition.rb +15 -0
- data/lib/animate_it/embed_helper.rb +200 -8
- data/lib/animate_it/embed_runtime/embed.js +338 -0
- data/lib/animate_it/embed_runtime.rb +13 -0
- data/lib/animate_it/embed_styles.rb +126 -0
- data/lib/animate_it/engine.rb +4 -1
- data/lib/animate_it/player_manifest.rb +29 -0
- data/lib/animate_it/runtime/runtime.js +138 -5
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it.rb +8 -0
- metadata +13 -6
|
@@ -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
|
-
|
|
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:
|
|
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);
|