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/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.2",
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
- function showCorrectOrWrongItems(page: HTMLElement, correct: boolean) {
852
- classSetter(page, "drag-activity-correct", correct);
853
- classSetter(page, "drag-activity-wrong", !correct);
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
- // play sound
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
- // if the attribute is not there at all, use the default sound, if one has been set.
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 audio = new Audio(
898
- (addPrefix ? urlPrefix() + "/audio/" : "") + soundFile,
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();
@@ -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
- const isAlreadyPlaying = mediaPlayer.currentTime > 0;
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
- mediaPlayer.addEventListener("playing", () => {
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
- mediaPlayer.addEventListener("error", () => {
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
- // because this code is meant to work in both Bloom and BloomPlayer, we can't call a Bloom API to find
974
- // out whether we actually have a recording (as we well might not, if we just opened the talking book
975
- // tool and haven't recorded anything yet). So we just try to play it and see what happens.
976
- // The optional param tells Bloom not to report an error if the file isn't found, and is ignored in
977
- // other contexts.
978
- player.setAttribute(
979
- "src",
980
- url + "?nocache=" + new Date().getTime() + "&optional=true",
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
- // The promise resolves when the video starts playing. We want to know when it ends.
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",