bloom-player 2.20.0-alpha.2 → 2.20.0-alpha.4
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-CpTXugnQ.css → bloomPlayer-QXwbsTI2.css} +1 -1
- package/dist/{bloomPlayer.CdHp5c3Q.js → bloomPlayer.Cekh2e5S.js} +22 -22
- package/dist/bloomplayer.htm +2 -2
- package/lib/dragActivityRuntime.d.ts +3 -0
- package/lib/shared.es.js +2125 -2034
- package/lib/shared.es.js.map +1 -1
- package/package.json +1 -1
- package/src/shared/dragActivityRuntime.ts +117 -30
- package/src/shared/narration.ts +191 -37
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.4",
|
|
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": "",
|
|
@@ -235,6 +235,9 @@ export function prepareActivity(
|
|
|
235
235
|
|
|
236
236
|
prepareOrderSentenceActivity(page);
|
|
237
237
|
|
|
238
|
+
// Start preloading correct/wrong sounds now so they are buffered by the time the user answers.
|
|
239
|
+
preloadSoundsForActivity(page);
|
|
240
|
+
|
|
238
241
|
// Slider: // for drag-word-chooser-slider
|
|
239
242
|
// setupWordChooserSlider(page);
|
|
240
243
|
// setSlideablesVisibility(page, false);
|
|
@@ -346,6 +349,9 @@ export function undoPrepareActivity(page: HTMLElement) {
|
|
|
346
349
|
).forEach((elt: HTMLElement) => {
|
|
347
350
|
elt.parentElement?.removeChild(elt);
|
|
348
351
|
});
|
|
352
|
+
|
|
353
|
+
cleanupActivitySounds(page);
|
|
354
|
+
|
|
349
355
|
const inPlayer = page.closest(".swiper-slide") !== null;
|
|
350
356
|
doShowAnswersInTargets(!inPlayer, page);
|
|
351
357
|
//Slider: setSlideablesVisibility(page, true);
|
|
@@ -848,14 +854,95 @@ export function setDefaultSoundUrls(
|
|
|
848
854
|
defaultWrongSoundUrl = wrongSoundUrl;
|
|
849
855
|
}
|
|
850
856
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
857
|
+
// Attribute used to mark audio elements preloaded for correct/wrong sounds.
|
|
858
|
+
const kPreloadSoundAttr = "data-preload-sound";
|
|
859
|
+
|
|
860
|
+
// Remove any audio elements previously added by preloadSoundsForActivity.
|
|
861
|
+
// Called from undoPrepareActivity and ActivityContext.stop().
|
|
862
|
+
export function cleanupActivitySounds(page: HTMLElement): void {
|
|
863
|
+
page.querySelectorAll<HTMLAudioElement>(
|
|
864
|
+
`audio[${kPreloadSoundAttr}]`,
|
|
865
|
+
).forEach((audio) => {
|
|
866
|
+
audio.src = "";
|
|
867
|
+
audio.remove();
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Preload both correct and wrong sounds so they are buffered before the user answers.
|
|
872
|
+
// Called from prepareActivity so loading starts as soon as the page is shown.
|
|
873
|
+
// Also called from ActivityContext so the sounds are ready for non-drag activities.
|
|
874
|
+
export function preloadSoundsForActivity(page: HTMLElement): void {
|
|
875
|
+
const preloadIfNeeded = (
|
|
876
|
+
soundFile: string | null,
|
|
877
|
+
defaultUrl: string | undefined,
|
|
878
|
+
): void => {
|
|
879
|
+
const addPrefix = soundFile !== null;
|
|
880
|
+
if (soundFile === null) {
|
|
881
|
+
soundFile = defaultUrl ?? null;
|
|
882
|
+
} else if (soundFile === "none") {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (!soundFile) return;
|
|
886
|
+
|
|
887
|
+
const url = (addPrefix ? urlPrefix() + "/audio/" : "") + soundFile;
|
|
888
|
+
// Avoid duplicate preloads (e.g. if prepareActivity is called more than once)
|
|
889
|
+
const already = Array.from(
|
|
890
|
+
page.querySelectorAll<HTMLAudioElement>(`audio[${kPreloadSoundAttr}]`),
|
|
891
|
+
).find((a) => a.dataset.preloadSound === url);
|
|
892
|
+
if (already) return;
|
|
893
|
+
|
|
894
|
+
const audio = document.createElement("audio");
|
|
895
|
+
audio.dataset.preloadSound = url;
|
|
896
|
+
audio.style.visibility = "hidden";
|
|
897
|
+
if (IsRunningOnBloomDesktop(page)) {
|
|
898
|
+
audio.classList.add("bloom-ui");
|
|
899
|
+
}
|
|
900
|
+
audio.src = url;
|
|
901
|
+
audio.load();
|
|
902
|
+
page.append(audio);
|
|
903
|
+
};
|
|
854
904
|
|
|
855
|
-
|
|
905
|
+
preloadIfNeeded(
|
|
906
|
+
page.getAttribute("data-correct-sound"),
|
|
907
|
+
defaultCorrectSoundUrl,
|
|
908
|
+
);
|
|
909
|
+
preloadIfNeeded(
|
|
910
|
+
page.getAttribute("data-wrong-sound"),
|
|
911
|
+
defaultWrongSoundUrl,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Play the correct or wrong sound for the given page, then call `then` when done (or immediately
|
|
916
|
+
// if there is no sound). Handles the data-correct-sound / data-wrong-sound attribute and the
|
|
917
|
+
// registered default URLs. Exported so ActivityContext can share this logic.
|
|
918
|
+
export function playCorrectOrWrongSound(
|
|
919
|
+
page: HTMLElement,
|
|
920
|
+
correct: boolean,
|
|
921
|
+
then?: () => void,
|
|
922
|
+
): void {
|
|
856
923
|
let soundFile = page.getAttribute(
|
|
857
924
|
correct ? "data-correct-sound" : "data-wrong-sound",
|
|
858
925
|
);
|
|
926
|
+
// if the attribute is not there at all, use the default sound, if one has been set.
|
|
927
|
+
// The default is not relative to the audio folder in the book, so we won't add a prefix.
|
|
928
|
+
const addPrefix = soundFile !== null;
|
|
929
|
+
if (soundFile === null) {
|
|
930
|
+
soundFile = correct ? defaultCorrectSoundUrl : defaultWrongSoundUrl;
|
|
931
|
+
} else if (soundFile === "none") {
|
|
932
|
+
// explicitly no sound
|
|
933
|
+
soundFile = undefined;
|
|
934
|
+
}
|
|
935
|
+
if (soundFile) {
|
|
936
|
+
playSound(page, soundFile, addPrefix, then);
|
|
937
|
+
} else {
|
|
938
|
+
then?.();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function showCorrectOrWrongItems(page: HTMLElement, correct: boolean) {
|
|
943
|
+
classSetter(page, "drag-activity-correct", correct);
|
|
944
|
+
classSetter(page, "drag-activity-wrong", !correct);
|
|
945
|
+
|
|
859
946
|
const playOtherStuff = () => {
|
|
860
947
|
const elementsMadeVisible = Array.from(
|
|
861
948
|
page.getElementsByClassName(
|
|
@@ -871,21 +958,7 @@ function showCorrectOrWrongItems(page: HTMLElement, correct: boolean) {
|
|
|
871
958
|
const playables = getAudioSentences(possibleNarrationElements);
|
|
872
959
|
playAllVideo(videoElements, () => playAllAudio(playables, page));
|
|
873
960
|
};
|
|
874
|
-
|
|
875
|
-
// This is not in relative to the audio folder in the book, so we won't add a prefix
|
|
876
|
-
// in that case.
|
|
877
|
-
const addPrefix = soundFile !== null;
|
|
878
|
-
if (soundFile === null) {
|
|
879
|
-
soundFile = correct ? defaultCorrectSoundUrl : defaultWrongSoundUrl;
|
|
880
|
-
} else if (soundFile === "none") {
|
|
881
|
-
// explicity no sound, go straight to other stuff if any
|
|
882
|
-
soundFile = undefined;
|
|
883
|
-
}
|
|
884
|
-
if (soundFile) {
|
|
885
|
-
playSound(page, soundFile, addPrefix, playOtherStuff);
|
|
886
|
-
} else {
|
|
887
|
-
playOtherStuff();
|
|
888
|
-
}
|
|
961
|
+
playCorrectOrWrongSound(page, correct, playOtherStuff);
|
|
889
962
|
}
|
|
890
963
|
|
|
891
964
|
function playSound(
|
|
@@ -894,9 +967,31 @@ function playSound(
|
|
|
894
967
|
addPrefix = true,
|
|
895
968
|
then?: () => void,
|
|
896
969
|
) {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
970
|
+
const url = (addPrefix ? urlPrefix() + "/audio/" : "") + soundFile;
|
|
971
|
+
|
|
972
|
+
// Reuse a preloaded element if one was prepared by preloadSoundsForActivity.
|
|
973
|
+
// This avoids re-downloading the file and removes the delay before playback starts.
|
|
974
|
+
const preloaded = Array.from(
|
|
975
|
+
someElt.querySelectorAll<HTMLAudioElement>(`audio[${kPreloadSoundAttr}]`),
|
|
976
|
+
).find((a) => a.dataset.preloadSound === url);
|
|
977
|
+
|
|
978
|
+
let audio: HTMLAudioElement;
|
|
979
|
+
if (preloaded) {
|
|
980
|
+
delete preloaded.dataset.preloadSound; // mark as consumed
|
|
981
|
+
preloaded.currentTime = 0;
|
|
982
|
+
audio = preloaded;
|
|
983
|
+
} else {
|
|
984
|
+
audio = new Audio(url);
|
|
985
|
+
if (IsRunningOnBloomDesktop(someElt)) {
|
|
986
|
+
audio.classList.add("bloom-ui"); // in case remove code fails, should make sure it doesn't get saved.
|
|
987
|
+
}
|
|
988
|
+
audio.style.visibility = "hidden";
|
|
989
|
+
// To my surprise, in BP storybook it works without adding the audio to any document.
|
|
990
|
+
// But in Bloom proper, it does not. I think it is because this code is part of the toolbox,
|
|
991
|
+
// so the audio element doesn't have the right context to interpret the relative URL.
|
|
992
|
+
someElt.append(audio);
|
|
993
|
+
}
|
|
994
|
+
|
|
900
995
|
let finished = false;
|
|
901
996
|
const finish = () => {
|
|
902
997
|
if (finished) {
|
|
@@ -905,14 +1000,6 @@ function playSound(
|
|
|
905
1000
|
finished = true;
|
|
906
1001
|
then?.();
|
|
907
1002
|
};
|
|
908
|
-
if (IsRunningOnBloomDesktop(someElt)) {
|
|
909
|
-
audio.classList.add("bloom-ui"); // in case remove code fails, should make sure it doesn't get saved.
|
|
910
|
-
}
|
|
911
|
-
audio.style.visibility = "hidden";
|
|
912
|
-
// To my surprise, in BP storybook it works without adding the audio to any document.
|
|
913
|
-
// But in Bloom proper, it does not. I think it is because this code is part of the toolbox,
|
|
914
|
-
// so the audio element doesn't have the right context to interpret the relative URL.
|
|
915
|
-
someElt.append(audio);
|
|
916
1003
|
const playPromise = audio.play();
|
|
917
1004
|
playPromise?.catch(() => {
|
|
918
1005
|
finish();
|
package/src/shared/narration.ts
CHANGED
|
@@ -312,6 +312,15 @@ export function playAllAudio(elements: HTMLElement[], page: HTMLElement): void {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
const firstElementToPlay = elementsToPlayConsecutivelyStack[stackSize - 1]; // Remember to pop it when you're done playing it. (i.e., in playEnded)
|
|
315
|
+
|
|
316
|
+
// Discard any preloaded audio from the previous page, then start preloading the second
|
|
317
|
+
// segment now so the browser has the full duration of the first segment to download it.
|
|
318
|
+
// (The first segment is loaded normally by the setSoundAndHighlight call below.)
|
|
319
|
+
clearPreloadedAudio();
|
|
320
|
+
if (stackSize >= 2) {
|
|
321
|
+
preloadAudioForElement(elementsToPlayConsecutivelyStack[stackSize - 2]);
|
|
322
|
+
}
|
|
323
|
+
|
|
315
324
|
// At one point it seemed to help something to delete the media player and make a new one each time.
|
|
316
325
|
// I didn't comment this at the time, but my recollection is that this could help with some cases
|
|
317
326
|
// where the old one was in a bad state, such as in the middle of pausing.
|
|
@@ -558,6 +567,18 @@ function playCurrentInternal() {
|
|
|
558
567
|
++currentAudioSessionNum;
|
|
559
568
|
audioPlayCurrentStartTime = new Date().getTime();
|
|
560
569
|
highlightNextSubElement(currentAudioSessionNum);
|
|
570
|
+
|
|
571
|
+
// While this segment plays, preload the next one so its audio is buffered by the
|
|
572
|
+
// time we need it. The current segment is at stack[length-1] (not yet popped), so
|
|
573
|
+
// the next is at stack[length-2].
|
|
574
|
+
const nextPreloadIndex =
|
|
575
|
+
elementsToPlayConsecutivelyStack.length - 2;
|
|
576
|
+
if (nextPreloadIndex >= 0) {
|
|
577
|
+
preloadAudioForElement(
|
|
578
|
+
elementsToPlayConsecutivelyStack[nextPreloadIndex],
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
561
582
|
handlePlayPromise(promise);
|
|
562
583
|
}
|
|
563
584
|
}
|
|
@@ -796,7 +817,12 @@ function setHighlightTo({
|
|
|
796
817
|
// narration and drag activity text. See BL-14797
|
|
797
818
|
const hasError = mediaPlayer.error !== null;
|
|
798
819
|
if (!hasError && disableHighlightIfNoAudio) {
|
|
799
|
-
|
|
820
|
+
// Use paused/ended to detect whether audio is actively playing.
|
|
821
|
+
// currentTime > 0 was the old check, but it also returns true right after a clip ends
|
|
822
|
+
// (the player is paused but currentTime is non-zero), which incorrectly skipped suppression
|
|
823
|
+
// for the next segment. The Soft Split case (one audio file, multiple highlighted sentences)
|
|
824
|
+
// still works because the player is neither paused nor ended while playing.
|
|
825
|
+
const isAlreadyPlaying = !mediaPlayer.paused && !mediaPlayer.ended;
|
|
800
826
|
// If it's already playing, no need to disable (Especially in the Soft Split case, where only one file is playing but multiple sentences need to be highlighted).
|
|
801
827
|
if (!isAlreadyPlaying) {
|
|
802
828
|
// Start off in a highlight-disabled state so we don't display any momentary highlight for cases where there is no audio for this element.
|
|
@@ -805,13 +831,36 @@ function setHighlightTo({
|
|
|
805
831
|
newElement.classList.add(kSuppressHighlightClass);
|
|
806
832
|
// When it starts playing, we know we really have such an audio file, so we can stop
|
|
807
833
|
// suppressing the highlight.
|
|
808
|
-
|
|
834
|
+
// Remove any previous suppress listeners before adding new ones so they don't
|
|
835
|
+
// accumulate across segments (we keep explicit references instead of { once: true }
|
|
836
|
+
// because { once: true } on the error listener broke auto-advance).
|
|
837
|
+
if (currentSuppressPlayingListener) {
|
|
838
|
+
mediaPlayer.removeEventListener(
|
|
839
|
+
"playing",
|
|
840
|
+
currentSuppressPlayingListener,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
if (currentSuppressErrorListener) {
|
|
844
|
+
mediaPlayer.removeEventListener(
|
|
845
|
+
"error",
|
|
846
|
+
currentSuppressErrorListener,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
currentSuppressPlayingListener = () => {
|
|
809
850
|
newElement.classList.remove(kSuppressHighlightClass);
|
|
810
|
-
}
|
|
811
|
-
|
|
851
|
+
};
|
|
852
|
+
currentSuppressErrorListener = () => {
|
|
812
853
|
newElement.classList.remove("ui-audioCurrent");
|
|
813
854
|
newElement.classList.remove(kSuppressHighlightClass);
|
|
814
|
-
}
|
|
855
|
+
};
|
|
856
|
+
mediaPlayer.addEventListener(
|
|
857
|
+
"playing",
|
|
858
|
+
currentSuppressPlayingListener,
|
|
859
|
+
);
|
|
860
|
+
mediaPlayer.addEventListener(
|
|
861
|
+
"error",
|
|
862
|
+
currentSuppressErrorListener,
|
|
863
|
+
);
|
|
815
864
|
}
|
|
816
865
|
}
|
|
817
866
|
|
|
@@ -957,6 +1006,55 @@ function setCurrentAudioId(id: string) {
|
|
|
957
1006
|
}
|
|
958
1007
|
}
|
|
959
1008
|
|
|
1009
|
+
// When we know which audio element comes next, we preload it on a hidden audio element so the
|
|
1010
|
+
// browser starts downloading before we actually need to play it. We generate the URL once (with
|
|
1011
|
+
// a fixed timestamp) and store it; updatePlayerStatus reuses that same URL so the browser can
|
|
1012
|
+
// serve the response from its cache rather than fetching again.
|
|
1013
|
+
let preloadedAudioId: string = "";
|
|
1014
|
+
let preloadedAudioSrc: string = "";
|
|
1015
|
+
|
|
1016
|
+
// Suppress-highlight listeners attached to the shared media player. We keep explicit references
|
|
1017
|
+
// so we can remove them before attaching new ones for the next segment — ensuring at most one of
|
|
1018
|
+
// each exists at any time without relying on { once: true }, which caused auto-advance to break.
|
|
1019
|
+
let currentSuppressPlayingListener: EventListener | null = null;
|
|
1020
|
+
let currentSuppressErrorListener: EventListener | null = null;
|
|
1021
|
+
|
|
1022
|
+
function getPreloadPlayer(): HTMLAudioElement {
|
|
1023
|
+
let el = document.getElementById(
|
|
1024
|
+
"bloom-audio-preload",
|
|
1025
|
+
) as HTMLAudioElement | null;
|
|
1026
|
+
if (!el) {
|
|
1027
|
+
el = document.createElement("audio");
|
|
1028
|
+
el.id = "bloom-audio-preload";
|
|
1029
|
+
document.body.appendChild(el);
|
|
1030
|
+
}
|
|
1031
|
+
return el;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function preloadAudioForElement(element: Element): void {
|
|
1035
|
+
const firstAudioSentence = getFirstAudioSentenceWithinElement(element);
|
|
1036
|
+
const id = firstAudioSentence ? firstAudioSentence.id : element.id;
|
|
1037
|
+
if (preloadedAudioId === id) return; // already preloading this one
|
|
1038
|
+
|
|
1039
|
+
const url =
|
|
1040
|
+
currentAudioUrl(id) +
|
|
1041
|
+
"?nocache=" +
|
|
1042
|
+
new Date().getTime() +
|
|
1043
|
+
"&optional=true";
|
|
1044
|
+
preloadedAudioId = id;
|
|
1045
|
+
preloadedAudioSrc = url;
|
|
1046
|
+
|
|
1047
|
+
const preloadPlayer = getPreloadPlayer();
|
|
1048
|
+
preloadPlayer.src = url;
|
|
1049
|
+
// load() tells the browser to actively fetch the resource rather than waiting.
|
|
1050
|
+
preloadPlayer.load();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function clearPreloadedAudio(): void {
|
|
1054
|
+
preloadedAudioId = "";
|
|
1055
|
+
preloadedAudioSrc = "";
|
|
1056
|
+
}
|
|
1057
|
+
|
|
960
1058
|
function updatePlayerStatus() {
|
|
961
1059
|
const player = getPlayer();
|
|
962
1060
|
if (!player) {
|
|
@@ -970,15 +1068,21 @@ function updatePlayerStatus() {
|
|
|
970
1068
|
}
|
|
971
1069
|
const url = currentAudioUrl(currentAudioId);
|
|
972
1070
|
logNarration(url);
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1071
|
+
// If we preloaded this audio segment, reuse the same URL so the browser can serve the
|
|
1072
|
+
// already-downloaded response from its cache instead of issuing a new request.
|
|
1073
|
+
let src: string;
|
|
1074
|
+
if (preloadedAudioId === currentAudioId && preloadedAudioSrc) {
|
|
1075
|
+
src = preloadedAudioSrc;
|
|
1076
|
+
clearPreloadedAudio();
|
|
1077
|
+
} else {
|
|
1078
|
+
// because this code is meant to work in both Bloom and BloomPlayer, we can't call a Bloom API to find
|
|
1079
|
+
// out whether we actually have a recording (as we well might not, if we just opened the talking book
|
|
1080
|
+
// tool and haven't recorded anything yet). So we just try to play it and see what happens.
|
|
1081
|
+
// The optional param tells Bloom not to report an error if the file isn't found, and is ignored in
|
|
1082
|
+
// other contexts.
|
|
1083
|
+
src = url + "?nocache=" + new Date().getTime() + "&optional=true";
|
|
1084
|
+
}
|
|
1085
|
+
player.setAttribute("src", src);
|
|
982
1086
|
}
|
|
983
1087
|
|
|
984
1088
|
function currentAudioUrl(id: string): string {
|
|
@@ -1454,6 +1558,74 @@ function playAllVideoInternal(
|
|
|
1454
1558
|
hideVideoError(video);
|
|
1455
1559
|
hideVideoAutoplayBlockedHint(video);
|
|
1456
1560
|
setCurrentPlaybackMode(PlaybackMode.VideoPlaying);
|
|
1561
|
+
// Always play each queued video from the beginning.
|
|
1562
|
+
// Without this, a previously played element may remain at end-of-stream
|
|
1563
|
+
// and fail to raise the expected ended event for sequencing.
|
|
1564
|
+
video.currentTime = 0;
|
|
1565
|
+
// Note that we get a new instance of this for each recursive call to playAllVideoInternal.
|
|
1566
|
+
// It serves to make sure we don't try to advance twice if we get both an ended and a pause event.
|
|
1567
|
+
let advanced = false;
|
|
1568
|
+
let watchdogTimerId: number | undefined;
|
|
1569
|
+
const watchdogGraceMs = 250;
|
|
1570
|
+
const clearWatchdog = () => {
|
|
1571
|
+
if (watchdogTimerId !== undefined) {
|
|
1572
|
+
window.clearTimeout(watchdogTimerId);
|
|
1573
|
+
watchdogTimerId = undefined;
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
const scheduleWatchdogFromDuration = () => {
|
|
1577
|
+
const duration = video.duration;
|
|
1578
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
clearWatchdog();
|
|
1582
|
+
const playbackRate =
|
|
1583
|
+
Number.isFinite(video.playbackRate) && video.playbackRate > 0
|
|
1584
|
+
? video.playbackRate
|
|
1585
|
+
: 1;
|
|
1586
|
+
const timeoutMs = Math.max(
|
|
1587
|
+
duration * 1000 / playbackRate + watchdogGraceMs,
|
|
1588
|
+
watchdogGraceMs,
|
|
1589
|
+
);
|
|
1590
|
+
watchdogTimerId = window.setTimeout(() => {
|
|
1591
|
+
advanceToNextVideo();
|
|
1592
|
+
}, timeoutMs);
|
|
1593
|
+
};
|
|
1594
|
+
const advanceToNextVideo = () => {
|
|
1595
|
+
if (advanced) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
advanced = true;
|
|
1599
|
+
clearWatchdog();
|
|
1600
|
+
video.removeEventListener("ended", endedHandler);
|
|
1601
|
+
video.removeEventListener("pause", pauseHandler);
|
|
1602
|
+
video.removeEventListener("loadedmetadata", metadataHandler);
|
|
1603
|
+
if (generation !== playAllVideoGeneration) {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
playAllVideoInternal(elements.slice(1), then, generation);
|
|
1607
|
+
};
|
|
1608
|
+
const endedHandler = () => {
|
|
1609
|
+
advanceToNextVideo();
|
|
1610
|
+
};
|
|
1611
|
+
// In some environments, playback can pause at the end without raising ended.
|
|
1612
|
+
// Treat that as completion so a short first video can't stall the sequence.
|
|
1613
|
+
const pauseHandler = () => {
|
|
1614
|
+
const duration = video.duration;
|
|
1615
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (video.currentTime >= duration - 0.05) {
|
|
1619
|
+
advanceToNextVideo();
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
const metadataHandler = () => {
|
|
1623
|
+
scheduleWatchdogFromDuration();
|
|
1624
|
+
};
|
|
1625
|
+
// Register ended before play() so we don't miss extremely fast end transitions.
|
|
1626
|
+
video.addEventListener("ended", endedHandler, { once: true });
|
|
1627
|
+
video.addEventListener("pause", pauseHandler);
|
|
1628
|
+
video.addEventListener("loadedmetadata", metadataHandler);
|
|
1457
1629
|
const promise = video.play();
|
|
1458
1630
|
promise
|
|
1459
1631
|
.then(() => {
|
|
@@ -1462,34 +1634,16 @@ function playAllVideoInternal(
|
|
|
1462
1634
|
}
|
|
1463
1635
|
transientVideoRetryCounts.delete(video);
|
|
1464
1636
|
hideVideoAutoplayBlockedHint(video);
|
|
1465
|
-
|
|
1466
|
-
// Note: in Bloom Desktop, sometimes this event does not fire normally, even when the video is
|
|
1467
|
-
// played to the end. I have not figured out why. It may be something to do with how we are
|
|
1468
|
-
// trimming the videos.
|
|
1469
|
-
// In Bloom Desktop, this is worked around by raising the ended event when we detect that it has
|
|
1470
|
-
// paused past the end point in resetToStartAfterPlayingToEndPoint.
|
|
1471
|
-
// In BloomPlayer,I don't think this is a problem. Videos are trimmed when published, so we always
|
|
1472
|
-
// play to the real end (unless the user pauses). So one way or another, we should get the ended
|
|
1473
|
-
// event.
|
|
1474
|
-
video.addEventListener(
|
|
1475
|
-
"ended",
|
|
1476
|
-
() => {
|
|
1477
|
-
if (generation !== playAllVideoGeneration) {
|
|
1478
|
-
return;
|
|
1479
|
-
}
|
|
1480
|
-
playAllVideoInternal(
|
|
1481
|
-
elements.slice(1),
|
|
1482
|
-
then,
|
|
1483
|
-
generation,
|
|
1484
|
-
);
|
|
1485
|
-
},
|
|
1486
|
-
{ once: true },
|
|
1487
|
-
);
|
|
1637
|
+
scheduleWatchdogFromDuration();
|
|
1488
1638
|
})
|
|
1489
1639
|
.catch((reason) => {
|
|
1490
1640
|
if (generation !== playAllVideoGeneration) {
|
|
1491
1641
|
return;
|
|
1492
1642
|
}
|
|
1643
|
+
clearWatchdog();
|
|
1644
|
+
video.removeEventListener("ended", endedHandler);
|
|
1645
|
+
video.removeEventListener("pause", pauseHandler);
|
|
1646
|
+
video.removeEventListener("loadedmetadata", metadataHandler);
|
|
1493
1647
|
if (reason?.name === "NotAllowedError") {
|
|
1494
1648
|
console.debug(
|
|
1495
1649
|
"Video autoplay blocked until user interaction",
|