animot-presenter 0.5.15 → 0.5.17

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.
@@ -155,7 +155,7 @@
155
155
  let {
156
156
  src, data, autoplay = false, loop = false, controls = true, arrows = false,
157
157
  progress: showProgress = true, keyboard = true, duration: durationOverride,
158
- startSlide = 0, class: className = '', onslidechange, oncomplete
158
+ startSlide = 0, muteNarration = false, class: className = '', onslidechange, oncomplete
159
159
  }: AnimotPresenterProps = $props();
160
160
 
161
161
  // State
@@ -190,13 +190,16 @@
190
190
  // Single <audio> element for per-slide narration playback. Only used
191
191
  // when `project.settings.narrationEnabled` is true. Lazy-allocated so
192
192
  // decks without narration don't pay for it.
193
+ //
194
+ // Narration is bound to the deck's PLAY state (`isAutoplay`), not to
195
+ // arbitrary page clicks: the user pressing the play button is what
196
+ // starts narration, the pause button stops it. This keeps multiple
197
+ // decks on a page from playing each other's audio when one is clicked,
198
+ // and keeps autoplay-blocked browsers from silently doing nothing —
199
+ // the user's click on the play control is itself the unlocking gesture.
193
200
  let narrationAudio: HTMLAudioElement | null = null;
194
- // Set when the most recent play() was rejected by the browser autoplay
195
- // policy. The first user gesture clears it and replays the current
196
- // slide's clip — without this a single-slide deck would never play
197
- // narration, since there's no navigation event to retry on.
198
- let narrationPendingGesture = false;
199
201
  function playNarrationForSlide(index: number) {
202
+ if (muteNarration) return;
200
203
  if (!project?.settings?.narrationEnabled) return;
201
204
  const slide = project?.slides?.[index];
202
205
  if (narrationAudio) {
@@ -207,22 +210,13 @@
207
210
  if (!src) return;
208
211
  if (!narrationAudio) narrationAudio = new Audio();
209
212
  narrationAudio.src = src;
210
- // First play() may reject under autoplay policy. We track the
211
- // rejection so a subsequent user gesture (handled below) retries
212
- // once instead of silently giving up.
213
- narrationAudio.play().then(
214
- () => { narrationPendingGesture = false; },
215
- () => { narrationPendingGesture = true; }
216
- );
213
+ narrationAudio.play().catch(() => {});
217
214
  }
218
- function tryUnlockNarrationOnGesture() {
219
- if (narrationPendingGesture) {
220
- playNarrationForSlide(currentSlideIndex);
221
- }
215
+ function pauseNarration() {
216
+ if (narrationAudio) narrationAudio.pause();
222
217
  }
223
218
  function stopNarration() {
224
219
  if (narrationAudio) { narrationAudio.pause(); narrationAudio = null; }
225
- narrationPendingGesture = false;
226
220
  }
227
221
 
228
222
  const slides = $derived(project?.slides ?? []);
@@ -565,6 +559,12 @@
565
559
  currentSlideIndex = 0;
566
560
  isTransitioning = false;
567
561
 
562
+ // Restart narration on loop. Setting `currentSlideIndex = 0` above
563
+ // is a no-op for single-slide decks (was already 0) so the play-state
564
+ // effect doesn't re-fire on its own. Calling explicitly here covers
565
+ // both single- and multi-slide loops uniformly.
566
+ if (isAutoplay) playNarrationForSlide(0);
567
+
568
568
  for (const element of firstSlide.canvas.elements) {
569
569
  if (element.type === 'text') {
570
570
  const textEl = element as TextElement;
@@ -585,12 +585,10 @@
585
585
  const targetSlide = slides[targetIndex];
586
586
  clearAllTypewriterAnimations();
587
587
  cancelMotionPathLoops();
588
- // Trigger per-slide narration. No-op when `narrationEnabled` is off
589
- // or the slide has no narration clip. First call may be blocked by
590
- // the browser's autoplay policy; the deck consumer should suppress
591
- // `autoplay` when narration is on so the viewer's first nav-click
592
- // (which gets us here) acts as the unlocking gesture.
593
- playNarrationForSlide(targetIndex);
588
+ // Trigger per-slide narration only while the deck is actively
589
+ // playing. Manual nav while paused stays silent narration is
590
+ // bound to the play button, not to every interaction.
591
+ if (isAutoplay) playNarrationForSlide(targetIndex);
594
592
  const transition = targetSlide.transition;
595
593
  const duration = durationOverride ?? transition.duration;
596
594
  transitionDurationMs = duration;
@@ -1150,11 +1148,9 @@
1150
1148
  await loadCodeHighlights();
1151
1149
  loading = false;
1152
1150
  if (currentSlide) setTimeout(() => animateMotionPaths(currentSlide!), 300);
1153
- // First-slide narration. Browsers may block this until the
1154
- // viewer interacts; downstream code (e.g. share-link page)
1155
- // should already disable autoplay when narration is on so the
1156
- // click that starts the deck doubles as the audio unlock.
1157
- playNarrationForSlide(currentSlideIndex);
1151
+ // Narration starts via the play-state effect below — not on
1152
+ // mount. That way the user's click on Play is the gesture
1153
+ // that unlocks audio, and a paused deck stays silent.
1158
1154
  if (autoplay) isAutoplay = true;
1159
1155
  } catch (e: any) { error = e.message; loading = false; }
1160
1156
  }
@@ -1173,25 +1169,29 @@
1173
1169
  if (containerEl) resizeObserver.observe(containerEl);
1174
1170
  resetMouseIdleTimer();
1175
1171
 
1176
- // First-gesture retry for narration playback. Browsers block
1177
- // audio.play() until the user has interacted; without this hook
1178
- // a single-slide deck would never play its narration since there's
1179
- // no slide change to retry on. Attached on document so any click
1180
- // or key press in the page acts as the unlocking gesture.
1181
- document.addEventListener('pointerdown', tryUnlockNarrationOnGesture, { capture: true });
1182
- document.addEventListener('keydown', tryUnlockNarrationOnGesture, { capture: true });
1183
-
1184
1172
  return () => {
1185
1173
  resizeObserver?.disconnect();
1186
1174
  clearAutoplayTimer();
1187
1175
  clearAllTypewriterAnimations();
1188
1176
  if (mouseIdleTimer) clearTimeout(mouseIdleTimer);
1189
1177
  stopNarration();
1190
- document.removeEventListener('pointerdown', tryUnlockNarrationOnGesture, { capture: true });
1191
- document.removeEventListener('keydown', tryUnlockNarrationOnGesture, { capture: true });
1192
1178
  };
1193
1179
  });
1194
1180
 
1181
+ // Narration follows the deck's play/pause state. The play button click
1182
+ // flips `isAutoplay` true → this effect fires → audio starts. The
1183
+ // click itself is the user gesture that unlocks the browser audio
1184
+ // context. Pause/stop turns it back off. Each presenter instance
1185
+ // scopes its own audio, so multiple decks on a page never overlap.
1186
+ $effect(() => {
1187
+ if (!project?.settings?.narrationEnabled) return;
1188
+ if (isAutoplay) {
1189
+ playNarrationForSlide(currentSlideIndex);
1190
+ } else {
1191
+ pauseNarration();
1192
+ }
1193
+ });
1194
+
1195
1195
  // Watch for prop changes
1196
1196
  $effect(() => { if (data) { project = data; } });
1197
1197
  </script>