)}
- {/* Speaker notes panel */}
-
+ {/* Speaker notes panel - hide when popped out */}
+
+
+ {/* Speaker notes popout window manager */}
+
);
}
diff --git a/website/src/components/SlideDeck/SlideDeckContext.tsx b/website/src/components/SlideDeck/SlideDeckContext.tsx
index b62c0ac3d0..cfe7153180 100644
--- a/website/src/components/SlideDeck/SlideDeckContext.tsx
+++ b/website/src/components/SlideDeck/SlideDeckContext.tsx
@@ -1,23 +1,83 @@
-import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useMemo } from 'react';
-import type { SlideDeckContextValue } from './types';
+import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useMemo, useRef } from 'react';
+import type { SlideDeckContextValue, NotesPreferences, NotesPosition, NotesDisplayMode } from './types';
const SlideDeckContext = createContext
(null);
+// localStorage key for notes preferences.
+const NOTES_PREFS_KEY = 'slide-deck-notes-preferences';
+
+// Default notes preferences.
+const defaultNotesPreferences: NotesPreferences = {
+ position: 'right',
+ displayMode: 'overlay',
+ isPopout: false,
+};
+
+// Load preferences from localStorage.
+const loadNotesPreferences = (): NotesPreferences => {
+ if (typeof window === 'undefined') return defaultNotesPreferences;
+ try {
+ const stored = localStorage.getItem(NOTES_PREFS_KEY);
+ if (stored) {
+ return { ...defaultNotesPreferences, ...JSON.parse(stored) };
+ }
+ } catch (e) {
+ console.error('Failed to load notes preferences:', e);
+ }
+ return defaultNotesPreferences;
+};
+
+// Save preferences to localStorage.
+const saveNotesPreferences = (prefs: NotesPreferences) => {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(NOTES_PREFS_KEY, JSON.stringify(prefs));
+ } catch (e) {
+ console.error('Failed to save notes preferences:', e);
+ }
+};
+
interface SlideDeckProviderProps {
children: ReactNode;
totalSlides: number;
startSlide?: number;
}
+// Check if device is mobile/tablet (touch device or small screen).
+const isMobileDevice = () => {
+ if (typeof window === 'undefined') return false;
+ // Check for touch capability or small screen.
+ const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
+ const isSmallScreen = window.innerWidth <= 1024;
+ return hasTouch && isSmallScreen;
+};
+
export function SlideDeckProvider({
children,
totalSlides,
startSlide = 1
}: SlideDeckProviderProps) {
const [currentSlide, setCurrentSlide] = useState(startSlide);
+ // Initialize fullscreen to false to avoid hydration mismatch (server always renders false).
const [isFullscreen, setIsFullscreen] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [currentNotes, setCurrentNotes] = useState(null);
+ const [isMobile, setIsMobile] = useState(false);
+ const [notesPreferences, setNotesPreferences] = useState(defaultNotesPreferences);
+
+ // Ref to track current fullscreen state for resize handler (avoids stale closure).
+ const isFullscreenRef = useRef(isFullscreen);
+ isFullscreenRef.current = isFullscreen;
+
+ // Load notes preferences and set mobile/fullscreen state after mount (client-side only).
+ useEffect(() => {
+ setNotesPreferences(loadNotesPreferences());
+ // Auto-enter fullscreen on mobile after hydration.
+ if (isMobileDevice()) {
+ setIsMobile(true);
+ setIsFullscreen(true);
+ }
+ }, []);
// Sync with URL hash on mount.
useEffect(() => {
@@ -56,14 +116,36 @@ export function SlideDeckProvider({
return () => window.removeEventListener('hashchange', handleHashChange);
}, [totalSlides]);
- // Handle fullscreen change events.
+ // Handle fullscreen change events and mobile detection.
useEffect(() => {
const handleFullscreenChange = () => {
- setIsFullscreen(!!document.fullscreenElement);
+ // If native fullscreen changed, sync state.
+ // But keep fullscreen on if we're on mobile.
+ if (document.fullscreenElement) {
+ setIsFullscreen(true);
+ } else if (!isMobileDevice()) {
+ setIsFullscreen(false);
+ }
+ };
+
+ const handleResize = () => {
+ // Auto-enter fullscreen mode on mobile, exit on desktop (unless native fullscreen).
+ // Use ref to get current fullscreen state (avoids stale closure).
+ const mobile = isMobileDevice();
+ setIsMobile(mobile);
+ if (mobile && !isFullscreenRef.current) {
+ setIsFullscreen(true);
+ } else if (!mobile && !document.fullscreenElement && isFullscreenRef.current) {
+ setIsFullscreen(false);
+ }
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
- return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
+ window.addEventListener('resize', handleResize);
+ return () => {
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
+ window.removeEventListener('resize', handleResize);
+ };
}, []);
const goToSlide = useCallback((index: number) => {
@@ -99,6 +181,30 @@ export function SlideDeckProvider({
setShowNotes(prev => !prev);
}, []);
+ const setNotesPosition = useCallback((position: NotesPosition) => {
+ setNotesPreferences(prev => {
+ const updated = { ...prev, position };
+ saveNotesPreferences(updated);
+ return updated;
+ });
+ }, []);
+
+ const setNotesDisplayMode = useCallback((displayMode: NotesDisplayMode) => {
+ setNotesPreferences(prev => {
+ const updated = { ...prev, displayMode };
+ saveNotesPreferences(updated);
+ return updated;
+ });
+ }, []);
+
+ const setNotesPopout = useCallback((isPopout: boolean) => {
+ setNotesPreferences(prev => {
+ const updated = { ...prev, isPopout };
+ saveNotesPreferences(updated);
+ return updated;
+ });
+ }, []);
+
const value: SlideDeckContextValue = useMemo(() => ({
currentSlide,
totalSlides,
@@ -111,7 +217,12 @@ export function SlideDeckProvider({
toggleNotes,
currentNotes,
setCurrentNotes,
- }), [currentSlide, totalSlides, goToSlide, nextSlide, prevSlide, isFullscreen, toggleFullscreen, showNotes, toggleNotes, currentNotes]);
+ notesPreferences,
+ setNotesPosition,
+ setNotesDisplayMode,
+ setNotesPopout,
+ isMobile,
+ }), [currentSlide, totalSlides, goToSlide, nextSlide, prevSlide, isFullscreen, toggleFullscreen, showNotes, toggleNotes, currentNotes, notesPreferences, setNotesPosition, setNotesDisplayMode, setNotesPopout, isMobile]);
return (
diff --git a/website/src/components/SlideDeck/SlideNotes.css b/website/src/components/SlideDeck/SlideNotes.css
index 46142d776d..ea12eee22b 100644
--- a/website/src/components/SlideDeck/SlideNotes.css
+++ b/website/src/components/SlideDeck/SlideNotes.css
@@ -11,28 +11,61 @@
z-index: 100;
}
-/* Notes panel - slides in from right */
+/* Notes panel - base styles */
.slide-notes {
position: absolute;
+ background: var(--ifm-background-color);
+ z-index: 101;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Right position (default) - slides in from right */
+.slide-notes--right {
top: 0;
right: 0;
bottom: 0;
width: 320px;
max-width: 90vw;
- background: var(--ifm-background-color);
border-left: 1px solid rgba(0, 0, 0, 0.1);
- z-index: 101;
- display: flex;
- flex-direction: column;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15);
}
-html[data-theme='dark'] .slide-notes {
+html[data-theme='dark'] .slide-notes--right {
background: #1a1a2e;
border-left-color: rgba(255, 255, 255, 0.08);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
}
+/* Bottom position - slides up from bottom (Google Slides style) */
+.slide-notes--bottom {
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 25vh;
+ min-height: 150px;
+ max-height: 50vh;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
+}
+
+html[data-theme='dark'] .slide-notes--bottom {
+ background: #1a1a2e;
+ border-top-color: rgba(255, 255, 255, 0.08);
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
+}
+
+.slide-notes--bottom .slide-notes__header {
+ padding-left: 2rem;
+ padding-right: 1rem;
+}
+
+.slide-notes--bottom .slide-notes__content {
+ min-height: 0;
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
/* Fullscreen mode adjustments */
.slide-deck--fullscreen .slide-notes__backdrop {
position: fixed;
@@ -40,10 +73,18 @@ html[data-theme='dark'] .slide-notes {
.slide-deck--fullscreen .slide-notes {
position: fixed;
+}
+
+.slide-deck--fullscreen .slide-notes--right {
background: #1a1a2e;
border-left-color: rgba(255, 255, 255, 0.1);
}
+.slide-deck--fullscreen .slide-notes--bottom {
+ background: #1a1a2e;
+ border-top-color: rgba(255, 255, 255, 0.1);
+}
+
/* Header */
.slide-notes__header {
display: flex;
@@ -65,6 +106,43 @@ html[data-theme='dark'] .slide-notes__header,
gap: 0.5rem;
}
+.slide-notes__header-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.slide-notes__control-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ifm-color-emphasis-600);
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.slide-notes__control-btn:hover {
+ background: var(--ifm-color-emphasis-200);
+ color: var(--ifm-color-emphasis-800);
+}
+
+html[data-theme='dark'] .slide-notes__control-btn,
+.slide-deck--fullscreen .slide-notes__control-btn {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+html[data-theme='dark'] .slide-notes__control-btn:hover,
+.slide-deck--fullscreen .slide-notes__control-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+}
+
.slide-notes__icon {
font-size: 1.125rem;
color: var(--ifm-color-primary);
@@ -120,6 +198,7 @@ html[data-theme='dark'] .slide-notes__close:hover,
/* Scrollable content area */
.slide-notes__content {
flex: 1;
+ min-height: 0; /* Required for flex child to scroll */
overflow-y: auto;
padding: 1rem;
}
@@ -166,16 +245,24 @@ html[data-theme='dark'] .slide-notes__empty p,
/* Responsive adjustments */
@media screen and (max-width: 768px) {
- .slide-notes {
+ .slide-notes--right {
width: 280px;
}
+
+ .slide-notes--bottom {
+ height: 30vh;
+ }
}
@media screen and (max-width: 480px) {
- .slide-notes {
+ .slide-notes--right {
width: 100%;
max-width: 100%;
}
+
+ .slide-notes--bottom {
+ height: 35vh;
+ }
}
/* Reduced motion */
diff --git a/website/src/components/SlideDeck/SlideNotesPanel.tsx b/website/src/components/SlideDeck/SlideNotesPanel.tsx
index d08de61d88..4081e3149e 100644
--- a/website/src/components/SlideDeck/SlideNotesPanel.tsx
+++ b/website/src/components/SlideDeck/SlideNotesPanel.tsx
@@ -1,39 +1,97 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
-import { RiCloseLine, RiSpeakLine } from 'react-icons/ri';
+import {
+ RiCloseLine,
+ RiSpeakLine,
+ RiLayoutRightLine,
+ RiLayoutBottomLine,
+ RiStackLine,
+ RiSplitCellsHorizontal,
+ RiExternalLinkLine,
+} from 'react-icons/ri';
import { useSlideDeck } from './SlideDeckContext';
+import { Tooltip } from './Tooltip';
import type { SlideNotesPanelProps } from './types';
import './SlideNotes.css';
/**
* SlideNotesPanel - A slide-out panel displaying speaker notes.
*
- * Slides in from the right side of the screen when the user presses 'N'.
- * Displays the notes content registered by SlideNotes components.
+ * Supports two positions:
+ * - 'right': slides in from the right (default)
+ * - 'bottom': slides up from the bottom (Google Slides style)
+ *
+ * Supports two display modes:
+ * - 'overlay': floats on top of slides with backdrop
+ * - 'shrink': shrinks the slide area (no backdrop)
*/
export function SlideNotesPanel({ isOpen, onClose }: SlideNotesPanelProps) {
- const { currentNotes, currentSlide } = useSlideDeck();
+ const {
+ currentNotes,
+ currentSlide,
+ notesPreferences,
+ setNotesPosition,
+ setNotesDisplayMode,
+ setNotesPopout,
+ isMobile,
+ } = useSlideDeck();
+ const { position, displayMode } = notesPreferences;
+
+ // Toggle notes position between right and bottom.
+ const toggleNotesPosition = useCallback(() => {
+ setNotesPosition(position === 'right' ? 'bottom' : 'right');
+ }, [position, setNotesPosition]);
+
+ // Toggle notes display mode between overlay and shrink.
+ const toggleNotesDisplayMode = useCallback(() => {
+ setNotesDisplayMode(displayMode === 'overlay' ? 'shrink' : 'overlay');
+ }, [displayMode, setNotesDisplayMode]);
+
+ // Toggle popout mode.
+ const toggleNotesPopout = useCallback(() => {
+ setNotesPopout(true);
+ }, [setNotesPopout]);
+
+ // Animation variants based on position.
+ const panelVariants = {
+ right: {
+ initial: { x: '100%' },
+ animate: { x: 0 },
+ exit: { x: '100%' },
+ },
+ bottom: {
+ initial: { y: '100%' },
+ animate: { y: 0 },
+ exit: { y: '100%' },
+ },
+ };
+
+ const variant = panelVariants[position];
+ const showBackdrop = displayMode === 'overlay';
+ const panelClassName = `slide-notes slide-notes--${position}`;
return (
{isOpen && (
<>
- {/* Backdrop */}
-
+ {/* Backdrop - only shown in overlay mode */}
+ {showBackdrop && (
+
+ )}
{/* Notes panel */}
@@ -41,13 +99,51 @@ export function SlideNotesPanel({ isOpen, onClose }: SlideNotesPanelProps) {
Speaker Notes
-
+
+ {/* Position toggle */}
+
+
+
+
+ {/* Display mode toggle */}
+
+
+
+
+ {/* Popout button - desktop only */}
+ {!isMobile && (
+
+
+
+ )}
+
+ {/* Close button */}
+
+
diff --git a/website/src/components/SlideDeck/SlideNotesPopout.tsx b/website/src/components/SlideDeck/SlideNotesPopout.tsx
new file mode 100644
index 0000000000..d1c7326f8b
--- /dev/null
+++ b/website/src/components/SlideDeck/SlideNotesPopout.tsx
@@ -0,0 +1,345 @@
+import { useEffect, useRef } from 'react';
+import { useSlideDeck } from './SlideDeckContext';
+
+// Channel name for cross-window communication.
+const CHANNEL_NAME = 'slide-deck-notes-sync';
+
+// Message types for BroadcastChannel.
+interface SyncMessage {
+ type: 'slide-change' | 'notes-update' | 'close-popout' | 'navigate';
+ slide?: number;
+ notes?: string;
+ direction?: 'next' | 'prev';
+}
+
+/**
+ * SlideNotesPopout - Manages a separate browser window for speaker notes.
+ *
+ * Uses BroadcastChannel API for cross-window communication.
+ * Shows current slide notes with navigation controls.
+ */
+export function SlideNotesPopout() {
+ const {
+ currentSlide,
+ totalSlides,
+ currentNotes,
+ nextSlide,
+ prevSlide,
+ notesPreferences,
+ setNotesPopout,
+ } = useSlideDeck();
+
+ const popoutWindowRef = useRef
(null);
+ const channelRef = useRef(null);
+
+ // Refs to track current values for use in popout initialization.
+ const currentSlideRef = useRef(currentSlide);
+ const totalSlidesRef = useRef(totalSlides);
+ const currentNotesRef = useRef(currentNotes);
+
+ // Keep refs in sync with state.
+ currentSlideRef.current = currentSlide;
+ totalSlidesRef.current = totalSlides;
+ currentNotesRef.current = currentNotes;
+
+ // Helper function to update popout content (used by both init and update effects).
+ const updatePopoutContent = (popout: Window) => {
+ const slideNumEl = popout.document.getElementById('slide-num');
+ const notesContentEl = popout.document.getElementById('notes-content');
+ const prevBtn = popout.document.getElementById('prev-btn') as HTMLButtonElement;
+ const nextBtn = popout.document.getElementById('next-btn') as HTMLButtonElement;
+
+ const slide = currentSlideRef.current;
+ const total = totalSlidesRef.current;
+ const notes = currentNotesRef.current;
+
+ if (slideNumEl) {
+ slideNumEl.textContent = `Slide ${slide} / ${total}`;
+ }
+
+ if (prevBtn) {
+ prevBtn.disabled = slide === 1;
+ }
+
+ if (nextBtn) {
+ nextBtn.disabled = slide === total;
+ }
+
+ if (notesContentEl) {
+ if (notes) {
+ // Convert React node to string if possible.
+ // Use textContent for safety to prevent XSS.
+ const notesText = typeof notes === 'string'
+ ? notes
+ : (notes as React.ReactElement)?.props?.children || 'Notes available';
+ // Clear existing content and create new div safely.
+ notesContentEl.textContent = '';
+ const div = popout.document.createElement('div');
+ div.textContent = typeof notesText === 'string' ? notesText : String(notesText);
+ notesContentEl.appendChild(div);
+ } else {
+ // Clear and create empty state safely.
+ notesContentEl.textContent = '';
+ const empty = popout.document.createElement('div');
+ empty.className = 'empty';
+ empty.textContent = 'No notes for this slide.';
+ notesContentEl.appendChild(empty);
+ }
+ }
+ };
+
+ // Initialize BroadcastChannel for cross-window sync.
+ useEffect(() => {
+ if (typeof BroadcastChannel !== 'undefined') {
+ channelRef.current = new BroadcastChannel(CHANNEL_NAME);
+
+ // Listen for messages from the popout window.
+ channelRef.current.onmessage = (event: MessageEvent) => {
+ const { type, direction } = event.data;
+ if (type === 'navigate') {
+ if (direction === 'next') {
+ nextSlide();
+ } else if (direction === 'prev') {
+ prevSlide();
+ }
+ } else if (type === 'close-popout') {
+ setNotesPopout(false);
+ }
+ };
+ }
+
+ return () => {
+ channelRef.current?.close();
+ };
+ }, [nextSlide, prevSlide, setNotesPopout]);
+
+ // Send slide updates to popout window.
+ useEffect(() => {
+ if (channelRef.current && notesPreferences.isPopout) {
+ const message: SyncMessage = {
+ type: 'slide-change',
+ slide: currentSlide,
+ };
+ channelRef.current.postMessage(message);
+ }
+ }, [currentSlide, notesPreferences.isPopout]);
+
+ // Open popout window when isPopout becomes true.
+ // Only depends on isPopout - content updates happen in a separate effect.
+ useEffect(() => {
+ if (!notesPreferences.isPopout) {
+ // Close the popout window if it exists.
+ if (popoutWindowRef.current && !popoutWindowRef.current.closed) {
+ popoutWindowRef.current.close();
+ }
+ popoutWindowRef.current = null;
+ return;
+ }
+
+ // Open the popout window.
+ // Note: Some browsers (like Arc) may open this as a tab instead of a popup.
+ // Adding popup=yes helps signal intent but behavior varies by browser.
+ const width = 400;
+ const height = 500;
+ const left = window.screenX + window.outerWidth - width - 50;
+ const top = window.screenY + 50;
+
+ const popout = window.open(
+ '',
+ 'SlideNotesPopout',
+ `popup=yes,width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`
+ );
+
+ if (!popout) {
+ console.error('Failed to open popout window - popup may be blocked');
+ setNotesPopout(false);
+ return;
+ }
+
+ popoutWindowRef.current = popout;
+
+ // Set up the popout window content.
+ popout.document.title = 'Speaker Notes';
+
+ // Write initial HTML structure (content will be updated by separate effect).
+ popout.document.write(`
+
+
+
+ Speaker Notes
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+ popout.document.close();
+
+ // Immediately update with current slide state (avoids "Loading..." flash).
+ // Use setTimeout to ensure DOM is ready after document.close().
+ setTimeout(() => {
+ if (popout && !popout.closed) {
+ updatePopoutContent(popout);
+ }
+ }, 0);
+
+ // Handle popout window being closed by user.
+ const checkClosed = setInterval(() => {
+ if (popout.closed) {
+ clearInterval(checkClosed);
+ setNotesPopout(false);
+ }
+ }, 500);
+
+ return () => {
+ clearInterval(checkClosed);
+ };
+ }, [notesPreferences.isPopout, setNotesPopout]);
+
+ // Update the popout window content when notes change.
+ useEffect(() => {
+ if (!notesPreferences.isPopout || !popoutWindowRef.current || popoutWindowRef.current.closed) {
+ return;
+ }
+
+ updatePopoutContent(popoutWindowRef.current);
+ }, [currentSlide, totalSlides, currentNotes, notesPreferences.isPopout]);
+
+ // This component doesn't render anything in the main window.
+ return null;
+}
+
+export default SlideNotesPopout;
diff --git a/website/src/components/SlideDeck/TTSPlayer.css b/website/src/components/SlideDeck/TTSPlayer.css
new file mode 100644
index 0000000000..9583158899
--- /dev/null
+++ b/website/src/components/SlideDeck/TTSPlayer.css
@@ -0,0 +1,200 @@
+.tts-player {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--ifm-background-surface-color);
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+}
+
+html[data-theme='dark'] .tts-player,
+.slide-deck--fullscreen .tts-player {
+ background: rgba(26, 26, 46, 0.95);
+ border-top-color: rgba(255, 255, 255, 0.1);
+}
+
+.tts-player__btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ background: var(--ifm-color-emphasis-200);
+ color: var(--ifm-font-color-base);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ flex-shrink: 0;
+}
+
+.tts-player__btn:hover:not(:disabled) {
+ background: var(--ifm-color-primary);
+ color: white;
+}
+
+.tts-player__btn--play {
+ width: 40px;
+ height: 40px;
+ background: var(--ifm-color-primary);
+ color: white;
+}
+
+.tts-player__btn--play:hover:not(:disabled) {
+ background: var(--ifm-color-primary-dark);
+}
+
+.tts-player__btn:disabled {
+ opacity: 0.5;
+ cursor: wait;
+}
+
+.tts-player__btn--muted {
+ color: var(--ifm-color-danger);
+}
+
+.tts-player__btn--muted:hover:not(:disabled) {
+ background: var(--ifm-color-danger);
+ color: white;
+}
+
+html[data-theme='dark'] .tts-player__btn,
+.slide-deck--fullscreen .tts-player__btn {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.9);
+}
+
+html[data-theme='dark'] .tts-player__btn:hover:not(:disabled),
+.slide-deck--fullscreen .tts-player__btn:hover:not(:disabled) {
+ background: var(--ifm-color-primary);
+ color: white;
+}
+
+html[data-theme='dark'] .tts-player__btn--play,
+.slide-deck--fullscreen .tts-player__btn--play {
+ background: var(--ifm-color-primary);
+ color: white;
+}
+
+.tts-player__spin {
+ animation: tts-spin 1s linear infinite;
+}
+
+@keyframes tts-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.tts-player__progress {
+ flex: 1;
+ height: 6px;
+ min-width: 80px;
+ background: var(--ifm-color-emphasis-200);
+ border-radius: 3px;
+ cursor: pointer;
+ overflow: hidden;
+ position: relative;
+}
+
+html[data-theme='dark'] .tts-player__progress,
+.slide-deck--fullscreen .tts-player__progress {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.tts-player__progress:hover {
+ height: 8px;
+}
+
+.tts-player__progress-fill {
+ height: 100%;
+ background: var(--ifm-color-primary);
+ transition: width 0.1s linear;
+ border-radius: 3px;
+}
+
+.tts-player__time {
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--ifm-color-emphasis-600);
+ min-width: 70px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+html[data-theme='dark'] .tts-player__time,
+.slide-deck--fullscreen .tts-player__time {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.tts-player__select {
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 4px;
+ background: var(--ifm-background-color);
+ color: var(--ifm-font-color-base);
+ font-size: 0.75rem;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.tts-player__select:focus {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 1px;
+}
+
+html[data-theme='dark'] .tts-player__select,
+.slide-deck--fullscreen .tts-player__select {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: white;
+}
+
+.tts-player__error {
+ color: var(--ifm-color-danger);
+ font-size: 0.75rem;
+ flex-shrink: 0;
+}
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .tts-player {
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ padding: 0.375rem 0.5rem;
+ }
+
+ .tts-player__btn--play {
+ width: 36px;
+ height: 36px;
+ }
+
+ .tts-player__btn {
+ width: 28px;
+ height: 28px;
+ }
+
+ .tts-player__progress {
+ order: 5;
+ flex-basis: 100%;
+ margin: 0.25rem 0;
+ }
+
+ .tts-player__time {
+ order: 6;
+ flex-basis: 100%;
+ text-align: center;
+ min-width: auto;
+ }
+
+ .tts-player__select {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.4rem;
+ }
+}
+
+/* Very small screens */
+@media (max-width: 480px) {
+ .tts-player__select {
+ max-width: 60px;
+ }
+}
diff --git a/website/src/components/SlideDeck/TTSPlayer.tsx b/website/src/components/SlideDeck/TTSPlayer.tsx
new file mode 100644
index 0000000000..d4858b4b97
--- /dev/null
+++ b/website/src/components/SlideDeck/TTSPlayer.tsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ RiPlayLine,
+ RiPauseLine,
+ RiStopLine,
+ RiLoader4Line,
+ RiVolumeMuteLine,
+ RiVolumeUpLine,
+} from 'react-icons/ri';
+import type { TTSVoice, UseTTSReturn } from './useTTS';
+import { Tooltip } from './Tooltip';
+import './TTSPlayer.css';
+
+interface TTSPlayerProps {
+ tts: UseTTSReturn;
+ currentSlide: number;
+ onStop?: () => void;
+ onPause?: () => void;
+ onResume?: () => void;
+}
+
+const VOICES: { value: TTSVoice; label: string }[] = [
+ { value: 'alloy', label: 'Alloy' },
+ { value: 'echo', label: 'Echo' },
+ { value: 'fable', label: 'Fable' },
+ { value: 'nova', label: 'Nova' },
+ { value: 'onyx', label: 'Onyx' },
+ { value: 'shimmer', label: 'Shimmer' },
+];
+
+const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
+
+/**
+ * TTSPlayer - A full-featured audio player bar for TTS playback.
+ *
+ * Features:
+ * - Play/Pause/Stop controls
+ * - Mute toggle
+ * - Progress bar with seek
+ * - Speed selector
+ * - Voice selector
+ */
+export function TTSPlayer({ tts, currentSlide, onStop, onPause, onResume }: TTSPlayerProps) {
+ const {
+ isPlaying,
+ isLoading,
+ isPaused,
+ isMuted,
+ error,
+ progress,
+ duration,
+ currentTime,
+ voice,
+ playbackRate,
+ play,
+ pause,
+ resume,
+ stop,
+ seek,
+ toggleMute,
+ setVoice,
+ setPlaybackRate,
+ } = tts;
+
+ const handlePlayPause = () => {
+ if (isPlaying) {
+ onPause ? onPause() : pause();
+ } else if (isPaused) {
+ onResume ? onResume() : resume();
+ } else {
+ play(currentSlide);
+ }
+ };
+
+ const handleStop = () => {
+ onStop ? onStop() : stop();
+ };
+
+ const handleProgressClick = (e: React.MouseEvent) => {
+ if (duration <= 0) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const pct = (e.clientX - rect.left) / rect.width;
+ seek(pct * duration);
+ };
+
+ const handleProgressKeyDown = (e: React.KeyboardEvent) => {
+ if (duration <= 0) return;
+ const step = 5; // Seek 5 seconds per key press.
+ if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ seek(Math.min(currentTime + step, duration));
+ } else if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ seek(Math.max(currentTime - step, 0));
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ seek(0);
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ seek(duration);
+ }
+ };
+
+ const formatTime = (s: number) => {
+ if (!isFinite(s) || s < 0) return '0:00';
+ const mins = Math.floor(s / 60);
+ const secs = Math.floor(s % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ return (
+
+
+ {/* Play/Pause Button */}
+
+
+
+
+ {/* Stop Button */}
+ {(isPlaying || isPaused) && (
+
+
+
+ )}
+
+ {/* Mute Button */}
+
+
+
+
+ {/* Progress Bar */}
+
+
+ {/* Time Display */}
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+ {/* Speed Selector */}
+
+
+
+
+ {/* Voice Selector */}
+
+
+
+
+ {/* Error Display */}
+ {error && {error}}
+
+
+ );
+}
+
+export default TTSPlayer;
diff --git a/website/src/components/SlideDeck/index.tsx b/website/src/components/SlideDeck/index.tsx
index 9e6f6a30ed..cdd710387c 100644
--- a/website/src/components/SlideDeck/index.tsx
+++ b/website/src/components/SlideDeck/index.tsx
@@ -4,6 +4,7 @@ import './SlideDrawer.css';
import './SlideImage.css';
import './SlideNotes.css';
import './Tooltip.css';
+import './TTSPlayer.css';
export { SlideDeck } from './SlideDeck';
export { Slide } from './Slide';
@@ -18,11 +19,16 @@ export { SlideIndex } from './SlideIndex';
export { SlideDrawer } from './SlideDrawer';
export { SlideNotes } from './SlideNotes';
export { SlideNotesPanel } from './SlideNotesPanel';
+export { SlideNotesPopout } from './SlideNotesPopout';
+export { TTSPlayer } from './TTSPlayer';
export { Tooltip } from './Tooltip';
// Context exports for advanced usage.
export { SlideDeckProvider, useSlideDeck } from './SlideDeckContext';
+// Hook exports.
+export { useTTS } from './useTTS';
+
// Type exports.
export type {
SlideDeckProps,
@@ -40,4 +46,9 @@ export type {
SlideIndexProps,
SlideNotesProps,
SlideNotesPanelProps,
+ NotesPosition,
+ NotesDisplayMode,
+ NotesPreferences,
} from './types';
+
+export type { TTSVoice, UseTTSReturn } from './useTTS';
diff --git a/website/src/components/SlideDeck/types.ts b/website/src/components/SlideDeck/types.ts
index 7f45cf08af..76d100c55d 100644
--- a/website/src/components/SlideDeck/types.ts
+++ b/website/src/components/SlideDeck/types.ts
@@ -3,6 +3,19 @@ import React, { ReactNode } from 'react';
// Slide layout variants.
export type SlideLayout = 'title' | 'content' | 'split' | 'code' | 'quote';
+// Notes panel position.
+export type NotesPosition = 'right' | 'bottom';
+
+// Notes display mode.
+export type NotesDisplayMode = 'overlay' | 'shrink';
+
+// Notes preferences stored in localStorage.
+export interface NotesPreferences {
+ position: NotesPosition;
+ displayMode: NotesDisplayMode;
+ isPopout: boolean;
+}
+
// Props for the SlideDeck container component.
export interface SlideDeckProps {
children: ReactNode;
@@ -86,6 +99,12 @@ export interface SlideDeckContextValue {
toggleNotes: () => void;
currentNotes: React.ReactNode | null;
setCurrentNotes: (notes: React.ReactNode | null) => void;
+ // Notes preferences.
+ notesPreferences: NotesPreferences;
+ setNotesPosition: (position: NotesPosition) => void;
+ setNotesDisplayMode: (mode: NotesDisplayMode) => void;
+ setNotesPopout: (isPopout: boolean) => void;
+ isMobile: boolean;
}
// Metadata for slide deck index page.
diff --git a/website/src/components/SlideDeck/useTTS.ts b/website/src/components/SlideDeck/useTTS.ts
new file mode 100644
index 0000000000..49680d88e9
--- /dev/null
+++ b/website/src/components/SlideDeck/useTTS.ts
@@ -0,0 +1,408 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+
+export type TTSVoice = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
+
+interface UseTTSOptions {
+ deckName: string;
+ onEnded?: () => void; // Callback when audio finishes.
+}
+
+export interface UseTTSReturn {
+ // State.
+ isPlaying: boolean;
+ isLoading: boolean;
+ isPaused: boolean;
+ isMuted: boolean;
+ error: string | null;
+ progress: number; // 0-100.
+ duration: number; // seconds.
+ currentTime: number; // seconds.
+ voice: TTSVoice;
+ playbackRate: number;
+
+ // Actions.
+ play: (slideNumber: number) => Promise;
+ prefetch: (slideNumber: number) => Promise<() => Promise>; // Returns playPrefetched function.
+ prefetchInBackground: (slideNumber: number) => void; // Prefetch next slide while current plays.
+ pause: () => void;
+ resume: () => void;
+ stop: () => void;
+ seek: (time: number) => void;
+ toggleMute: () => void;
+ setVoice: (voice: TTSVoice) => void;
+ setPlaybackRate: (rate: number) => void;
+}
+
+const TTS_PREFS_KEY = 'slide-deck-tts-preferences';
+
+interface TTSPrefs {
+ voice: TTSVoice;
+ rate: number;
+ muted: boolean;
+}
+
+const defaultPrefs: TTSPrefs = { voice: 'nova', rate: 1, muted: false };
+
+/**
+ * Custom hook for Text-to-Speech playback of slide notes.
+ *
+ * Uses the Cloud Posse TTS API to convert slide notes to speech.
+ * Supports voice selection, speed control, muting, and progress tracking.
+ *
+ * IMPORTANT: This hook reuses a single Audio element to maintain user-activation
+ * state on iOS. Creating new Audio elements breaks autoplay on mobile Safari.
+ */
+export function useTTS({ deckName, onEnded }: UseTTSOptions): UseTTSReturn {
+ // Load saved preferences.
+ const loadPrefs = (): TTSPrefs => {
+ if (typeof window === 'undefined') return defaultPrefs;
+ try {
+ const stored = localStorage.getItem(TTS_PREFS_KEY);
+ return stored ? { ...defaultPrefs, ...JSON.parse(stored) } : defaultPrefs;
+ } catch {
+ return defaultPrefs;
+ }
+ };
+
+ const [voice, setVoiceState] = useState(defaultPrefs.voice);
+ const [playbackRate, setPlaybackRateState] = useState(defaultPrefs.rate);
+ const [isMuted, setIsMuted] = useState(defaultPrefs.muted);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [progress, setProgress] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [currentTime, setCurrentTime] = useState(0);
+
+ // Persistent audio element - reused across plays to maintain iOS user-activation.
+ const audioRef = useRef(null);
+ const onEndedRef = useRef(onEnded);
+ onEndedRef.current = onEnded;
+
+ // Cache for prefetched audio data URLs, keyed by slide number and voice.
+ const prefetchCacheRef = useRef