@akinon/pz-theme 2.0.118-beta.0 → 2.0.118
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 +4 -4
- package/jest/setup.ts +11 -0
- package/package.json +4 -4
- package/src/blocks/__tests__/video-block.test.tsx +185 -26
- package/src/blocks/video-block.tsx +153 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.118
|
|
3
|
+
## 2.0.118
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.118
|
|
9
|
-
- @akinon/theme-contract@2.0.118
|
|
7
|
+
- a58283b: Give a phone the mobile video at the first paint: let the player fill the box the stylesheet already shaped instead of re-declaring it from a breakpoint the server cannot know, and name the single file the viewport needs on the client, so a block with a separate mobile clip no longer ships the desktop one to every phone
|
|
8
|
+
- @akinon/next@2.0.118
|
|
9
|
+
- @akinon/theme-contract@2.0.118
|
|
10
10
|
|
|
11
11
|
## 2.0.117
|
|
12
12
|
|
package/jest/setup.ts
CHANGED
|
@@ -25,6 +25,17 @@ if (hasWindow) {
|
|
|
25
25
|
window.scrollTo = (() => {}) as typeof window.scrollTo;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// jsdom declares the media methods but implements none of them: calling one
|
|
29
|
+
// prints a "Not implemented" error through the virtual console, which turns any
|
|
30
|
+
// component that drives a player on mount into noise in an otherwise passing
|
|
31
|
+
// run. No test asserts on playback itself — only on which file the element was
|
|
32
|
+
// pointed at — so no-ops are the honest shim.
|
|
33
|
+
if (hasWindow && typeof HTMLMediaElement !== 'undefined') {
|
|
34
|
+
HTMLMediaElement.prototype.load = () => {};
|
|
35
|
+
HTMLMediaElement.prototype.play = () => Promise.resolve();
|
|
36
|
+
HTMLMediaElement.prototype.pause = () => {};
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
// `react-dom/server` builds a MessageChannel at module scope, and jsdom has
|
|
29
40
|
// none — importing it in a jsdom suite throws before a single test runs. Node's
|
|
30
41
|
// own MessageChannel is not the answer: its ports keep the event loop alive, so
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.118
|
|
4
|
+
"version": "2.0.118",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.118
|
|
11
|
+
"@akinon/next": "2.0.118",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.118
|
|
16
|
+
"@akinon/theme-contract": "2.0.118",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.118
|
|
24
|
+
"@akinon/next": "2.0.118",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
4
|
import VideoBlock from '../video-block';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Stand the test in a viewport band.
|
|
8
|
+
*
|
|
9
|
+
* Outside the editor the VIEWPORT decides which file plays, not the breakpoint
|
|
10
|
+
* prop — the prop is still `'desktop'` when a phone paints for the first time,
|
|
11
|
+
* which is exactly what used to put the landscape clip in every phone's HTML.
|
|
12
|
+
* jsdom's `matchMedia` shim always answers `false`, so a storefront test has to
|
|
13
|
+
* say which band it is standing in.
|
|
14
|
+
*/
|
|
15
|
+
const withViewport = (narrow: boolean, run: () => void) => {
|
|
16
|
+
const original = window.matchMedia;
|
|
17
|
+
window.matchMedia = ((query: string) => ({
|
|
18
|
+
matches: narrow && query.includes('max-width'),
|
|
19
|
+
media: query,
|
|
20
|
+
onchange: null,
|
|
21
|
+
addListener: () => {},
|
|
22
|
+
removeListener: () => {},
|
|
23
|
+
addEventListener: () => {},
|
|
24
|
+
removeEventListener: () => {},
|
|
25
|
+
dispatchEvent: () => false
|
|
26
|
+
})) as unknown as typeof window.matchMedia;
|
|
27
|
+
try {
|
|
28
|
+
run();
|
|
29
|
+
} finally {
|
|
30
|
+
window.matchMedia = original;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
const renderBlock = (value: any, properties?: any, styles?: any) =>
|
|
6
35
|
render(
|
|
7
36
|
<VideoBlock
|
|
@@ -440,14 +469,22 @@ describe('VideoBlock', () => {
|
|
|
440
469
|
expect(iframe.style.height).toBe('auto');
|
|
441
470
|
});
|
|
442
471
|
|
|
443
|
-
it('
|
|
472
|
+
it('leaves a direct file to the box the stylesheet already shaped', () => {
|
|
473
|
+
// The opposite of the iframe above, and deliberately so. A file player
|
|
474
|
+
// sits inside `[data-block-id]`, and `generateThemeCSS` has already put
|
|
475
|
+
// the author's ratio THERE, per breakpoint, as a real media query — right
|
|
476
|
+
// at the first paint with no JavaScript. Re-resolving it here at
|
|
477
|
+
// `currentBreakpoint` (the literal 'desktop' on the server and through a
|
|
478
|
+
// phone's first render) and inlining it is what beat that media query and
|
|
479
|
+
// left a 9/16 box 693px tall holding a 192px player over 500px of white.
|
|
444
480
|
const { container } = renderBlock(
|
|
445
481
|
'https://cdn.example.com/clip.mp4',
|
|
446
482
|
undefined,
|
|
447
483
|
{ 'aspect-ratio': { desktop: '9/16' } }
|
|
448
484
|
);
|
|
449
485
|
const video = container.querySelector('video') as HTMLVideoElement;
|
|
450
|
-
expect(video.style.
|
|
486
|
+
expect(video.style.getPropertyValue('aspect-ratio')).toBe('');
|
|
487
|
+
expect(video.style.height).toBe('100%');
|
|
451
488
|
expect(video.style.objectFit).toBe('cover');
|
|
452
489
|
});
|
|
453
490
|
});
|
|
@@ -468,6 +505,18 @@ describe('VideoBlock', () => {
|
|
|
468
505
|
expect(video.style.height).toBe('100%');
|
|
469
506
|
});
|
|
470
507
|
|
|
508
|
+
it('fills its box unconditionally, without asking the breakpoint', () => {
|
|
509
|
+
// Measured in WebKit, Gecko and Blink: where nothing sizes the wrapper a
|
|
510
|
+
// percentage height resolves to `auto`, so an unsized block behaves
|
|
511
|
+
// exactly as it always did. Asking `currentBreakpoint` whether the box
|
|
512
|
+
// was sized would reintroduce the defect, because the answer differs
|
|
513
|
+
// between the server and the phone's first paint.
|
|
514
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
515
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
516
|
+
expect(video.style.height).toBe('100%');
|
|
517
|
+
expect(video.style.getPropertyValue('aspect-ratio')).toBe('');
|
|
518
|
+
});
|
|
519
|
+
|
|
471
520
|
it('defaults Object Fit to cover even without an authored aspect ratio', () => {
|
|
472
521
|
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
473
522
|
const video = container.querySelector('video') as HTMLVideoElement;
|
|
@@ -519,36 +568,146 @@ describe('VideoBlock', () => {
|
|
|
519
568
|
).toBe('https://cdn.example.com/desktop.mp4');
|
|
520
569
|
});
|
|
521
570
|
|
|
522
|
-
it('plays the mobile file on
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
(
|
|
533
|
-
|
|
571
|
+
it('plays the mobile file on a phone when useDifferentMobileVideo is on', () => {
|
|
572
|
+
withViewport(true, () => {
|
|
573
|
+
const { container } = renderAt(
|
|
574
|
+
'https://cdn.example.com/desktop.mp4',
|
|
575
|
+
{
|
|
576
|
+
useDifferentMobileVideo: true,
|
|
577
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
578
|
+
},
|
|
579
|
+
'mobile'
|
|
580
|
+
);
|
|
581
|
+
expect(
|
|
582
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
583
|
+
).toBe('https://cdn.example.com/mobile.mp4');
|
|
584
|
+
});
|
|
534
585
|
});
|
|
535
586
|
|
|
536
587
|
it('plays the mobile file on a tablet too, the way the CSS already does', () => {
|
|
537
588
|
// The mobile media query is max-width: 1023px, so at 800px every style
|
|
538
|
-
// around this player is already the mobile one.
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
589
|
+
// around this player is already the mobile one. The condition the source
|
|
590
|
+
// is chosen with is that same band, so a tablet cannot drift off it.
|
|
591
|
+
withViewport(true, () => {
|
|
592
|
+
const { container } = renderAt(
|
|
593
|
+
'https://cdn.example.com/desktop.mp4',
|
|
594
|
+
{
|
|
595
|
+
useDifferentMobileVideo: true,
|
|
596
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
597
|
+
},
|
|
598
|
+
'tablet'
|
|
599
|
+
);
|
|
600
|
+
expect((container.querySelector('video') as HTMLVideoElement).src).toBe(
|
|
601
|
+
'https://cdn.example.com/mobile.mp4'
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('keeps the desktop file on a desktop viewport', () => {
|
|
607
|
+
withViewport(false, () => {
|
|
608
|
+
const { container } = renderAt(
|
|
609
|
+
'https://cdn.example.com/desktop.mp4',
|
|
610
|
+
{
|
|
611
|
+
useDifferentMobileVideo: true,
|
|
612
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
613
|
+
},
|
|
614
|
+
'desktop'
|
|
615
|
+
);
|
|
616
|
+
expect(
|
|
617
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
618
|
+
).toBe('https://cdn.example.com/desktop.mp4');
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('ships no source at all from the server when the files differ', () => {
|
|
623
|
+
// THE DEFECT THIS EXISTS TO PIN. The server has no viewport, and the page
|
|
624
|
+
// is statically generated, so it cannot get one from the request either.
|
|
625
|
+
// Naming a file in the markup therefore names the WRONG one for every
|
|
626
|
+
// phone: ~16MB fetched and thrown away on this brand's homepage, plus a
|
|
627
|
+
// visible swap. So when the two addresses genuinely differ, the server
|
|
628
|
+
// ships the player empty and the client names the single file it needs.
|
|
629
|
+
const html = renderToStaticMarkup(
|
|
630
|
+
<VideoBlock
|
|
631
|
+
block={
|
|
632
|
+
{
|
|
633
|
+
value: 'https://cdn.example.com/desktop.mp4',
|
|
634
|
+
properties: {
|
|
635
|
+
useDifferentMobileVideo: true,
|
|
636
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
637
|
+
}
|
|
638
|
+
} as any
|
|
639
|
+
}
|
|
640
|
+
placeholderId="p1"
|
|
641
|
+
sectionId="s1"
|
|
642
|
+
isDesigner={false}
|
|
643
|
+
/>
|
|
548
644
|
);
|
|
549
|
-
expect(
|
|
550
|
-
|
|
645
|
+
expect(html).toContain('<video');
|
|
646
|
+
expect(html).not.toContain('desktop.mp4');
|
|
647
|
+
expect(html).not.toContain('mobile.mp4');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('still ships the source from the server for a single-file block', () => {
|
|
651
|
+
// The gate has to leave every composition that never authored a mobile
|
|
652
|
+
// override byte-identical — including its parse-time head start.
|
|
653
|
+
const html = renderToStaticMarkup(
|
|
654
|
+
<VideoBlock
|
|
655
|
+
block={{ value: 'https://cdn.example.com/only.mp4' } as any}
|
|
656
|
+
placeholderId="p1"
|
|
657
|
+
sectionId="s1"
|
|
658
|
+
isDesigner={false}
|
|
659
|
+
/>
|
|
551
660
|
);
|
|
661
|
+
expect(html).toContain('https://cdn.example.com/only.mp4');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('reads the override however the editor happened to store it', () => {
|
|
665
|
+
// The live composition that reported this bug stores BOTH keys under
|
|
666
|
+
// `desktop` only — `{"useDifferentMobileVideo":{"desktop":true}}` and
|
|
667
|
+
// `{"mobileUrl":{"desktop":"..."}}`. A gate resolved at the breakpoint
|
|
668
|
+
// would answer differently on the server and on the phone and the player
|
|
669
|
+
// would not hydrate, so the gate ignores the breakpoint entirely.
|
|
670
|
+
withViewport(true, () => {
|
|
671
|
+
const { container } = renderAt(
|
|
672
|
+
'https://cdn.example.com/desktop.mp4',
|
|
673
|
+
{
|
|
674
|
+
useDifferentMobileVideo: { desktop: true },
|
|
675
|
+
mobileUrl: { desktop: 'https://cdn.example.com/mobile.mp4' }
|
|
676
|
+
},
|
|
677
|
+
'mobile'
|
|
678
|
+
);
|
|
679
|
+
expect(
|
|
680
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
681
|
+
).toBe('https://cdn.example.com/mobile.mp4');
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('follows the editor device instead of the viewport inside the canvas', () => {
|
|
686
|
+
// The canvas draws a phone preview inside a desktop-wide iframe, so the
|
|
687
|
+
// viewport is the wrong thing to ask there. It must also keep working in
|
|
688
|
+
// BROWSE MODE, where the `isDesigner` a block receives is already false
|
|
689
|
+
// while the editor is still attached — hence the window flag rather than
|
|
690
|
+
// the prop.
|
|
691
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
692
|
+
true;
|
|
693
|
+
try {
|
|
694
|
+
withViewport(false, () => {
|
|
695
|
+
const { container } = renderAt(
|
|
696
|
+
'https://cdn.example.com/desktop.mp4',
|
|
697
|
+
{
|
|
698
|
+
useDifferentMobileVideo: true,
|
|
699
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
700
|
+
},
|
|
701
|
+
'mobile'
|
|
702
|
+
);
|
|
703
|
+
expect(
|
|
704
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
705
|
+
).toBe('https://cdn.example.com/mobile.mp4');
|
|
706
|
+
});
|
|
707
|
+
} finally {
|
|
708
|
+
delete (window as { __themeEditorDesigner?: boolean })
|
|
709
|
+
.__themeEditorDesigner;
|
|
710
|
+
}
|
|
552
711
|
});
|
|
553
712
|
|
|
554
713
|
it('takes the mobile poster on a tablet for the same reason', () => {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useRef, useState } from 'react';
|
|
4
|
-
import {
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
BREAKPOINTS,
|
|
6
|
+
PHONE_AND_TABLET_MEDIA_CONDITION,
|
|
7
|
+
getResponsiveValue,
|
|
8
|
+
isNarrowBreakpoint
|
|
9
|
+
} from '../utils';
|
|
5
10
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
6
11
|
|
|
7
12
|
/**
|
|
@@ -181,6 +186,36 @@ export const parseVideoSource = (
|
|
|
181
186
|
return null;
|
|
182
187
|
};
|
|
183
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Is this per-breakpoint value filled in at ANY breakpoint?
|
|
191
|
+
*
|
|
192
|
+
* Deliberately breakpoint-INDEPENDENT, and that is the whole point of it.
|
|
193
|
+
* `currentBreakpoint` is the literal `'desktop'` on the server and stays
|
|
194
|
+
* `'desktop'` through a phone's FIRST client render, so a gate that read it
|
|
195
|
+
* would answer one thing in the server HTML and another a paint later — the
|
|
196
|
+
* markup would not hydrate, and the element would be rebuilt rather than
|
|
197
|
+
* adopted. This question has one answer at every width.
|
|
198
|
+
*/
|
|
199
|
+
const setAtAnyBreakpoint = (value: unknown): boolean => {
|
|
200
|
+
if (value === null || value === undefined || value === '') return false;
|
|
201
|
+
if (typeof value !== 'object') return Boolean(value);
|
|
202
|
+
if (Array.isArray(value)) return false;
|
|
203
|
+
const perBreakpoint = value as Record<string, unknown>;
|
|
204
|
+
return BREAKPOINTS.some((breakpoint) => Boolean(perBreakpoint[breakpoint]));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The first address a per-breakpoint value carries, asked the same way. */
|
|
208
|
+
const firstUrlAtAnyBreakpoint = (value: unknown): string => {
|
|
209
|
+
if (typeof value === 'string') return value;
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
|
|
211
|
+
const perBreakpoint = value as Record<string, unknown>;
|
|
212
|
+
for (const breakpoint of BREAKPOINTS) {
|
|
213
|
+
const candidate = perBreakpoint[breakpoint];
|
|
214
|
+
if (typeof candidate === 'string' && candidate !== '') return candidate;
|
|
215
|
+
}
|
|
216
|
+
return '';
|
|
217
|
+
};
|
|
218
|
+
|
|
184
219
|
/**
|
|
185
220
|
* A video: a YouTube or Vimeo embed, or a file the browser plays itself.
|
|
186
221
|
*
|
|
@@ -248,6 +283,21 @@ const VideoBlock = ({
|
|
|
248
283
|
|
|
249
284
|
const parsed = parseVideoSource(videoUrl, { ...properties, sourceType });
|
|
250
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Does this block really ship two DIFFERENT files?
|
|
288
|
+
*
|
|
289
|
+
* Asked without the breakpoint, for the reason `setAtAnyBreakpoint` records:
|
|
290
|
+
* the answer has to be identical on the server and in the phone's first
|
|
291
|
+
* render or the player would not hydrate. When it is yes, the server ships
|
|
292
|
+
* the element with no source at all and the effect below names the one file
|
|
293
|
+
* this viewport needs — see it for why the server cannot decide this.
|
|
294
|
+
*/
|
|
295
|
+
const mobileUrlAnywhere = firstUrlAtAnyBreakpoint(properties.mobileUrl);
|
|
296
|
+
const hasDistinctMobileVideo =
|
|
297
|
+
setAtAnyBreakpoint(properties.useDifferentMobileVideo) &&
|
|
298
|
+
!!mobileUrlAnywhere &&
|
|
299
|
+
mobileUrlAnywhere !== desktopUrl;
|
|
300
|
+
|
|
251
301
|
/**
|
|
252
302
|
* Read through the breakpoint, the way `aspect-ratio` already is.
|
|
253
303
|
*
|
|
@@ -306,6 +356,77 @@ const VideoBlock = ({
|
|
|
306
356
|
}
|
|
307
357
|
};
|
|
308
358
|
|
|
359
|
+
/**
|
|
360
|
+
* THE FILE, NAMED BY THE SIDE THAT KNOWS THE VIEWPORT.
|
|
361
|
+
*
|
|
362
|
+
* `videoUrl` above is chosen from `currentBreakpoint`, and on the storefront
|
|
363
|
+
* that value is a lie for the first paint: `theme-placeholder-wrapper.tsx`
|
|
364
|
+
* seeds it with the literal `'desktop'` and corrects it only in a post-mount
|
|
365
|
+
* effect. So the HTML every phone received named the DESKTOP clip; the phone
|
|
366
|
+
* began downloading the landscape film — 15.7MB of it on this brand's
|
|
367
|
+
* homepage — and threw it away a paint later when the portrait one the
|
|
368
|
+
* merchant uploaded replaced it.
|
|
369
|
+
*
|
|
370
|
+
* The server cannot answer this by looking. It has no viewport, and the page
|
|
371
|
+
* this runs on is statically generated with `revalidate`, so reading the
|
|
372
|
+
* request headers would not vary the cache — it would only take the route out
|
|
373
|
+
* of static generation and still answer "not mobile" for everyone.
|
|
374
|
+
*
|
|
375
|
+
* `<source media>` would let the browser choose during parsing, earlier than
|
|
376
|
+
* this. Measured on WebKit, Gecko and Blink it works and the skipped
|
|
377
|
+
* candidate is never requested — but a shopper's browser version is not
|
|
378
|
+
* something this package can audit, and where the attribute is ignored the
|
|
379
|
+
* cost lands as a whole wasted film on the phone, which is the connection
|
|
380
|
+
* that can least afford it. So the choice is made here instead: one code
|
|
381
|
+
* path, one download, on every browser.
|
|
382
|
+
*/
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!hasDistinctMobileVideo) return;
|
|
385
|
+
|
|
386
|
+
const el = videoRef.current;
|
|
387
|
+
if (!el) return;
|
|
388
|
+
|
|
389
|
+
/*
|
|
390
|
+
* Inside the canvas the breakpoint is FORCED by the editor rather than read
|
|
391
|
+
* off the viewport, and that has to keep working in browse mode — where the
|
|
392
|
+
* `isDesigner` a block receives is already false while the editor is still
|
|
393
|
+
* attached. `__themeEditorDesigner` is the flag that answers "is the editor
|
|
394
|
+
* attached" and survives that toggle; see browse-mode.ts.
|
|
395
|
+
*/
|
|
396
|
+
const editorAttached =
|
|
397
|
+
typeof window !== 'undefined' &&
|
|
398
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
|
|
399
|
+
true;
|
|
400
|
+
|
|
401
|
+
/*
|
|
402
|
+
* The breakpoint prop is the fallback, not the answer: it is still
|
|
403
|
+
* `'desktop'` when this effect first runs on a phone (a child's effect
|
|
404
|
+
* commits before the placeholder's own breakpoint effect above it), which
|
|
405
|
+
* is the whole defect. It is only reached if a browser somehow has no
|
|
406
|
+
* `matchMedia` — better a stale first guess that a later render corrects
|
|
407
|
+
* than a TypeError that takes the player out entirely.
|
|
408
|
+
*/
|
|
409
|
+
const viewportIsNarrow =
|
|
410
|
+
typeof window.matchMedia === 'function'
|
|
411
|
+
? window.matchMedia(PHONE_AND_TABLET_MEDIA_CONDITION).matches
|
|
412
|
+
: isNarrowBreakpoint(currentBreakpoint);
|
|
413
|
+
|
|
414
|
+
const wantsMobile = editorAttached
|
|
415
|
+
? isNarrowBreakpoint(currentBreakpoint)
|
|
416
|
+
: viewportIsNarrow;
|
|
417
|
+
|
|
418
|
+
const next = wantsMobile ? mobileUrlAnywhere : desktopUrl;
|
|
419
|
+
if (!next || el.getAttribute('src') === next) return;
|
|
420
|
+
|
|
421
|
+
el.setAttribute('src', next);
|
|
422
|
+
el.load();
|
|
423
|
+
}, [
|
|
424
|
+
hasDistinctMobileVideo,
|
|
425
|
+
currentBreakpoint,
|
|
426
|
+
mobileUrlAnywhere,
|
|
427
|
+
desktopUrl
|
|
428
|
+
]);
|
|
429
|
+
|
|
309
430
|
/**
|
|
310
431
|
* The author's choice wins; a Short supplies its own when they made none.
|
|
311
432
|
* With neither, the historic fixed height is kept, so every composition
|
|
@@ -331,11 +452,6 @@ const VideoBlock = ({
|
|
|
331
452
|
* `<video>` inside it stayed `height: auto` — sized by its own aspect
|
|
332
453
|
* ratio, not by the box it was placed in.
|
|
333
454
|
*/
|
|
334
|
-
const heightStyle = getResponsiveValue(
|
|
335
|
-
block.styles?.height,
|
|
336
|
-
currentBreakpoint,
|
|
337
|
-
'auto'
|
|
338
|
-
) as string;
|
|
339
455
|
const objectFit = getResponsiveValue(
|
|
340
456
|
block.styles?.['object-fit'],
|
|
341
457
|
currentBreakpoint,
|
|
@@ -404,7 +520,7 @@ const VideoBlock = ({
|
|
|
404
520
|
const videoElement = (
|
|
405
521
|
<video
|
|
406
522
|
ref={videoRef}
|
|
407
|
-
src={parsed.src}
|
|
523
|
+
src={hasDistinctMobileVideo ? undefined : parsed.src}
|
|
408
524
|
controls={nativeControls}
|
|
409
525
|
autoPlay={properties.autoplay}
|
|
410
526
|
loop={properties.loop}
|
|
@@ -420,21 +536,35 @@ const VideoBlock = ({
|
|
|
420
536
|
playsInline={properties.playsInline !== false}
|
|
421
537
|
onPlay={() => setIsPlaying(true)}
|
|
422
538
|
onPause={() => setIsPlaying(false)}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
539
|
+
/*
|
|
540
|
+
* THE PLAYER FILLS ITS BOX; IT DOES NOT RE-DECLARE IT.
|
|
541
|
+
*
|
|
542
|
+
* `video` is not an inner-root type, so `generateThemeCSS` already writes
|
|
543
|
+
* the author's `aspect-ratio` and `height` onto the block's WRAPPER, and
|
|
544
|
+
* on the storefront it writes them as a real cascade — the box is the
|
|
545
|
+
* right shape at the first paint with no JavaScript at all. Re-resolving
|
|
546
|
+
* those same keys at `currentBreakpoint` and inlining them here is what
|
|
547
|
+
* broke it: the stored ratio is often mobile-only, `currentBreakpoint` is
|
|
548
|
+
* `'desktop'` for the server and the first client render, so the player
|
|
549
|
+
* shipped `height: auto` — and an inline style outranks the media query
|
|
550
|
+
* that had just sized the wrapper correctly. A 9/16 box 693px tall got a
|
|
551
|
+
* 192px player and 500px of white.
|
|
552
|
+
*
|
|
553
|
+
* `height: 100%` is unconditional on purpose. Measured in WebKit, Gecko
|
|
554
|
+
* and Blink: where nothing sizes the wrapper a percentage height resolves
|
|
555
|
+
* to `auto`, byte-identical to the old behaviour, so an unsized block is
|
|
556
|
+
* untouched. Where the author DID size it the player now fills it — which
|
|
557
|
+
* also ends the overflow a fixed pixel height used to produce.
|
|
558
|
+
*
|
|
559
|
+
* `aspect-ratio` stays inline for the `<iframe>` branch above, because a
|
|
560
|
+
* Short's ratio is derived from the URL and the stylesheet cannot know it.
|
|
561
|
+
*/
|
|
562
|
+
style={{
|
|
563
|
+
width: '100%',
|
|
564
|
+
height: '100%',
|
|
565
|
+
objectFit,
|
|
566
|
+
borderRadius: 'inherit'
|
|
567
|
+
}}
|
|
438
568
|
/>
|
|
439
569
|
);
|
|
440
570
|
|