@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
- setState({ player: null, isLive: null });
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
@@ -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;
@@ -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.0",
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",