bloom-player 2.20.0-alpha.1 → 2.20.0-alpha.3

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.20.0-alpha.1",
6
+ "version": "2.20.0-alpha.3",
7
7
  "packageManager": "pnpm@11.1.2",
8
8
  "private": false,
9
9
  "// sideeffects might need to be ['*.css'] to avoid 'shaking' our CSS, if we ever get tree shaking working": "",
@@ -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,50 +1446,119 @@ 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);
1457
+ // Always play each queued video from the beginning.
1458
+ // Without this, a previously played element may remain at end-of-stream
1459
+ // and fail to raise the expected ended event for sequencing.
1460
+ video.currentTime = 0;
1461
+ // Note that we get a new instance of this for each recursive call to playAllVideoInternal.
1462
+ // It serves to make sure we don't try to advance twice if we get both an ended and a pause event.
1463
+ let advanced = false;
1464
+ let watchdogTimerId: number | undefined;
1465
+ const watchdogGraceMs = 250;
1466
+ const clearWatchdog = () => {
1467
+ if (watchdogTimerId !== undefined) {
1468
+ window.clearTimeout(watchdogTimerId);
1469
+ watchdogTimerId = undefined;
1470
+ }
1471
+ };
1472
+ const scheduleWatchdogFromDuration = () => {
1473
+ const duration = video.duration;
1474
+ if (!Number.isFinite(duration) || duration <= 0) {
1475
+ return;
1476
+ }
1477
+ clearWatchdog();
1478
+ const playbackRate =
1479
+ Number.isFinite(video.playbackRate) && video.playbackRate > 0
1480
+ ? video.playbackRate
1481
+ : 1;
1482
+ const timeoutMs = Math.max(
1483
+ duration * 1000 / playbackRate + watchdogGraceMs,
1484
+ watchdogGraceMs,
1485
+ );
1486
+ watchdogTimerId = window.setTimeout(() => {
1487
+ advanceToNextVideo();
1488
+ }, timeoutMs);
1489
+ };
1490
+ const advanceToNextVideo = () => {
1491
+ if (advanced) {
1492
+ return;
1493
+ }
1494
+ advanced = true;
1495
+ clearWatchdog();
1496
+ video.removeEventListener("ended", endedHandler);
1497
+ video.removeEventListener("pause", pauseHandler);
1498
+ video.removeEventListener("loadedmetadata", metadataHandler);
1499
+ if (generation !== playAllVideoGeneration) {
1500
+ return;
1501
+ }
1502
+ playAllVideoInternal(elements.slice(1), then, generation);
1503
+ };
1504
+ const endedHandler = () => {
1505
+ advanceToNextVideo();
1506
+ };
1507
+ // In some environments, playback can pause at the end without raising ended.
1508
+ // Treat that as completion so a short first video can't stall the sequence.
1509
+ const pauseHandler = () => {
1510
+ const duration = video.duration;
1511
+ if (!Number.isFinite(duration) || duration <= 0) {
1512
+ return;
1513
+ }
1514
+ if (video.currentTime >= duration - 0.05) {
1515
+ advanceToNextVideo();
1516
+ }
1517
+ };
1518
+ const metadataHandler = () => {
1519
+ scheduleWatchdogFromDuration();
1520
+ };
1521
+ // Register ended before play() so we don't miss extremely fast end transitions.
1522
+ video.addEventListener("ended", endedHandler, { once: true });
1523
+ video.addEventListener("pause", pauseHandler);
1524
+ video.addEventListener("loadedmetadata", metadataHandler);
1407
1525
  const promise = video.play();
1408
1526
  promise
1409
1527
  .then(() => {
1410
1528
  if (generation !== playAllVideoGeneration) {
1411
1529
  return;
1412
1530
  }
1413
- // The promise resolves when the video starts playing. We want to know when it ends.
1414
- // Note: in Bloom Desktop, sometimes this event does not fire normally, even when the video is
1415
- // played to the end. I have not figured out why. It may be something to do with how we are
1416
- // trimming the videos.
1417
- // In Bloom Desktop, this is worked around by raising the ended event when we detect that it has
1418
- // paused past the end point in resetToStartAfterPlayingToEndPoint.
1419
- // In BloomPlayer,I don't think this is a problem. Videos are trimmed when published, so we always
1420
- // play to the real end (unless the user pauses). So one way or another, we should get the ended
1421
- // event.
1422
- video.addEventListener(
1423
- "ended",
1424
- () => {
1425
- if (generation !== playAllVideoGeneration) {
1426
- return;
1427
- }
1428
- playAllVideoInternal(
1429
- elements.slice(1),
1430
- then,
1431
- generation,
1432
- );
1433
- },
1434
- { once: true },
1435
- );
1531
+ transientVideoRetryCounts.delete(video);
1532
+ hideVideoAutoplayBlockedHint(video);
1533
+ scheduleWatchdogFromDuration();
1436
1534
  })
1437
1535
  .catch((reason) => {
1438
1536
  if (generation !== playAllVideoGeneration) {
1439
1537
  return;
1440
1538
  }
1539
+ clearWatchdog();
1540
+ video.removeEventListener("ended", endedHandler);
1541
+ video.removeEventListener("pause", pauseHandler);
1542
+ video.removeEventListener("loadedmetadata", metadataHandler);
1543
+ if (reason?.name === "NotAllowedError") {
1544
+ console.debug(
1545
+ "Video autoplay blocked until user interaction",
1546
+ );
1547
+ showVideoAutoplayBlockedHint(video);
1548
+ return;
1549
+ }
1550
+ const retries = transientVideoRetryCounts.get(video) ?? 0;
1551
+ if (isTransientVideoPlayFailure(reason)) {
1552
+ if (retries < 2) {
1553
+ transientVideoRetryCounts.set(video, retries + 1);
1554
+ window.setTimeout(() => {
1555
+ playAllVideoInternal(elements, then, generation);
1556
+ }, 50);
1557
+ return;
1558
+ }
1559
+ }
1441
1560
  console.error("Video play failed", reason);
1561
+ transientVideoRetryCounts.delete(video);
1442
1562
  showVideoError(video);
1443
1563
  playAllVideoInternal(elements.slice(1), then, generation);
1444
1564
  });
@@ -1467,10 +1587,26 @@ export function showVideoError(video: HTMLVideoElement): void {
1467
1587
  msgDiv.style.top = "10%";
1468
1588
  msgDiv.style.width = "80%";
1469
1589
  msgDiv.style.fontSize = "x-large";
1590
+ msgDiv.style.pointerEvents = "none";
1470
1591
  parent.appendChild(msgDiv);
1471
1592
  }
1472
1593
  }
1473
1594
  }
1595
+
1596
+ export function showVideoAutoplayBlockedHint(video: HTMLVideoElement): void {
1597
+ const container =
1598
+ (video.closest(".bloom-videoContainer") as HTMLElement | null) ||
1599
+ video.parentElement;
1600
+ container?.classList.add("autoplayBlocked");
1601
+ }
1602
+
1603
+ export function hideVideoAutoplayBlockedHint(video: HTMLVideoElement): void {
1604
+ const container =
1605
+ (video.closest(".bloom-videoContainer") as HTMLElement | null) ||
1606
+ video.parentElement;
1607
+ container?.classList.remove("autoplayBlocked");
1608
+ }
1609
+
1474
1610
  export function hideVideoError(video: HTMLVideoElement): void {
1475
1611
  const parent = video.parentElement;
1476
1612
  if (parent) {