@vectoriox/iox-ui 5.73.4 → 5.74.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.
@@ -13,6 +13,8 @@ var FrameSet;
13
13
  FrameSet["Wide"] = "w";
14
14
  FrameSet["Phone"] = "p";
15
15
  })(FrameSet || (FrameSet = {}));
16
+ /** At or under this width a screen is a phone. Scene's own limit says the same: scene-core reads media-core, never the reverse. */
17
+ const FRAMES_PHONE_MAX_WIDTH = 767;
16
18
  var FrameExtension;
17
19
  (function (FrameExtension) {
18
20
  FrameExtension["Webp"] = "webp";
@@ -41,11 +43,13 @@ function normalizeFrameSequence(raw) {
41
43
  if (!wide || typeof value.base !== 'string' || !SAFE_BASE.test(value.base) || !ALL_EXTENSIONS.has(String(value.ext)))
42
44
  return null;
43
45
  const phone = setInfo(value.phone);
46
+ const phoneBase = phone && typeof value.phoneBase === 'string' && SAFE_BASE.test(value.phoneBase) ? value.phoneBase.replace(/\/+$/, '') : '';
44
47
  return {
45
48
  base: value.base.replace(/\/+$/, ''),
46
49
  ext: value.ext,
47
50
  wide,
48
51
  ...(phone ? { phone } : {}),
52
+ ...(phoneBase ? { phoneBase } : {}),
49
53
  ...(Number.isFinite(value.bytes) && value.bytes > 0 ? { bytes: value.bytes } : {}),
50
54
  };
51
55
  }
@@ -63,7 +67,8 @@ function frameFileName(index, ext) {
63
67
  }
64
68
  /** Picture `index` (from 0) of a set. */
65
69
  function frameUrl(sequence, set, index) {
66
- return `${sequence.base}/${set}/${frameFileName(index, sequence.ext)}`;
70
+ const base = set === FrameSet.Phone && sequence.phoneBase ? sequence.phoneBase : sequence.base;
71
+ return `${base}/${set}/${frameFileName(index, sequence.ext)}`;
67
72
  }
68
73
  /** The first picture of each set, for a poster's `srcset`; '' when there is only one set. */
69
74
  function phoneFrameSrcset(sequence) {
@@ -75,6 +80,31 @@ function phoneFrameSrcset(sequence) {
75
80
  function frameSetFor(sequence, phone) {
76
81
  return phone && sequence.phone ? { set: FrameSet.Phone, info: sequence.phone } : { set: FrameSet.Wide, info: sequence.wide };
77
82
  }
83
+ /** Where `played` % stands in a set of `count`: 2.5 is half way from the third picture to the fourth. */
84
+ function framePosition(played, count) {
85
+ return (Math.min(100, Math.max(0, played)) / 100) * (count - 1);
86
+ }
87
+ /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
88
+ const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
89
+ /** How long a stopped scroll waits before settling on a whole picture, and how fast it gets there per frame. */
90
+ const REST_AFTER_MS = 140;
91
+ const REST_RATE = 0.25;
92
+ /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
93
+ const FRAME_BLEND_STEPS = 32;
94
+ /**
95
+ * The moment between two pictures. A long Scene gives a picture 50px of scroll or more, so a slow scroll would otherwise
96
+ * show the film stepping. Blending the two neighbours keeps it moving under the finger, at the cost of one more drawImage.
97
+ * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
98
+ */
99
+ function frameBlendAt(loaded, position) {
100
+ const from = Math.floor(position);
101
+ const to = from + 1;
102
+ const mix = Math.round((position - from) * FRAME_BLEND_STEPS) / FRAME_BLEND_STEPS;
103
+ if (mix > 0 && mix < 1 && loaded[from] && loaded[to])
104
+ return { from, to, mix };
105
+ const nearest = nearestLoadedFrame(loaded, Math.round(position));
106
+ return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
107
+ }
78
108
  /** The picture at `played` % of a set of `count`. */
79
109
  function frameIndexAt(played, count) {
80
110
  if (count <= 1)
@@ -168,11 +198,14 @@ class FramePlayer {
168
198
  constructor(el, sequence, options = {}) {
169
199
  this.el = el;
170
200
  this.sequence = sequence;
201
+ this.played = null;
202
+ this.restTimer = null;
203
+ this.restFrame = null;
171
204
  this.observer = null;
172
205
  this.canvas = null;
173
206
  this.context = null;
174
- this.wanted = 0;
175
- this.drawn = -1;
207
+ this.position = 0;
208
+ this.drawn = null;
176
209
  this.nextToLoad = 0;
177
210
  this.loading = 0;
178
211
  this.started = false;
@@ -199,8 +232,37 @@ class FramePlayer {
199
232
  }
200
233
  /** Stand at `played` %. */
201
234
  seek(played) {
202
- this.wanted = frameIndexAt(played, this.info.count);
235
+ if (played === this.played)
236
+ return;
237
+ this.played = played;
238
+ this.stopResting();
239
+ this.position = framePosition(played, this.info.count);
203
240
  this.draw();
241
+ this.restTimer = this.host.setTimeout?.(() => this.rest(), REST_AFTER_MS) ?? null;
242
+ }
243
+ /** A stopped scroll must not stand on two pictures at once: a moving subject would show twice. It settles on the nearer. */
244
+ rest() {
245
+ this.restTimer = null;
246
+ const target = Math.round(this.position);
247
+ const step = () => {
248
+ this.restFrame = null;
249
+ if (this.destroyed)
250
+ return;
251
+ const left = target - this.position;
252
+ this.position = Math.abs(left) < 1 / FRAME_BLEND_STEPS ? target : this.position + left * REST_RATE;
253
+ this.draw();
254
+ if (this.position !== target)
255
+ this.restFrame = this.host.requestAnimationFrame(step);
256
+ };
257
+ step();
258
+ }
259
+ stopResting() {
260
+ if (this.restTimer !== null)
261
+ this.host.clearTimeout?.(this.restTimer);
262
+ if (this.restFrame !== null)
263
+ this.host.cancelAnimationFrame?.(this.restFrame);
264
+ this.restTimer = null;
265
+ this.restFrame = null;
204
266
  }
205
267
  /** Whether a re-render took the canvas away with the markup it sat in. */
206
268
  get detached() {
@@ -208,6 +270,8 @@ class FramePlayer {
208
270
  }
209
271
  destroy() {
210
272
  this.destroyed = true;
273
+ this.stopResting();
274
+ this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
211
275
  this.observer?.disconnect();
212
276
  this.observer = null;
213
277
  for (const image of this.pending) {
@@ -266,15 +330,26 @@ class FramePlayer {
266
330
  image.src = frameUrl(this.sequence, this.set, index);
267
331
  }
268
332
  draw() {
269
- const index = nearestLoadedFrame(this.images, this.wanted);
270
- if (index < 0 || index === this.drawn)
333
+ const blend = frameBlendAt(this.images, this.position);
334
+ const last = this.drawn;
335
+ if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix))
271
336
  return;
272
- const image = this.images[index];
337
+ const image = this.images[blend.from];
273
338
  if (!this.ensureCanvas(image))
274
339
  return;
275
- this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
276
- this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
277
- this.drawn = index;
340
+ const context = this.context;
341
+ const { width, height } = this.canvas;
342
+ context.clearRect(0, 0, width, height);
343
+ context.drawImage(image, 0, 0, width, height);
344
+ if (blend.mix > 0) {
345
+ context.globalAlpha = blend.mix;
346
+ context.drawImage(this.images[blend.to], 0, 0, width, height);
347
+ context.globalAlpha = 1;
348
+ }
349
+ // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
350
+ if (!this.drawn)
351
+ this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
352
+ this.drawn = blend;
278
353
  }
279
354
  ensureCanvas(image) {
280
355
  if (this.canvas)
@@ -639,7 +714,13 @@ function videoInnerHtml(plan, options) {
639
714
  const srcset = own ? phoneFrameSrcset(plan.frames) : '';
640
715
  // Its real size, so where a Scene flows (reduced motion, no script) the picture holds its height before it loads.
641
716
  const size = own ? ` width="${plan.frames.wide.width}" height="${plan.frames.wide.height}"` : '';
642
- return `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size} alt="${esc(plan.title)}" decoding="async">`;
717
+ const image = (attrs) => `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${attrs} alt="${esc(plan.title)}" decoding="async">`;
718
+ // A phone film is framed for a tall screen, so it is chosen by the screen, never by the pixels a high-density phone asks for.
719
+ if (own && plan.frames.phoneBase) {
720
+ const { width, height } = plan.frames.phone;
721
+ return `<picture><source media="(max-width: ${FRAMES_PHONE_MAX_WIDTH}px)" srcset="${esc(frameUrl(plan.frames, FrameSet.Phone, 0))}" width="${width}" height="${height}">${image(size)}</picture>`;
722
+ }
723
+ return image(`${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size}`);
643
724
  }
644
725
  const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
645
726
  const label = esc(videoPlayLabel(plan, words));
@@ -907,5 +988,5 @@ function videoRuntimeOf(el) {
907
988
  * Generated bundle index. Do not edit.
908
989
  */
909
990
 
910
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameFileName, frameIndexAt, frameLoadOrder, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
991
+ export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
911
992
  //# sourceMappingURL=vectoriox-iox-ui-media-core.mjs.map