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/dist/{bloomPlayer-DHiLPE1q.css → bloomPlayer-QXwbsTI2.css} +1 -1
- package/dist/{bloomPlayer.Bmk9o7f9.js → bloomPlayer.CyOA-cvW.js} +23 -23
- package/dist/bloomplayer.htm +2 -2
- package/lib/narration.d.ts +6 -1
- package/lib/shared.es.js +2411 -2356
- package/lib/shared.es.js.map +1 -1
- package/package.json +1 -1
- package/src/shared/narration.ts +164 -28
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.
|
|
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": "",
|
package/src/shared/narration.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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) {
|