bloom-player 2.19.9-alpha.1 → 2.19.9

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "A library for displaying Bloom books in iframes or WebViews",
4
4
  "author": "SIL Global",
5
5
  "license": "MIT",
6
- "version": "2.19.9-alpha.1",
6
+ "version": "2.19.9",
7
7
  "private": false,
8
8
  "// sideeffects might need to be ['*.css'] to avoid 'shaking' our CSS, if we ever get tree shaking working": "",
9
9
  "sideEffects": false,
@@ -226,6 +226,8 @@ export const PlayCompleted = new LiteEvent<HTMLElement>();
226
226
  // Raised when we can't play narration, specifically because the browser won't allow it until
227
227
  // the user has interacted with the page.
228
228
  export const PlayFailed = new LiteEvent<HTMLElement>();
229
+ // Raised when a user interaction resolves an autoplay-blocked state and media is about to resume.
230
+ export const PlayUnblocked = new LiteEvent<void>();
229
231
 
230
232
  // This event allows Narration to inform its controllers when we start/stop reading
231
233
  // image descriptions. It is raised for each segment we read and passed true if the one
@@ -1271,6 +1273,7 @@ export function hidingPage() {
1271
1273
 
1272
1274
  let playAllVideoGeneration = 0;
1273
1275
  let activePlayAllVideoElement: HTMLVideoElement | undefined;
1276
+ const transientVideoRetryCounts = new WeakMap<HTMLVideoElement, number>();
1274
1277
 
1275
1278
  export function stopPlayAllVideoPlayback() {
1276
1279
  playAllVideoGeneration++;
@@ -1281,12 +1284,26 @@ export function stopPlayAllVideoPlayback() {
1281
1284
  }
1282
1285
  }
1283
1286
 
1287
+ export function isTransientVideoPlayFailure(reason: any): boolean {
1288
+ if (!reason) {
1289
+ return false;
1290
+ }
1291
+ if (reason.name === "AbortError") {
1292
+ return true;
1293
+ }
1294
+ const message =
1295
+ (typeof reason.message === "string" && reason.message) ||
1296
+ (typeof reason.toString === "function" ? reason.toString() : "");
1297
+ return message.includes("interrupted by a call to pause()");
1298
+ }
1299
+
1284
1300
  // Attempt to show a video's first frame by briefly starting playback and then pausing.
1285
1301
  // The callback allows callers to decide at the moment playback starts whether pausing
1286
1302
  // is still desired (for example, page-level logic may stop pausing after initial load).
1287
1303
  export function showVideoFirstFrameWhenReady(
1288
1304
  video: HTMLVideoElement,
1289
1305
  shouldPauseAfterPlaying: () => boolean = () => true,
1306
+ onAutoplayBlocked: () => void = () => showVideoAutoplayBlockedHint(video),
1290
1307
  ) {
1291
1308
  let canceled = false;
1292
1309
  const attempt = () => {
@@ -1300,6 +1317,7 @@ export function showVideoFirstFrameWhenReady(
1300
1317
  }
1301
1318
  const playingListener = () => {
1302
1319
  window.clearTimeout(removePlayingListenerTimeout);
1320
+ hideVideoAutoplayBlockedHint(video);
1303
1321
  if (!shouldPauseAfterPlaying()) {
1304
1322
  return;
1305
1323
  }
@@ -1332,13 +1350,20 @@ export function showVideoFirstFrameWhenReady(
1332
1350
  const removePlayingListenerTimeout = window.setTimeout(() => {
1333
1351
  video.removeEventListener("playing", playingListener);
1334
1352
  }, 1000);
1353
+ hideVideoAutoplayBlockedHint(video);
1335
1354
  const promise = video.play();
1336
1355
  if (promise && promise.catch) {
1337
- promise.catch(() => {
1356
+ promise.catch((reason) => {
1338
1357
  window.clearTimeout(removePlayingListenerTimeout);
1339
1358
  // If play() fails (e.g., autoplay policy), remove the listener
1340
1359
  // so it won't interfere if playback starts later by other means.
1341
1360
  video.removeEventListener("playing", playingListener);
1361
+ // If autoplay is blocked, remove our transparent poster so a decoded
1362
+ // first frame can be shown when available.
1363
+ if (reason?.name === "NotAllowedError") {
1364
+ video.removeAttribute("poster");
1365
+ onAutoplayBlocked();
1366
+ }
1342
1367
  });
1343
1368
  }
1344
1369
  };
@@ -1378,6 +1403,32 @@ export function playAllVideo(elements: HTMLVideoElement[], then: () => void) {
1378
1403
  playAllVideoInternal(elements, then, generation);
1379
1404
  }
1380
1405
 
1406
+ export function isDefiniteVideoPlaybackSupportFailure(
1407
+ video: HTMLVideoElement,
1408
+ reason?: any,
1409
+ ): boolean {
1410
+ if (
1411
+ video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE &&
1412
+ video.readyState === HTMLMediaElement.HAVE_NOTHING
1413
+ ) {
1414
+ return true;
1415
+ }
1416
+
1417
+ if (!reason) {
1418
+ return false;
1419
+ }
1420
+
1421
+ if (reason.name === "NotSupportedError") {
1422
+ return true;
1423
+ }
1424
+
1425
+ // Some browsers expose codec/source failures only through media.error.
1426
+ return (
1427
+ video.error?.code ===
1428
+ (window.MediaError ? window.MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED : 4)
1429
+ );
1430
+ }
1431
+
1381
1432
  function playAllVideoInternal(
1382
1433
  elements: HTMLVideoElement[],
1383
1434
  then: () => void,
@@ -1395,14 +1446,13 @@ function playAllVideoInternal(
1395
1446
  activePlayAllVideoElement = video;
1396
1447
 
1397
1448
  // If there is an error, try to continue with the next video.
1398
- if (
1399
- video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE &&
1400
- video.readyState === HTMLMediaElement.HAVE_NOTHING
1401
- ) {
1449
+ if (isDefiniteVideoPlaybackSupportFailure(video)) {
1450
+ transientVideoRetryCounts.delete(video);
1402
1451
  showVideoError(video);
1403
1452
  playAllVideoInternal(elements.slice(1), then, generation);
1404
1453
  } else {
1405
1454
  hideVideoError(video);
1455
+ hideVideoAutoplayBlockedHint(video);
1406
1456
  setCurrentPlaybackMode(PlaybackMode.VideoPlaying);
1407
1457
  const promise = video.play();
1408
1458
  promise
@@ -1410,6 +1460,8 @@ function playAllVideoInternal(
1410
1460
  if (generation !== playAllVideoGeneration) {
1411
1461
  return;
1412
1462
  }
1463
+ transientVideoRetryCounts.delete(video);
1464
+ hideVideoAutoplayBlockedHint(video);
1413
1465
  // The promise resolves when the video starts playing. We want to know when it ends.
1414
1466
  // Note: in Bloom Desktop, sometimes this event does not fire normally, even when the video is
1415
1467
  // played to the end. I have not figured out why. It may be something to do with how we are
@@ -1438,7 +1490,25 @@ function playAllVideoInternal(
1438
1490
  if (generation !== playAllVideoGeneration) {
1439
1491
  return;
1440
1492
  }
1493
+ if (reason?.name === "NotAllowedError") {
1494
+ console.debug(
1495
+ "Video autoplay blocked until user interaction",
1496
+ );
1497
+ showVideoAutoplayBlockedHint(video);
1498
+ return;
1499
+ }
1500
+ const retries = transientVideoRetryCounts.get(video) ?? 0;
1501
+ if (isTransientVideoPlayFailure(reason)) {
1502
+ if (retries < 2) {
1503
+ transientVideoRetryCounts.set(video, retries + 1);
1504
+ window.setTimeout(() => {
1505
+ playAllVideoInternal(elements, then, generation);
1506
+ }, 50);
1507
+ return;
1508
+ }
1509
+ }
1441
1510
  console.error("Video play failed", reason);
1511
+ transientVideoRetryCounts.delete(video);
1442
1512
  showVideoError(video);
1443
1513
  playAllVideoInternal(elements.slice(1), then, generation);
1444
1514
  });
@@ -1467,10 +1537,26 @@ export function showVideoError(video: HTMLVideoElement): void {
1467
1537
  msgDiv.style.top = "10%";
1468
1538
  msgDiv.style.width = "80%";
1469
1539
  msgDiv.style.fontSize = "x-large";
1540
+ msgDiv.style.pointerEvents = "none";
1470
1541
  parent.appendChild(msgDiv);
1471
1542
  }
1472
1543
  }
1473
1544
  }
1545
+
1546
+ export function showVideoAutoplayBlockedHint(video: HTMLVideoElement): void {
1547
+ const container =
1548
+ (video.closest(".bloom-videoContainer") as HTMLElement | null) ||
1549
+ video.parentElement;
1550
+ container?.classList.add("autoplayBlocked");
1551
+ }
1552
+
1553
+ export function hideVideoAutoplayBlockedHint(video: HTMLVideoElement): void {
1554
+ const container =
1555
+ (video.closest(".bloom-videoContainer") as HTMLElement | null) ||
1556
+ video.parentElement;
1557
+ container?.classList.remove("autoplayBlocked");
1558
+ }
1559
+
1474
1560
  export function hideVideoError(video: HTMLVideoElement): void {
1475
1561
  const parent = video.parentElement;
1476
1562
  if (parent) {