@sequencestudio/web-core 0.5.0 → 0.5.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.
|
@@ -16,6 +16,8 @@ export type ShakaPlaybackState = {
|
|
|
16
16
|
isLoading: boolean;
|
|
17
17
|
isBuffering: boolean;
|
|
18
18
|
isReady: boolean;
|
|
19
|
+
/** The load is waiting on the viewer: shown as a Play button, not the spinner. */
|
|
20
|
+
needsStart: boolean;
|
|
19
21
|
isLive: boolean | null;
|
|
20
22
|
recoveredLoadKey: string | null;
|
|
21
23
|
/** The renewal in use, while the page still passes the source it replaced. */
|
|
@@ -57,6 +59,12 @@ export interface ShakaPlayback {
|
|
|
57
59
|
activeSource(options: ShakaPlayerOptions, state?: ShakaPlaybackState): ShakaActiveSource;
|
|
58
60
|
scheduleRenewal(): () => void;
|
|
59
61
|
boot(): () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Plays from the viewer's tap on the Play button `needsStart` shows. Call it
|
|
64
|
+
* straight from the click handler: it calls `play()` before returning, so
|
|
65
|
+
* the browser counts the tap.
|
|
66
|
+
*/
|
|
67
|
+
start(): void;
|
|
60
68
|
}
|
|
61
69
|
/** What a renderer returns from its Shaka hook, from the state and the page's options. */
|
|
62
70
|
export declare function shakaPlayerStatus(state: ShakaPlaybackState, options: Pick<ShakaPlayerOptions, 'src' | 'expectedDeliveryType'>): ShakaPlayerStatus;
|
|
@@ -15,6 +15,10 @@ const NATIVE_MEDIA_ERROR = 3016;
|
|
|
15
15
|
const FRESH_SOURCE_TIMEOUT_MS = 10_000;
|
|
16
16
|
// setTimeout fires at once for any delay past a signed 32-bit integer.
|
|
17
17
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
18
|
+
// How long a load may sit with no media before it is taken to be waiting on
|
|
19
|
+
// the viewer, and how often it is checked again while the page is hidden.
|
|
20
|
+
const START_WAIT_MS = 2_500;
|
|
21
|
+
const START_RECHECK_MS = 1_000;
|
|
18
22
|
const INITIAL_STATE = (src) => ({
|
|
19
23
|
player: null,
|
|
20
24
|
error: null,
|
|
@@ -22,6 +26,7 @@ const INITIAL_STATE = (src) => ({
|
|
|
22
26
|
isLoading: true,
|
|
23
27
|
isBuffering: false,
|
|
24
28
|
isReady: false,
|
|
29
|
+
needsStart: false,
|
|
25
30
|
isLive: null,
|
|
26
31
|
recoveredLoadKey: null,
|
|
27
32
|
fresh: null,
|
|
@@ -39,6 +44,7 @@ export function shakaPlayerStatus(state, options) {
|
|
|
39
44
|
isLoading: state.isLoading,
|
|
40
45
|
isBuffering: state.isBuffering,
|
|
41
46
|
isReady: state.isReady,
|
|
47
|
+
needsStart: state.needsStart,
|
|
42
48
|
isLive: state.isLive,
|
|
43
49
|
deliveryType: resolveDeliveryType(state.isLive, options.expectedDeliveryType),
|
|
44
50
|
recoveredLoadKey: state.recoveredLoadKey,
|
|
@@ -94,6 +100,12 @@ export function createShakaPlayback(initial) {
|
|
|
94
100
|
// Mode does), and overlapping attach/load on one <video> leaves Shaka
|
|
95
101
|
// spinning on the spinner.
|
|
96
102
|
let previousDestroy = Promise.resolve();
|
|
103
|
+
// The viewer pressed Play during this boot's load, so it no longer waits on
|
|
104
|
+
// them: the load carries on, and only a refused play() asks again.
|
|
105
|
+
let startRequested = false;
|
|
106
|
+
// Counts boots, and teardowns, so a tap's late play() rejection from an earlier
|
|
107
|
+
// load leaves the current one alone: the <video> is the same across sources.
|
|
108
|
+
let bootGeneration = 0;
|
|
97
109
|
// A reporter for the options and source as they are now, made afresh on
|
|
98
110
|
// every update as a render's closure would be. Merges the per-play context
|
|
99
111
|
// onto every record, so call sites pass just the event specifics.
|
|
@@ -213,6 +225,8 @@ export function createShakaPlayback(initial) {
|
|
|
213
225
|
if (!video || !activeSrc)
|
|
214
226
|
return () => undefined;
|
|
215
227
|
const reloadCount = state.reloadCount;
|
|
228
|
+
startRequested = false;
|
|
229
|
+
bootGeneration += 1;
|
|
216
230
|
// This boot's content, pinned now: by teardown the options already hold
|
|
217
231
|
// the next source's id.
|
|
218
232
|
const bootContentId = options.contentId;
|
|
@@ -410,6 +424,29 @@ export function createShakaPlayback(initial) {
|
|
|
410
424
|
// Closing the tab runs no renderer cleanup, so this is the only chance to
|
|
411
425
|
// report a stall the viewer walked away from.
|
|
412
426
|
const onPageHide = () => reportStall(stalls.left());
|
|
427
|
+
// Media arriving means the load was never waiting on the viewer.
|
|
428
|
+
const onLoadedMetadata = () => setState({ needsStart: false });
|
|
429
|
+
// Some browsers hold a page's media back until the viewer taps it: iOS 27
|
|
430
|
+
// keeps the MediaSource from opening, so `load()` neither resolves nor
|
|
431
|
+
// fails and nothing is requested. The spinner would then never clear, and
|
|
432
|
+
// the controls that could take the tap wait for the load. A load still
|
|
433
|
+
// without media after a few seconds in view asks for the tap instead. Asking
|
|
434
|
+
// during a slow but working load costs only a tap that plays sooner.
|
|
435
|
+
let startTimer;
|
|
436
|
+
const checkStart = () => {
|
|
437
|
+
startTimer = undefined;
|
|
438
|
+
if (cancelled || phase === 'runtime' || state.error)
|
|
439
|
+
return;
|
|
440
|
+
// Keeps watching until the load runs: a tap whose play() failed for another
|
|
441
|
+
// reason clears `startRequested`, and a load still without media asks again.
|
|
442
|
+
if (!startRequested &&
|
|
443
|
+
!state.needsStart &&
|
|
444
|
+
document.visibilityState === 'visible' &&
|
|
445
|
+
video.readyState === HTMLMediaElement.HAVE_NOTHING) {
|
|
446
|
+
setState({ needsStart: true });
|
|
447
|
+
}
|
|
448
|
+
startTimer = setTimeout(checkStart, START_RECHECK_MS);
|
|
449
|
+
};
|
|
413
450
|
const run = async () => {
|
|
414
451
|
setState({
|
|
415
452
|
recoveredLoadKey: recovering ? `reload-${reloadCount}` : null,
|
|
@@ -418,6 +455,7 @@ export function createShakaPlayback(initial) {
|
|
|
418
455
|
isLive: null,
|
|
419
456
|
isBuffering: false,
|
|
420
457
|
isLoading: true,
|
|
458
|
+
needsStart: false,
|
|
421
459
|
});
|
|
422
460
|
restoreShakaVolume(video);
|
|
423
461
|
try {
|
|
@@ -499,6 +537,7 @@ export function createShakaPlayback(initial) {
|
|
|
499
537
|
video.addEventListener('seeking', onSeeking);
|
|
500
538
|
video.addEventListener('pause', onPause);
|
|
501
539
|
video.addEventListener('ended', onEnded);
|
|
540
|
+
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
502
541
|
window.addEventListener('pagehide', onPageHide);
|
|
503
542
|
}
|
|
504
543
|
if (!currentPlayer) {
|
|
@@ -515,6 +554,10 @@ export function createShakaPlayback(initial) {
|
|
|
515
554
|
await previousDestroy;
|
|
516
555
|
if (cancelled)
|
|
517
556
|
return;
|
|
557
|
+
// Timed from here, once a previous player has gone, so a slow teardown
|
|
558
|
+
// never counts as this load's wait. Not after `attach()`: with
|
|
559
|
+
// MediaSource it waits for `sourceopen`, which is what never comes.
|
|
560
|
+
startTimer = setTimeout(checkStart, START_WAIT_MS);
|
|
518
561
|
await player.attach(video);
|
|
519
562
|
if (cancelled)
|
|
520
563
|
return;
|
|
@@ -555,8 +598,9 @@ export function createShakaPlayback(initial) {
|
|
|
555
598
|
manifestDeliveryType = isLiveStream ? 'live' : 'vod';
|
|
556
599
|
presentedLive = readEventStream()?.presentingLive === true;
|
|
557
600
|
report = reportLogRef.current;
|
|
601
|
+
clearTimeout(startTimer);
|
|
558
602
|
// One update, so no render sees a ready player of unknown live-ness.
|
|
559
|
-
setState({ isLive: isLiveStream, isReady: true, isLoading: false, isBuffering: false });
|
|
603
|
+
setState({ isLive: isLiveStream, isReady: true, isLoading: false, isBuffering: false, needsStart: false });
|
|
560
604
|
recovery.setStalled(false);
|
|
561
605
|
// A reload plays on only if the viewer was playing when it expired. It
|
|
562
606
|
// mutes rather than stays paused if the browser refuses sound, as a
|
|
@@ -579,6 +623,7 @@ export function createShakaPlayback(initial) {
|
|
|
579
623
|
void run();
|
|
580
624
|
return () => {
|
|
581
625
|
cancelled = true;
|
|
626
|
+
clearTimeout(startTimer);
|
|
582
627
|
// A loaded VOD hands its place to the next load of the same content.
|
|
583
628
|
if (phase === 'runtime' && !isLiveStream && Number.isFinite(video.currentTime) && video.currentTime > 0) {
|
|
584
629
|
carriedPosition = {
|
|
@@ -609,8 +654,10 @@ export function createShakaPlayback(initial) {
|
|
|
609
654
|
video.removeEventListener('seeking', onSeeking);
|
|
610
655
|
video.removeEventListener('pause', onPause);
|
|
611
656
|
video.removeEventListener('ended', onEnded);
|
|
657
|
+
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
612
658
|
window.removeEventListener('pagehide', onPageHide);
|
|
613
|
-
|
|
659
|
+
bootGeneration += 1;
|
|
660
|
+
setState({ player: null, isLive: null, needsStart: false });
|
|
614
661
|
if (!currentPlayer)
|
|
615
662
|
return;
|
|
616
663
|
const previous = previousDestroy;
|
|
@@ -618,6 +665,25 @@ export function createShakaPlayback(initial) {
|
|
|
618
665
|
previousDestroy = Promise.all([previous, currentDestroy]).then(() => undefined);
|
|
619
666
|
};
|
|
620
667
|
};
|
|
668
|
+
const start = () => {
|
|
669
|
+
const video = options.videoRef.current;
|
|
670
|
+
if (!video)
|
|
671
|
+
return;
|
|
672
|
+
const generation = bootGeneration;
|
|
673
|
+
startRequested = true;
|
|
674
|
+
setState({ needsStart: false });
|
|
675
|
+
// Called before anything is awaited, inside the viewer's tap. The promise
|
|
676
|
+
// can stay pending until the load finishes.
|
|
677
|
+
// A refusal asks again at once; any other failure lets the load's check ask again
|
|
678
|
+
// if there is still no media.
|
|
679
|
+
void video.play().catch((err) => {
|
|
680
|
+
if (generation !== bootGeneration)
|
|
681
|
+
return;
|
|
682
|
+
startRequested = false;
|
|
683
|
+
if (err?.name === 'NotAllowedError')
|
|
684
|
+
setState({ needsStart: true });
|
|
685
|
+
});
|
|
686
|
+
};
|
|
621
687
|
update(initial);
|
|
622
688
|
return {
|
|
623
689
|
getState: () => state,
|
|
@@ -631,6 +697,7 @@ export function createShakaPlayback(initial) {
|
|
|
631
697
|
activeSource: (from, current) => activeSource(from, current),
|
|
632
698
|
scheduleRenewal,
|
|
633
699
|
boot,
|
|
700
|
+
start,
|
|
634
701
|
};
|
|
635
702
|
}
|
|
636
703
|
// What the page passed, to tell a credential the page replaced from one a
|
|
@@ -81,6 +81,12 @@ export type ShakaPlayerStatus = {
|
|
|
81
81
|
isLoading: boolean;
|
|
82
82
|
isBuffering: boolean;
|
|
83
83
|
isReady: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The load is waiting on the viewer's tap: some browsers (iOS 27) hold a
|
|
86
|
+
* page's media back until then, so the player shows a Play button rather
|
|
87
|
+
* than a spinner that would never clear.
|
|
88
|
+
*/
|
|
89
|
+
needsStart: boolean;
|
|
84
90
|
/**
|
|
85
91
|
* Whether the loaded manifest is live. `null` until `load()` resolves - Shaka
|
|
86
92
|
* cannot answer before the manifest is parsed - and again after a source
|
package/dist/player/styles.css
CHANGED
|
@@ -797,6 +797,40 @@
|
|
|
797
797
|
animation: sq-player-spin 1s linear infinite;
|
|
798
798
|
color: var(--sq-content-on-media);
|
|
799
799
|
}
|
|
800
|
+
/* In place of the spinner when the load waits on the viewer's tap. */
|
|
801
|
+
.sq-player-start {
|
|
802
|
+
position: absolute;
|
|
803
|
+
inset: 0px;
|
|
804
|
+
display: flex;
|
|
805
|
+
align-items: center;
|
|
806
|
+
justify-content: center;
|
|
807
|
+
border: none;
|
|
808
|
+
background: none;
|
|
809
|
+
padding: 0;
|
|
810
|
+
cursor: pointer;
|
|
811
|
+
}
|
|
812
|
+
.sq-player-start-badge {
|
|
813
|
+
display: flex;
|
|
814
|
+
height: 5rem;
|
|
815
|
+
width: 5rem;
|
|
816
|
+
align-items: center;
|
|
817
|
+
justify-content: center;
|
|
818
|
+
border-radius: calc(infinity * 1px);
|
|
819
|
+
background-color: var(--sq-player-control-background, rgba(32, 32, 32, 0.4));
|
|
820
|
+
color: var(--sq-content-on-media);
|
|
821
|
+
-webkit-backdrop-filter: blur(8px);
|
|
822
|
+
backdrop-filter: blur(8px);
|
|
823
|
+
}
|
|
824
|
+
.sq-player-start:focus-visible .sq-player-start-badge {
|
|
825
|
+
outline: 2px solid var(--sq-content-on-media);
|
|
826
|
+
outline-offset: 2px;
|
|
827
|
+
}
|
|
828
|
+
.sq-player-start-icon {
|
|
829
|
+
width: 2.5rem;
|
|
830
|
+
height: 2.5rem;
|
|
831
|
+
flex-shrink: 0;
|
|
832
|
+
color: var(--sq-content-on-media);
|
|
833
|
+
}
|
|
800
834
|
@keyframes sq-player-spin {
|
|
801
835
|
to {
|
|
802
836
|
transform: rotate(360deg);
|
|
@@ -31,6 +31,8 @@ export interface PlayerTestIds {
|
|
|
31
31
|
/** The layer over the video that holds the controls and states. */
|
|
32
32
|
overlay?: string | null;
|
|
33
33
|
loading?: string | null;
|
|
34
|
+
/** The Play button shown in place of the spinner when the load waits on a tap. */
|
|
35
|
+
start?: string | null;
|
|
34
36
|
/** The flash that confirms a keyboard or tap action, and its parts. */
|
|
35
37
|
actionFeedback?: string | null;
|
|
36
38
|
actionFeedbackCenter?: string | null;
|
package/dist/player/test-ids.js
CHANGED
|
@@ -8,6 +8,7 @@ export const DEFAULT_PLAYER_TEST_IDS = {
|
|
|
8
8
|
interactiveClose: 'player-interactive-close',
|
|
9
9
|
overlay: 'video-overlay',
|
|
10
10
|
loading: 'video-loading',
|
|
11
|
+
start: 'video-start',
|
|
11
12
|
actionFeedback: 'video-action-feedback',
|
|
12
13
|
actionFeedbackCenter: 'video-action-feedback-center',
|
|
13
14
|
actionFeedbackSeek: 'video-action-feedback-seek',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sequencestudio/web-core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Framework-independent core for sites that render Sequence layouts: the adapter from Client Delivery responses to view models, the presentation model, shelf layout math and design tokens.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|