@akinon/pz-theme 2.0.127-rc.0 → 2.0.127
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/CHANGELOG.md +7 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/video-block.test.tsx +639 -24
- package/src/blocks/video-block.tsx +529 -45
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
BREAKPOINTS,
|
|
6
6
|
PHONE_AND_TABLET_MEDIA_CONDITION,
|
|
7
7
|
getResponsiveValue,
|
|
8
|
-
isNarrowBreakpoint
|
|
8
|
+
isNarrowBreakpoint,
|
|
9
|
+
partAttrs
|
|
9
10
|
} from '../utils';
|
|
10
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
11
12
|
|
|
@@ -38,27 +39,132 @@ interface VideoProperties {
|
|
|
38
39
|
sourceType?: string;
|
|
39
40
|
/** Gated on `controls === false` in the editor — it replaces native controls, not add to them. */
|
|
40
41
|
customPlayPause?: unknown;
|
|
42
|
+
/** Second control beside play/pause. Off by default: an ambient clip is silent furniture. */
|
|
43
|
+
soundToggle?: unknown;
|
|
44
|
+
/** One glyph per state the control can be in. Empty means "keep ours". */
|
|
45
|
+
playIcon?: unknown;
|
|
46
|
+
pauseIcon?: unknown;
|
|
47
|
+
soundOnIcon?: unknown;
|
|
48
|
+
soundOffIcon?: unknown;
|
|
49
|
+
/** On by default — see the observer effect for what "ambient" means here. */
|
|
50
|
+
playWhenVisible?: unknown;
|
|
51
|
+
/** `auto` | `metadata` | `none`, straight through to the element's attribute. */
|
|
52
|
+
preload?: unknown;
|
|
41
53
|
/** Off by default: mobile plays the same file as desktop unless this is on. */
|
|
42
54
|
useDifferentMobileVideo?: unknown;
|
|
43
55
|
mobileUrl?: unknown;
|
|
44
56
|
mobilePoster?: unknown;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* An OUTLINED triangle, not a solid one: one path that fills the triangle and
|
|
61
|
+
* then cuts a smaller one out of it, so the glyph reads as a hairline at 8px
|
|
62
|
+
* the way the pause bars beside it do. A solid wedge at this size turns into
|
|
63
|
+
* an ink blob next to two 1px rules.
|
|
64
|
+
*/
|
|
48
65
|
const PlayIcon = () => (
|
|
49
|
-
<svg width="
|
|
50
|
-
<path d="
|
|
66
|
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
|
67
|
+
<path fill="currentColor" d="M7 4 1 8V0zM2 6.13 5.197 4 2 1.868z" />
|
|
51
68
|
</svg>
|
|
52
69
|
);
|
|
53
70
|
|
|
54
|
-
/** Two 1x8 bars
|
|
71
|
+
/** Two 1x8 bars at the outer edges of the 8x8 box, to the play glyph's weight. */
|
|
55
72
|
const PauseIcon = () => (
|
|
56
|
-
<svg width="
|
|
57
|
-
<
|
|
58
|
-
|
|
73
|
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
|
|
74
|
+
<path d="M1.5 8h-1V0h1zm6 0h-1V0h1z" />
|
|
75
|
+
</svg>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* AN AUTHORED GLYPH, PAINTED BY WHATEVER THE PANEL PUT ON THE BUTTON.
|
|
80
|
+
*
|
|
81
|
+
* A mask rather than an `<img>`, and rather than the SVG-rewriting the icon
|
|
82
|
+
* block and the slider both do. Those two read a colour off the block's own
|
|
83
|
+
* styles and bake it into the SVG string before handing it over — which cannot
|
|
84
|
+
* work here, because this button's colour is a PART style: it is emitted as
|
|
85
|
+
* CSS (`[data-block-id=…] [data-part="control-button"]{color:…}`) and the
|
|
86
|
+
* renderer never sees the value. A mask filled with `currentColor` inherits
|
|
87
|
+
* whatever that rule resolved to, so an authored glyph answers Text Color
|
|
88
|
+
* exactly the way the four built-in ones do.
|
|
89
|
+
*
|
|
90
|
+
* The trade is that a multi-colour icon arrives monochrome. For a 20px control
|
|
91
|
+
* that is the right way round — the alternative is a glyph that ignores the
|
|
92
|
+
* brand colour the panel just set, on the one element whose whole job is to be
|
|
93
|
+
* legible against the film behind it.
|
|
94
|
+
*/
|
|
95
|
+
const AuthoredGlyph = ({ src }: { src: string }) => (
|
|
96
|
+
<span
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
style={{
|
|
99
|
+
display: 'block',
|
|
100
|
+
width: '100%',
|
|
101
|
+
height: '100%',
|
|
102
|
+
backgroundColor: 'currentColor',
|
|
103
|
+
WebkitMaskImage: `url("${src}")`,
|
|
104
|
+
maskImage: `url("${src}")`,
|
|
105
|
+
WebkitMaskRepeat: 'no-repeat',
|
|
106
|
+
maskRepeat: 'no-repeat',
|
|
107
|
+
WebkitMaskPosition: 'center',
|
|
108
|
+
maskPosition: 'center',
|
|
109
|
+
// `contain`, not `cover`: a glyph that overflows its 20px button reads as
|
|
110
|
+
// a smear, and an author's icon is rarely the same aspect as ours.
|
|
111
|
+
WebkitMaskSize: 'contain',
|
|
112
|
+
maskSize: 'contain'
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** Speaker with the sound arc struck through — the clip is silent right now. */
|
|
118
|
+
const SoundOffIcon = () => (
|
|
119
|
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
|
120
|
+
<path fill="currentColor" d="M0 3h1.4L3.4 1v6L1.4 5H0z" />
|
|
121
|
+
<path
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
strokeWidth="0.8"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
d="m5.2 2.8 2 2.4m0-2.4-2 2.4"
|
|
126
|
+
/>
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
/** The same speaker, arc intact. */
|
|
131
|
+
const SoundOnIcon = () => (
|
|
132
|
+
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
|
133
|
+
<path fill="currentColor" d="M0 3h1.4L3.4 1v6L1.4 5H0z" />
|
|
134
|
+
<path
|
|
135
|
+
stroke="currentColor"
|
|
136
|
+
strokeWidth="0.8"
|
|
137
|
+
strokeLinecap="round"
|
|
138
|
+
d="M5.2 2.5a2.1 2.1 0 0 1 0 3M6.6 1.6a3.6 3.6 0 0 1 0 4.8"
|
|
139
|
+
/>
|
|
59
140
|
</svg>
|
|
60
141
|
);
|
|
61
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The share of the player that has to be on screen before it plays, and below
|
|
145
|
+
* which it pauses again.
|
|
146
|
+
*
|
|
147
|
+
* Measured against the reference storefront rather than picked: its own
|
|
148
|
+
* observer is built with `threshold: 0.1`, and stepping a clip out of view
|
|
149
|
+
* there confirms the edge — still playing at 15% visible, paused at 8%.
|
|
150
|
+
*/
|
|
151
|
+
const VISIBILITY_PLAY_THRESHOLD = 0.1;
|
|
152
|
+
|
|
153
|
+
/** The inset the control sits at, from the two corners it is anchored to. */
|
|
154
|
+
const CONTROL_INSET_DESKTOP = '24px';
|
|
155
|
+
const CONTROL_INSET_NARROW = '18px';
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Asked at the moment it matters rather than held in state: the answer is only
|
|
159
|
+
* ever consulted inside an effect, and a shopper who changes this setting
|
|
160
|
+
* mid-visit is not a case worth a subscription. Same spelling — spaces and all
|
|
161
|
+
* — as `collapsible-block.tsx`, which is the other runtime reader.
|
|
162
|
+
*/
|
|
163
|
+
const prefersReducedMotion = () =>
|
|
164
|
+
typeof window !== 'undefined' &&
|
|
165
|
+
typeof window.matchMedia === 'function' &&
|
|
166
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
167
|
+
|
|
62
168
|
/** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
|
|
63
169
|
const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
|
|
64
170
|
|
|
@@ -220,10 +326,12 @@ const firstUrlAtAnyBreakpoint = (value: unknown): string => {
|
|
|
220
326
|
* A video: a YouTube or Vimeo embed, or a file the browser plays itself.
|
|
221
327
|
*
|
|
222
328
|
* Properties: url (on `block.value`), controls, autoplay, loop, muted,
|
|
223
|
-
* playsInline, poster, sourceType
|
|
329
|
+
* playsInline, poster, sourceType, preload, playWhenVisible, customPlayPause,
|
|
330
|
+
* soundToggle. Style: `aspect-ratio`.
|
|
224
331
|
*/
|
|
225
332
|
const VideoBlock = ({
|
|
226
333
|
block,
|
|
334
|
+
isDesigner = false,
|
|
227
335
|
currentBreakpoint = 'desktop'
|
|
228
336
|
}: BlockRendererProps) => {
|
|
229
337
|
const desktopUrl = block.value as string;
|
|
@@ -343,19 +451,265 @@ const VideoBlock = ({
|
|
|
343
451
|
!nativeControls &&
|
|
344
452
|
!!getResponsiveValue(properties.customPlayPause, currentBreakpoint, false);
|
|
345
453
|
|
|
454
|
+
/**
|
|
455
|
+
* The four glyphs, each falling back to the built-in one.
|
|
456
|
+
*
|
|
457
|
+
* Read per breakpoint like every other authored value, so a brand can carry
|
|
458
|
+
* a different mark on the phone — the control is 18px from the edge there
|
|
459
|
+
* rather than 24, and a busier glyph stops reading at that size.
|
|
460
|
+
*/
|
|
461
|
+
const authoredGlyph = (key: keyof VideoProperties) =>
|
|
462
|
+
(getResponsiveValue(properties[key], currentBreakpoint, '') as string) || '';
|
|
463
|
+
|
|
464
|
+
const glyphFor = (key: keyof VideoProperties, fallback: React.ReactNode) => {
|
|
465
|
+
const src = authoredGlyph(key);
|
|
466
|
+
return src ? <AuthoredGlyph src={src} /> : fallback;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** The mute button rides with the play/pause one; it cannot appear alone. */
|
|
470
|
+
const showSoundToggle =
|
|
471
|
+
customPlayPause &&
|
|
472
|
+
!!getResponsiveValue(properties.soundToggle, currentBreakpoint, false);
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* DOES THIS CLIP PLAY BY ITSELF?
|
|
476
|
+
*
|
|
477
|
+
* `Boolean(properties.autoplay)`, raw and unresolved, on purpose: it is the
|
|
478
|
+
* SAME expression the `autoPlay` attribute below is written with, and the
|
|
479
|
+
* point is that the two can never disagree. The note further down records
|
|
480
|
+
* why that attribute still reads raw — the editor stores `{ desktop: false }`,
|
|
481
|
+
* an object is truthy, and reading it correctly would switch autoplay off on
|
|
482
|
+
* every composition already published. Until that is done under its own
|
|
483
|
+
* ticket, "would this have played on its own?" has exactly one answer, and
|
|
484
|
+
* asking it any other way here would arm the observer on clips the page does
|
|
485
|
+
* not currently start, or fail to arm it on the ones it does.
|
|
486
|
+
*/
|
|
487
|
+
const wantsAutoplay = Boolean(properties.autoplay);
|
|
488
|
+
|
|
489
|
+
const playWhenVisible =
|
|
490
|
+
getResponsiveValue(properties.playWhenVisible, currentBreakpoint, true) !==
|
|
491
|
+
false;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* "Ambient": a clip the page starts on the shopper's behalf, with no controls
|
|
495
|
+
* of its own — the campaign film behind a headline. Only those are driven by
|
|
496
|
+
* the viewport.
|
|
497
|
+
*
|
|
498
|
+
* `!nativeControls` is the load-bearing half of that sentence, and leaving it
|
|
499
|
+
* out was a real regression rather than a nicety. `wantsAutoplay` is truthy
|
|
500
|
+
* for EVERY block the editor has ever written (see above), so without it the
|
|
501
|
+
* observer armed on ordinary videos that show the browser's own control bar:
|
|
502
|
+
* a shopper who pressed pause at 0:30, scrolled the player off screen and
|
|
503
|
+
* scrolled back would find it playing again, because this observer
|
|
504
|
+
* deliberately does not remember a manual pause. A player with controls is a
|
|
505
|
+
* deliberate watch; it is left entirely alone.
|
|
506
|
+
*
|
|
507
|
+
* `nativeControls` IS read per breakpoint, so this gate is exact even while
|
|
508
|
+
* the autoplay read next to it is not.
|
|
509
|
+
*/
|
|
510
|
+
const ambientPlayback =
|
|
511
|
+
wantsAutoplay && !nativeControls && playWhenVisible && !isDesigner;
|
|
512
|
+
|
|
346
513
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
347
|
-
const [isPlaying, setIsPlaying] = useState(properties.autoplay === true);
|
|
348
514
|
|
|
349
|
-
|
|
515
|
+
/**
|
|
516
|
+
* What the GLYPH says, which is not the same as what the author asked for.
|
|
517
|
+
*
|
|
518
|
+
* An ambient clip starts at `false` because nothing has started it yet: it
|
|
519
|
+
* carries no autoplay attribute, and the observer only presses play once the
|
|
520
|
+
* clip is on screen — the `play` event flips this within a frame of that.
|
|
521
|
+
* Seeding it `true` there meant the control drew the pause bars and announced
|
|
522
|
+
* "Pause video" over a clip standing still, and stayed that way for good
|
|
523
|
+
* wherever playback never began at all: a reduced-motion shopper, an iPhone
|
|
524
|
+
* in Low Power Mode refusing even a muted start, a 404 on the file. None of
|
|
525
|
+
* those fire a `pause` event to correct it, because the element was already
|
|
526
|
+
* paused.
|
|
527
|
+
*
|
|
528
|
+
* Everything else keeps the attribute and really does start on its own, so
|
|
529
|
+
* `wantsAutoplay` is the honest first answer there.
|
|
530
|
+
*/
|
|
531
|
+
const [isPlaying, setIsPlaying] = useState(
|
|
532
|
+
ambientPlayback ? false : wantsAutoplay
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Whether the observer last saw this player on screen.
|
|
537
|
+
*
|
|
538
|
+
* A ref rather than state because the only reader is the src-swapping effect
|
|
539
|
+
* below, and re-rendering the player every time a clip scrolls past would be
|
|
540
|
+
* a lot of work to change nothing. See that effect for why it has to ask.
|
|
541
|
+
*/
|
|
542
|
+
const isInViewRef = useRef(false);
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Starts false on the server AND on the first client render, which is what
|
|
546
|
+
* keeps `preload` hydratable: both sides ship `none`, and only the observer
|
|
547
|
+
* moves it. See the preload note on the element itself.
|
|
548
|
+
*/
|
|
549
|
+
const [hasEnteredView, setHasEnteredView] = useState(false);
|
|
550
|
+
|
|
551
|
+
const mutedProperty = Boolean(properties.muted);
|
|
552
|
+
const [isMuted, setIsMuted] = useState(mutedProperty);
|
|
553
|
+
|
|
554
|
+
/*
|
|
555
|
+
* The author flipping Muted has to win over whatever the shopper's sound
|
|
556
|
+
* button last did. Worth knowing what this can and cannot reach: the stored
|
|
557
|
+
* value is per-breakpoint for anything the editor wrote, and `Boolean()` of
|
|
558
|
+
* an object is a constant, so for those blocks this never fires — the same
|
|
559
|
+
* raw read the autoplay note above explains, and it moves with that ticket.
|
|
560
|
+
* It does work for the flat booleans templates and HTML import write.
|
|
561
|
+
*/
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
setIsMuted(mutedProperty);
|
|
564
|
+
}, [mutedProperty]);
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* `play()` returns a promise that REJECTS when the browser declines — an
|
|
568
|
+
* unmuted clip with no user gesture, or one interrupted by a `pause()` a
|
|
569
|
+
* frame later. Every call site swallows it: a refused play is a poster
|
|
570
|
+
* sitting still, not an error worth an unhandled rejection in the console.
|
|
571
|
+
*/
|
|
572
|
+
const requestPlay = (el: HTMLVideoElement) => {
|
|
573
|
+
const started = el.play();
|
|
574
|
+
if (started && typeof started.catch === 'function') {
|
|
575
|
+
started.catch(() => undefined);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* THE CONTROL IS NOT PART OF THE BLOCK'S ACTION.
|
|
581
|
+
*
|
|
582
|
+
* `WithDesignerFeatures` puts the block's own click handler on the
|
|
583
|
+
* `[data-block-id]` wrapper whenever an Action is set, and `video` is not in
|
|
584
|
+
* `NATIVELY_INTERACTIVE_BLOCK_TYPES`, so it gets one. A campaign tile built
|
|
585
|
+
* as a Video block with Action = Link therefore navigated away the moment a
|
|
586
|
+
* shopper tapped pause: the video paused and the click carried on up. Note
|
|
587
|
+
* that `pointerEvents: 'auto'` on the buttons governs hit-testing only — it
|
|
588
|
+
* has never had anything to say about propagation.
|
|
589
|
+
*
|
|
590
|
+
* NOT in the designer, though, and that exception is the whole of why this
|
|
591
|
+
* is a function rather than one call. The canvas resolves which PART was
|
|
592
|
+
* clicked from a single bubble handler on that same wrapper
|
|
593
|
+
* (`use-designer-features`), so stopping the click unconditionally meant a
|
|
594
|
+
* click on the button selected NOTHING — not the Control Button part, not
|
|
595
|
+
* even the Video block. `hotspot-block.tsx` returns early on `isDesigner`
|
|
596
|
+
* for exactly this reason, and this follows it.
|
|
597
|
+
*/
|
|
598
|
+
const stopBubbling = (event: React.MouseEvent) => {
|
|
599
|
+
if (isDesigner) return;
|
|
600
|
+
event.stopPropagation();
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const togglePlayback = (event: React.MouseEvent) => {
|
|
604
|
+
stopBubbling(event);
|
|
350
605
|
const el = videoRef.current;
|
|
351
606
|
if (!el) return;
|
|
352
607
|
if (el.paused) {
|
|
353
|
-
el
|
|
608
|
+
requestPlay(el);
|
|
354
609
|
} else {
|
|
355
610
|
el.pause();
|
|
356
611
|
}
|
|
357
612
|
};
|
|
358
613
|
|
|
614
|
+
const toggleSound = (event: React.MouseEvent) => {
|
|
615
|
+
stopBubbling(event);
|
|
616
|
+
const el = videoRef.current;
|
|
617
|
+
const next = !isMuted;
|
|
618
|
+
setIsMuted(next);
|
|
619
|
+
// Written straight to the element as well: React sets `muted` as a DOM
|
|
620
|
+
// PROPERTY and never emits it as an attribute, so a re-render alone is not
|
|
621
|
+
// guaranteed to reach an element the browser is already playing.
|
|
622
|
+
if (el) el.muted = next;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* PLAY WHILE IT IS ON SCREEN; STOP WHEN IT LEAVES.
|
|
627
|
+
*
|
|
628
|
+
* An `autoPlay` attribute starts a clip the moment it is parsed, wherever it
|
|
629
|
+
* sits on the page — so a homepage with three of them decodes three films at
|
|
630
|
+
* once, two of which nobody has scrolled to. This binds playback to the
|
|
631
|
+
* viewport instead, which is both the behaviour the reference storefront has
|
|
632
|
+
* and the reason a long page stays cheap.
|
|
633
|
+
*
|
|
634
|
+
* Three things are deliberate:
|
|
635
|
+
*
|
|
636
|
+
* - `pause()` on the way out does NOT touch `currentTime`. Scrolling back
|
|
637
|
+
* resumes where the clip stopped, measured as the reference behaviour.
|
|
638
|
+
* - A manual pause is NOT remembered across a scroll-out. The observer is
|
|
639
|
+
* authoritative; the button is an override that lasts while the clip is on
|
|
640
|
+
* screen. Also measured — pausing by hand, scrolling away and back plays
|
|
641
|
+
* again there too.
|
|
642
|
+
* - It stands down in the designer (`isDesigner`, folded into
|
|
643
|
+
* `ambientPlayback`), the way every other observer- or timer-driven
|
|
644
|
+
* affordance in this package does. The canvas keeps whatever the `autoPlay`
|
|
645
|
+
* attribute already gave it, so authoring is unchanged.
|
|
646
|
+
*
|
|
647
|
+
* Both escape hatches below flip `hasEnteredView` before returning. The
|
|
648
|
+
* element is holding `preload="none"` waiting for this effect, so bailing out
|
|
649
|
+
* without doing so would leave a clip that never loads at all.
|
|
650
|
+
*/
|
|
651
|
+
useEffect(() => {
|
|
652
|
+
if (!ambientPlayback) return undefined;
|
|
653
|
+
|
|
654
|
+
const el = videoRef.current;
|
|
655
|
+
if (!el) return undefined;
|
|
656
|
+
|
|
657
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
658
|
+
// Nothing else is going to start this clip — an ambient one ships no
|
|
659
|
+
// `autoplay` attribute precisely so that this effect owns the decision —
|
|
660
|
+
// so the fallback has to both release the file and press play itself.
|
|
661
|
+
setHasEnteredView(true);
|
|
662
|
+
requestPlay(el);
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (prefersReducedMotion()) {
|
|
667
|
+
// Release the file so the shopper can still press play; just never start
|
|
668
|
+
// it for them. `pause()` covers the case where something else already
|
|
669
|
+
// had it running before this effect re-ran.
|
|
670
|
+
setHasEnteredView(true);
|
|
671
|
+
el.pause();
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const observer = new IntersectionObserver(
|
|
676
|
+
(entries) => {
|
|
677
|
+
// The LAST entry, not the first: a burst delivered after a fast scroll
|
|
678
|
+
// describes where the element ended up, and acting on the head of it
|
|
679
|
+
// would play a clip that has already gone.
|
|
680
|
+
const entry = entries[entries.length - 1];
|
|
681
|
+
if (!entry) return;
|
|
682
|
+
|
|
683
|
+
isInViewRef.current = entry.isIntersecting;
|
|
684
|
+
|
|
685
|
+
if (entry.isIntersecting) {
|
|
686
|
+
setHasEnteredView(true);
|
|
687
|
+
requestPlay(el);
|
|
688
|
+
} else {
|
|
689
|
+
el.pause();
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
{ threshold: VISIBILITY_PLAY_THRESHOLD }
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
observer.observe(el);
|
|
696
|
+
return () => {
|
|
697
|
+
observer.disconnect();
|
|
698
|
+
isInViewRef.current = false;
|
|
699
|
+
};
|
|
700
|
+
/*
|
|
701
|
+
* `parsed?.kind` is in the list because this effect reads `videoRef.current`
|
|
702
|
+
* and gives up silently when it is null — and which ELEMENT gets rendered
|
|
703
|
+
* is breakpoint-dependent. An author who pastes a YouTube link and uploads
|
|
704
|
+
* an mp4 for phones gets the `<iframe>` branch on the first client render
|
|
705
|
+
* (currentBreakpoint is the literal 'desktop' until a post-mount effect
|
|
706
|
+
* says otherwise); the `<video>` only appears on the render after that.
|
|
707
|
+
* Without this the effect never runs again, and that clip spends the rest
|
|
708
|
+
* of the page's life at `preload="none"` with nothing watching the
|
|
709
|
+
* viewport — the feature simply absent, with nothing to show for it.
|
|
710
|
+
*/
|
|
711
|
+
}, [ambientPlayback, parsed?.kind]);
|
|
712
|
+
|
|
359
713
|
/**
|
|
360
714
|
* THE FILE, NAMED BY THE SIDE THAT KNOWS THE VIEWPORT.
|
|
361
715
|
*
|
|
@@ -420,11 +774,30 @@ const VideoBlock = ({
|
|
|
420
774
|
|
|
421
775
|
el.setAttribute('src', next);
|
|
422
776
|
el.load();
|
|
777
|
+
|
|
778
|
+
/*
|
|
779
|
+
* `load()` RESETS THE PLAYER, SO WHOEVER WAS PLAYING HAS TO BE RESTARTED.
|
|
780
|
+
*
|
|
781
|
+
* The observer above only speaks when the clip CROSSES its threshold. It
|
|
782
|
+
* has nothing to say about a clip that was already on screen and stayed
|
|
783
|
+
* there — which is exactly the case here: this effect runs on mount before
|
|
784
|
+
* the first intersection is delivered, and again whenever a resize or a
|
|
785
|
+
* rotation crosses 1024px. Both leave a freshly reset, paused element
|
|
786
|
+
* sitting in full view with no further intersection coming to start it.
|
|
787
|
+
*
|
|
788
|
+
* The `autoPlay` attribute is not a substitute. Its "can autoplay" flag is
|
|
789
|
+
* cleared the first time the element is paused or played, so a clip that
|
|
790
|
+
* has already been on screen once does not get a second free start from it.
|
|
791
|
+
*/
|
|
792
|
+
if (ambientPlayback && isInViewRef.current) {
|
|
793
|
+
requestPlay(el);
|
|
794
|
+
}
|
|
423
795
|
}, [
|
|
424
796
|
hasDistinctMobileVideo,
|
|
425
797
|
currentBreakpoint,
|
|
426
798
|
mobileUrlAnywhere,
|
|
427
|
-
desktopUrl
|
|
799
|
+
desktopUrl,
|
|
800
|
+
ambientPlayback
|
|
428
801
|
]);
|
|
429
802
|
|
|
430
803
|
/**
|
|
@@ -458,6 +831,28 @@ const VideoBlock = ({
|
|
|
458
831
|
'cover'
|
|
459
832
|
) as React.CSSProperties['objectFit'];
|
|
460
833
|
|
|
834
|
+
/**
|
|
835
|
+
* HOW MUCH OF THE FILE TO FETCH BEFORE ANYONE ASKS FOR IT.
|
|
836
|
+
*
|
|
837
|
+
* The element carried no `preload` at all until now, so every browser fell
|
|
838
|
+
* back to its own default — in practice `metadata`, and on some builds
|
|
839
|
+
* `auto` — and a clip five screens down the homepage started downloading
|
|
840
|
+
* with the page. `metadata` is the new floor: enough for the box and the
|
|
841
|
+
* first frame, a few kilobytes rather than the film.
|
|
842
|
+
*
|
|
843
|
+
* On top of that, an ambient clip ships `none` until the observer above says
|
|
844
|
+
* it has reached the viewport. That is the whole of the lazy story, and it
|
|
845
|
+
* costs the poster nothing: `poster` is an image the element paints whatever
|
|
846
|
+
* `preload` says, so the box is never empty while the file is being skipped.
|
|
847
|
+
*/
|
|
848
|
+
const authoredPreload = getResponsiveValue(
|
|
849
|
+
properties.preload,
|
|
850
|
+
currentBreakpoint,
|
|
851
|
+
'metadata'
|
|
852
|
+
) as string;
|
|
853
|
+
const preload =
|
|
854
|
+
ambientPlayback && !hasEnteredView ? 'none' : authoredPreload;
|
|
855
|
+
|
|
461
856
|
if (!videoUrl || !parsed) {
|
|
462
857
|
return (
|
|
463
858
|
<div
|
|
@@ -522,9 +917,28 @@ const VideoBlock = ({
|
|
|
522
917
|
ref={videoRef}
|
|
523
918
|
src={hasDistinctMobileVideo ? undefined : parsed.src}
|
|
524
919
|
controls={nativeControls}
|
|
525
|
-
|
|
920
|
+
/*
|
|
921
|
+
* ONE STARTER, NOT TWO.
|
|
922
|
+
*
|
|
923
|
+
* An ambient clip ships NO autoplay attribute: the observer above starts
|
|
924
|
+
* it, and this is what makes `preload="none"` beside it mean anything.
|
|
925
|
+
* The HTML spec lets the autoplay attribute override preload, and both
|
|
926
|
+
* Blink and Gecko take it — an element carrying both downloaded the whole
|
|
927
|
+
* film at parse time and deferred only the playing of it, which is the
|
|
928
|
+
* opposite of the saving this is for. It also ended the fight between two
|
|
929
|
+
* drivers: `load()` in the src-swapping effect below re-arms the
|
|
930
|
+
* attribute's can-autoplay flag, which used to restart a clip the
|
|
931
|
+
* reduced-motion branch had just stood down.
|
|
932
|
+
*
|
|
933
|
+
* The cost is that an ambient clip does not start with JavaScript off. It
|
|
934
|
+
* could never have stopped without it either, and the poster still paints.
|
|
935
|
+
*/
|
|
936
|
+
autoPlay={ambientPlayback ? undefined : properties.autoplay}
|
|
526
937
|
loop={properties.loop}
|
|
527
|
-
|
|
938
|
+
// Seeded from `Boolean(properties.muted)` — the same truthiness the raw
|
|
939
|
+
// read had — and thereafter owned by the sound button, when there is one.
|
|
940
|
+
muted={isMuted}
|
|
941
|
+
preload={preload}
|
|
528
942
|
poster={posterUrl || undefined}
|
|
529
943
|
// Without it iOS opens the video full screen, which is never what an
|
|
530
944
|
// inline background clip is for. Left reading `properties.playsInline`
|
|
@@ -573,13 +987,34 @@ const VideoBlock = ({
|
|
|
573
987
|
}
|
|
574
988
|
|
|
575
989
|
/**
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
990
|
+
* THE CONTROL LAYER — BOTTOM RIGHT, AND STILL THERE ON A TALL CLIP.
|
|
991
|
+
*
|
|
992
|
+
* Still no wrapper around the VIDEO: `[data-block-id]` (rendered by
|
|
993
|
+
* `WithDesignerFeatures`) is already `position: relative` and already carries
|
|
994
|
+
* whatever height/position the author's block styles give it — a full-bleed
|
|
995
|
+
* hero is sized THERE, not by the `<video>` tag's own intrinsic box, and
|
|
996
|
+
* wrapping the player would hand the controls the player's natural (usually
|
|
997
|
+
* much shorter) height instead. What is added here is a SIBLING overlay,
|
|
998
|
+
* `inset: 0` against that same wrapper, so it inherits the authored box
|
|
999
|
+
* exactly the way the single absolute button used to.
|
|
1000
|
+
*
|
|
1001
|
+
* Three nested boxes, because one absolutely positioned button cannot do it:
|
|
1002
|
+
*
|
|
1003
|
+
* overlay inset 0, flex, justify-content flex-end -> the right edge
|
|
1004
|
+
* column flex column, justify-content flex-end -> the bottom edge
|
|
1005
|
+
* rail position: sticky, bottom: <inset> -> stays reachable
|
|
1006
|
+
*
|
|
1007
|
+
* The sticky rail is what a 2:3 portrait clip needs. At 749px wide that box
|
|
1008
|
+
* is 1123px tall — taller than most laptop viewports — so a control pinned
|
|
1009
|
+
* to the bottom of the block is off screen for the whole time the clip is
|
|
1010
|
+
* being watched. Sticky keeps it the same inset above the viewport floor
|
|
1011
|
+
* until the real bottom comes into range, then parks it there. Where an
|
|
1012
|
+
* ancestor's `overflow: hidden` defeats sticky, it degrades to its static
|
|
1013
|
+
* position, which is the bottom-right corner it was going to sit in anyway.
|
|
1014
|
+
*
|
|
1015
|
+
* Both outer boxes are `pointer-events: none` — the overlay covers the whole
|
|
1016
|
+
* player, and the shopper must still be able to click a link sitting under
|
|
1017
|
+
* it. Only the buttons take pointer events back.
|
|
583
1018
|
*
|
|
584
1019
|
* `zIndex: 2` because a sibling content-overlay block (title/CTA on top of
|
|
585
1020
|
* the video) is a full-size `position: absolute; z-index: 1` box — its
|
|
@@ -587,39 +1022,88 @@ const VideoBlock = ({
|
|
|
587
1022
|
* higher z-index here the overlay wins hit-testing and swallows the click
|
|
588
1023
|
* even where the button is visually on top of it.
|
|
589
1024
|
*/
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
:
|
|
1025
|
+
const controlInset = isMobile ? CONTROL_INSET_NARROW : CONTROL_INSET_DESKTOP;
|
|
1026
|
+
|
|
1027
|
+
const controlButtonStyle: React.CSSProperties = {
|
|
1028
|
+
width: '20px',
|
|
1029
|
+
height: '20px',
|
|
1030
|
+
display: 'flex',
|
|
1031
|
+
alignItems: 'center',
|
|
1032
|
+
justifyContent: 'center',
|
|
1033
|
+
background: '#fff',
|
|
1034
|
+
border: 'none',
|
|
1035
|
+
borderRadius: 0,
|
|
1036
|
+
padding: 0,
|
|
1037
|
+
margin: 0,
|
|
1038
|
+
cursor: 'pointer',
|
|
1039
|
+
color: '#000',
|
|
1040
|
+
pointerEvents: 'auto'
|
|
1041
|
+
};
|
|
596
1042
|
|
|
597
1043
|
return (
|
|
598
1044
|
<>
|
|
599
1045
|
{videoElement}
|
|
600
|
-
<
|
|
601
|
-
|
|
602
|
-
onClick={togglePlayback}
|
|
603
|
-
aria-label={isPlaying ? 'Pause video' : 'Play video'}
|
|
604
|
-
aria-pressed={isPlaying}
|
|
1046
|
+
<div
|
|
1047
|
+
{...partAttrs('controls')}
|
|
605
1048
|
style={{
|
|
606
1049
|
position: 'absolute',
|
|
607
|
-
|
|
1050
|
+
inset: 0,
|
|
608
1051
|
zIndex: 2,
|
|
609
|
-
width: '20px',
|
|
610
|
-
height: '20px',
|
|
611
1052
|
display: 'flex',
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
padding: 0,
|
|
617
|
-
cursor: 'pointer',
|
|
618
|
-
color: '#111'
|
|
1053
|
+
justifyContent: 'flex-end',
|
|
1054
|
+
padding: controlInset,
|
|
1055
|
+
pointerEvents: 'none',
|
|
1056
|
+
borderRadius: 'inherit'
|
|
619
1057
|
}}
|
|
620
1058
|
>
|
|
621
|
-
|
|
622
|
-
|
|
1059
|
+
<div
|
|
1060
|
+
style={{
|
|
1061
|
+
display: 'flex',
|
|
1062
|
+
flexDirection: 'column',
|
|
1063
|
+
justifyContent: 'flex-end',
|
|
1064
|
+
pointerEvents: 'none'
|
|
1065
|
+
}}
|
|
1066
|
+
>
|
|
1067
|
+
<div
|
|
1068
|
+
{...partAttrs('control-rail')}
|
|
1069
|
+
style={{
|
|
1070
|
+
position: 'sticky',
|
|
1071
|
+
bottom: controlInset,
|
|
1072
|
+
display: 'flex',
|
|
1073
|
+
alignItems: 'center',
|
|
1074
|
+
gap: '8px',
|
|
1075
|
+
pointerEvents: 'none'
|
|
1076
|
+
}}
|
|
1077
|
+
>
|
|
1078
|
+
{showSoundToggle && (
|
|
1079
|
+
<button
|
|
1080
|
+
type="button"
|
|
1081
|
+
{...partAttrs('control-button')}
|
|
1082
|
+
onClick={toggleSound}
|
|
1083
|
+
aria-label={isMuted ? 'Unmute video' : 'Mute video'}
|
|
1084
|
+
aria-pressed={!isMuted}
|
|
1085
|
+
style={controlButtonStyle}
|
|
1086
|
+
>
|
|
1087
|
+
{isMuted
|
|
1088
|
+
? glyphFor('soundOffIcon', <SoundOffIcon />)
|
|
1089
|
+
: glyphFor('soundOnIcon', <SoundOnIcon />)}
|
|
1090
|
+
</button>
|
|
1091
|
+
)}
|
|
1092
|
+
<button
|
|
1093
|
+
type="button"
|
|
1094
|
+
{...partAttrs('control-button')}
|
|
1095
|
+
onClick={togglePlayback}
|
|
1096
|
+
aria-label={isPlaying ? 'Pause video' : 'Play video'}
|
|
1097
|
+
aria-pressed={isPlaying}
|
|
1098
|
+
style={controlButtonStyle}
|
|
1099
|
+
>
|
|
1100
|
+
{isPlaying
|
|
1101
|
+
? glyphFor('pauseIcon', <PauseIcon />)
|
|
1102
|
+
: glyphFor('playIcon', <PlayIcon />)}
|
|
1103
|
+
</button>
|
|
1104
|
+
</div>
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
623
1107
|
</>
|
|
624
1108
|
);
|
|
625
1109
|
};
|