diff --git a/website/docs/slides/atmos-intro.mdx b/website/docs/slides/atmos-intro.mdx index bd5a77fe48..7cbcfc2dd6 100644 --- a/website/docs/slides/atmos-intro.mdx +++ b/website/docs/slides/atmos-intro.mdx @@ -269,7 +269,7 @@ import { MetallicIcon } from '@site/src/components/MetallicIcon';
  • Terraform lacks built-in guardrails & policy controls that enterprises need
  • - + Let me enumerate the specific challenges with Terraform. GitOps and CI/CD for teams is non-trivial to set up correctly. Configuration is not DRY - diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 1ec7e6e3b9..734a4331a3 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -365,6 +365,9 @@ const config = { 'cli/*', ], }, + ], + [ + path.resolve(__dirname, 'plugins', 'slide-notes-extractor'), {} ] ], diff --git a/website/plugins/slide-notes-extractor/index.js b/website/plugins/slide-notes-extractor/index.js new file mode 100644 index 0000000000..9a89215088 --- /dev/null +++ b/website/plugins/slide-notes-extractor/index.js @@ -0,0 +1,123 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = function slideNotesExtractorPlugin(context, options) { + return { + name: 'slide-notes-extractor', + + async postBuild({ outDir }) { + const slidesDir = path.join(context.siteDir, 'docs/slides'); + + // Check if slides directory exists. + if (!fs.existsSync(slidesDir)) { + console.log('[slide-notes-extractor] No docs/slides directory found, skipping'); + return; + } + + // Find all MDX files in docs/slides/. + const mdxFiles = fs.readdirSync(slidesDir) + .filter(f => f.endsWith('.mdx')); + + if (mdxFiles.length === 0) { + console.log('[slide-notes-extractor] No MDX files found in docs/slides/'); + return; + } + + let totalNotes = 0; + + for (const file of mdxFiles) { + const content = fs.readFileSync( + path.join(slidesDir, file), + 'utf-8' + ); + + // Extract notes from each slide. + const notes = extractSlideNotes(content); + + // Create output directory. + const deckName = path.basename(file, '.mdx'); + const outputDir = path.join(outDir, 'slides', deckName); + fs.mkdirSync(outputDir, { recursive: true }); + + // Write individual .txt files (only for slides with notes). + let notesCount = 0; + notes.forEach((noteText, index) => { + if (noteText.trim()) { + fs.writeFileSync( + path.join(outputDir, `slide${index + 1}.txt`), + noteText.trim() + ); + notesCount++; + } + }); + + totalNotes += notesCount; + console.log(`[slide-notes-extractor] ${deckName}: ${notesCount} notes from ${notes.length} slides`); + } + + console.log(`[slide-notes-extractor] Total: ${totalNotes} speaker notes extracted`); + } + }; +}; + +/** + * Extract speaker notes from MDX content. + * Returns an array of note strings, one per slide (empty string if no notes). + */ +function extractSlideNotes(mdxContent) { + const notes = []; + + // Regex to match ... blocks (handles attributes and multiline). + const slideRegex = /]*>([\s\S]*?)<\/Slide>/g; + + let match; + while ((match = slideRegex.exec(mdxContent)) !== null) { + const slideContent = match[1]; + + // Extract ... content. + const notesMatch = slideContent.match(/([\s\S]*?)<\/SlideNotes>/); + + if (notesMatch) { + // Strip JSX/HTML tags, normalize whitespace for plain text output. + let text = notesMatch[1]; + + // Remove JSX comments first. + text = text.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); + + // Use a simple, secure approach: extract only text content. + // Replace all HTML/JSX tags with spaces (to preserve word boundaries), + // then clean up. This avoids multi-character sanitization issues. + // + // Strategy: Rather than trying to strip tags iteratively (which can + // leave fragments), we extract text between tags and join with spaces. + const textParts = []; + let lastIndex = 0; + const tagRegex = /<[^>]*>/g; + let tagMatch; + + while ((tagMatch = tagRegex.exec(text)) !== null) { + // Extract text before this tag. + if (tagMatch.index > lastIndex) { + textParts.push(text.slice(lastIndex, tagMatch.index)); + } + lastIndex = tagMatch.index + tagMatch[0].length; + } + // Extract remaining text after last tag. + if (lastIndex < text.length) { + textParts.push(text.slice(lastIndex)); + } + + // Join parts and clean up any stray angle brackets (from malformed input). + text = textParts.join(' ').replace(/[<>]/g, ''); + + // Normalize whitespace. + text = text.replace(/\s+/g, ' ').trim(); + + notes.push(text); + } else { + notes.push(''); // Empty string for slides without notes. + } + } + + return notes; +} diff --git a/website/src/components/SlideDeck/Slide.css b/website/src/components/SlideDeck/Slide.css index 939144f3e9..675f585354 100644 --- a/website/src/components/SlideDeck/Slide.css +++ b/website/src/components/SlideDeck/Slide.css @@ -9,6 +9,7 @@ box-sizing: border-box; background: var(--ifm-background-color); color: var(--ifm-font-color-base); + overflow-y: auto; } .slide__inner { @@ -87,14 +88,19 @@ html[data-theme='dark'] .slide--title { max-width: 700px; } -/* Fullscreen adjustments */ -.slide-deck--fullscreen .slide { - background: #1a1a2e; - color: #fff; +/* Fullscreen adjustments - slides inherit normal theme styling */ + +/* Desktop fullscreen - scale content to fill the larger slide area */ +.slide-deck--fullscreen .slide__inner { + max-width: 85%; +} + +.slide-deck--fullscreen .slide--split .slide__inner { + max-width: 90%; } -.slide-deck--fullscreen .slide--title { - background: linear-gradient(135deg, rgba(30, 91, 184, 0.3) 0%, #1a1a2e 100%); +.slide-deck--fullscreen .slide--code .slide__inner { + max-width: 90%; } /* Responsive */ @@ -126,3 +132,138 @@ html[data-theme='dark'] .slide--title { max-width: 100%; } } + +/* Small Mobile */ +@media screen and (max-width: 480px) { + .slide { + padding: 0.75rem 1rem; + } + + .slide--title { + padding: 0.75rem 1rem; + } + + .slide--code { + padding: 0.5rem; + } + + .slide--split { + padding: 0.75rem 1rem; + } +} + +/* Mobile fullscreen - scale content to fit viewport */ +@media screen and (max-width: 996px) and (max-height: 500px), + screen and (max-width: 768px) { + .slide-deck--fullscreen .slide { + padding: 0 1.5rem !important; + overflow-y: auto; + } + + .slide-deck--fullscreen .slide__inner { + max-width: 95vw; + width: 100%; + margin: auto; + padding: 0 1rem; + } + + /* Override content layout for mobile fullscreen */ + .slide-deck--fullscreen .slide--content .slide__inner { + text-align: left; + align-items: flex-start; + } + + /* Scale text for mobile fullscreen */ + .slide-deck--fullscreen .slide-title { + font-size: clamp(1.25rem, 6vw, 2rem); + margin-bottom: 0.75rem; + } + + .slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1rem, 4vw, 1.5rem); + } + + .slide-deck--fullscreen .slide-content, + .slide-deck--fullscreen .slide-list { + font-size: clamp(0.9rem, 3.5vw, 1.1rem); + } + + .slide-deck--fullscreen .slide-list li { + margin-bottom: 0.4em; + } + + /* Scale images proportionally */ + .slide-deck--fullscreen .slide-image img { + max-height: 40vh; + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + /* For split layouts on mobile, keep 2-column layout */ + .slide-deck--fullscreen .slide--split .slide__inner { + flex-direction: row; + gap: 1.5rem; + align-items: center; + } + + .slide-deck--fullscreen .slide--split .slide__inner > *:first-child { + flex: 2; + min-width: 0; + } + + .slide-deck--fullscreen .slide--split .slide__inner > *:last-child { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .slide-deck--fullscreen .slide--split .slide-image img, + .slide-deck--fullscreen .slide--split img { + max-height: 35vh; + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + /* Split layout text sizes */ + .slide-deck--fullscreen .slide--split .slide-title { + font-size: clamp(1rem, 5vw, 1.75rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--split .slide-list { + font-size: clamp(0.75rem, 3vw, 1rem); + } + + .slide-deck--fullscreen .slide--split .slide-list li { + margin-bottom: 0.3em; + } +} + +/* Mobile Portrait Fullscreen - adjust layout for narrow tall viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen .slide { + padding: 2rem 1.5rem 5rem !important; + /* Center content vertically */ + align-items: center; + justify-content: center; + } + + .slide-deck--fullscreen .slide__inner { + max-width: 100%; + width: 100%; + margin: 0 auto; + padding: 0; + } + + /* Content slides keep left text alignment */ + .slide-deck--fullscreen .slide--content .slide__inner { + text-align: left; + align-items: flex-start; + } +} diff --git a/website/src/components/SlideDeck/SlideContent.css b/website/src/components/SlideDeck/SlideContent.css index 7376e269fd..c3481f1251 100644 --- a/website/src/components/SlideDeck/SlideContent.css +++ b/website/src/components/SlideDeck/SlideContent.css @@ -119,6 +119,36 @@ html[data-theme='dark'] .slide-subtitle { flex: 1; } +/* Desktop fullscreen - scale text to fill larger slide area */ +.slide-deck--fullscreen .slide-title { + font-size: clamp(2.5rem, 4vw, 4rem); +} + +.slide-deck--fullscreen .slide--title .slide-title { + font-size: clamp(3.5rem, 5vw, 5.5rem); +} + +.slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1.5rem, 2.5vw, 2.5rem); +} + +.slide-deck--fullscreen .slide--title .slide-subtitle { + font-size: clamp(1.75rem, 3vw, 3rem); +} + +.slide-deck--fullscreen .slide-content, +.slide-deck--fullscreen .slide-list { + font-size: clamp(1.25rem, 2vw, 2rem); +} + +.slide-deck--fullscreen .slide-list li { + margin-bottom: 1rem; +} + +.slide-deck--fullscreen .slide-code { + font-size: clamp(0.9rem, 1.2vw, 1.3rem); +} + /* Responsive */ @media screen and (max-width: 996px) { .slide-title { @@ -165,3 +195,88 @@ html[data-theme='dark'] .slide-subtitle { font-size: 0.8rem; } } + +/* Small Mobile */ +@media screen and (max-width: 480px) { + .slide-title { + font-size: 1.25rem; + margin-bottom: 0.5rem; + } + + .slide--title .slide-title { + font-size: 1.5rem; + } + + .slide-subtitle { + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .slide-content, + .slide-list { + font-size: 0.9rem; + line-height: 1.5; + } + + .slide-list { + padding-left: 1rem; + } + + .slide-list li { + margin-bottom: 0.5rem; + } + + .slide-code { + font-size: 0.7rem; + } + + .slide-image img { + border-radius: 4px; + } +} + +/* Mobile Portrait Fullscreen - use smaller font sizes for narrow viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen .slide-title { + font-size: clamp(1.25rem, 5vw, 1.75rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--title .slide-title { + font-size: clamp(1.5rem, 6vw, 2.25rem); + } + + .slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1rem, 4vw, 1.25rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--title .slide-subtitle { + font-size: clamp(1rem, 4.5vw, 1.5rem); + } + + .slide-deck--fullscreen .slide-content, + .slide-deck--fullscreen .slide-list { + font-size: clamp(0.875rem, 3.5vw, 1.1rem); + line-height: 1.5; + } + + .slide-deck--fullscreen .slide-list li { + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide-code { + font-size: clamp(0.7rem, 2.5vw, 0.9rem); + } + + /* Split layouts in portrait - stack vertically */ + .slide-deck--fullscreen .slide--split .slide__inner { + flex-direction: column; + gap: 1rem; + } + + .slide-deck--fullscreen .slide--split .slide-image img, + .slide-deck--fullscreen .slide--split img { + max-height: 30vh; + } +} diff --git a/website/src/components/SlideDeck/SlideDeck.css b/website/src/components/SlideDeck/SlideDeck.css index 5210a7ae9c..1859ec0cbd 100644 --- a/website/src/components/SlideDeck/SlideDeck.css +++ b/website/src/components/SlideDeck/SlideDeck.css @@ -53,11 +53,7 @@ aspect-ratio: 16 / 9; } -html[data-theme='dark'] .slide-deck { - /* No extra styling needed - inherits page background */ -} - -/* Fullscreen Mode */ +/* Fullscreen Mode - z-index must be higher than Docusaurus navbar (var(--ifm-z-index-fixed) = 100) */ .slide-deck--fullscreen { position: fixed; top: 0; @@ -68,57 +64,39 @@ html[data-theme='dark'] .slide-deck { margin: 0; border-radius: 0; border: none; - z-index: 9999; + z-index: 99999; display: flex; flex-direction: column; - background: #000; + background: var(--ifm-background-color); } .slide-deck--fullscreen .slide-deck__main { flex: 1; + min-height: 0; + overflow: hidden; } .slide-deck--fullscreen .slide-deck__container { padding-top: 0; height: 100%; + background: var(--ifm-background-color); + display: flex; + align-items: center; + justify-content: center; } .slide-deck--fullscreen .slide-deck__slide-wrapper { - max-width: 1600px; - max-height: calc(100vh - 60px); + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + /* Size based on viewport height to fill screen while maintaining 16:9 */ + height: calc(100vh - 60px); + width: calc((100vh - 60px) * 16 / 9); + max-width: calc(100vw - 80px); + max-height: calc((100vw - 80px) * 9 / 16); aspect-ratio: 16 / 9; - margin: 0 auto; -} - -.slide-deck--fullscreen .slide-deck__toolbar { - background: rgba(0, 0, 0, 0.8); - border-top-color: rgba(255, 255, 255, 0.1); -} - -.slide-deck--fullscreen .slide-deck__progress { - color: rgba(255, 255, 255, 0.7); -} - -.slide-deck--fullscreen .slide-deck__tool-button { - color: rgba(255, 255, 255, 0.6); -} - -.slide-deck--fullscreen .slide-deck__tool-button:hover:not(:disabled) { - color: #fff; - background: rgba(255, 255, 255, 0.1); -} - -.slide-deck--fullscreen .slide-deck__side-nav { - color: rgba(255, 255, 255, 0.5); -} - -.slide-deck--fullscreen .slide-deck__side-nav:hover:not(:disabled) { - color: #fff; - background: rgba(255, 255, 255, 0.1); -} - -.slide-deck--fullscreen .slide-deck__progress-bar { - background: rgba(255, 255, 255, 0.15); } /* Left area - drawer trigger zone */ @@ -179,14 +157,9 @@ html[data-theme='dark'] .slide-deck { cursor: pointer; transition: all 0.2s ease, opacity 0.3s ease, transform 0.3s ease; opacity: 1; -} - -.slide-deck__side-nav--prev { - transform: translateX(0); -} - -.slide-deck__side-nav--next { - transform: translateX(0); + /* Ensure nav buttons are above notes panel backdrop (z-index: 100) and panel (z-index: 101) */ + z-index: 102; + position: relative; } .slide-deck__side-nav:hover:not(:disabled) { @@ -274,9 +247,19 @@ html[data-theme='dark'] .slide-deck__tool-button--active { background: rgba(30, 91, 184, 0.2); } -.slide-deck--fullscreen .slide-deck__tool-button--active { - color: var(--ifm-color-primary-light); - background: rgba(30, 91, 184, 0.3); +.slide-deck__tool-button:disabled { + opacity: 0.5; + cursor: wait; +} + +/* Spinning animation for loading states */ +.slide-deck__spin { + animation: slide-deck-spin 1s linear infinite; +} + +@keyframes slide-deck-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .slide-deck__progress { @@ -293,6 +276,13 @@ html[data-theme='dark'] .slide-deck__tool-button--active { transition: opacity 0.3s ease; } +/* Extend progress bar to full width in page mode */ +.markdown .slide-deck__progress-bar, +.theme-doc-markdown .slide-deck__progress-bar { + margin-left: -2rem; + margin-right: -2rem; +} + html[data-theme='dark'] .slide-deck__progress-bar { background: var(--ifm-color-emphasis-700); } @@ -303,26 +293,257 @@ html[data-theme='dark'] .slide-deck__progress-bar { transition: width 0.3s ease; } -/* Responsive */ -@media screen and (max-width: 768px) { +/* Notes Shrink Mode - Resize slide container to make room for notes panel */ +.slide-deck--notes-shrink.slide-deck--notes-right .slide-deck__main { + padding-right: 320px; + transition: padding-right 0.3s ease; +} + +.slide-deck--notes-shrink.slide-deck--notes-bottom { + padding-bottom: 25vh; + transition: padding-bottom 0.3s ease; +} + +/* Fullscreen shrink mode adjustments */ +.slide-deck--fullscreen.slide-deck--notes-shrink.slide-deck--notes-right .slide-deck__slide-wrapper { + max-width: calc((100vh - 60px) * 16 / 9 - 320px); +} + +.slide-deck--fullscreen.slide-deck--notes-shrink.slide-deck--notes-bottom .slide-deck__slide-wrapper { + height: calc(100vh - 60px - 25vh); + max-height: calc((100vw - 80px) * 9 / 16 - 25vh); +} + +/* Responsive - Tablet */ +@media screen and (max-width: 996px) { + .markdown .slide-deck, + .theme-doc-markdown .slide-deck { + padding-left: 1rem; + padding-right: 1rem; + height: calc(100vh - 100px); + max-height: calc(100vh - 100px); + } + + .markdown .slide-deck .slide-deck__container, + .theme-doc-markdown .slide-deck .slide-deck__container { + padding-left: 1rem; + padding-right: 1rem; + } + + .markdown .slide-deck .slide-deck__slide-wrapper, + .theme-doc-markdown .slide-deck .slide-deck__slide-wrapper { + max-width: calc((100vh - 140px) * 16 / 9); + } +} + +/* Responsive - Mobile (increased to 996px to catch large phones in landscape) */ +@media screen and (max-width: 996px) and (max-height: 500px), + screen and (max-width: 768px) { + .markdown .slide-deck, + .theme-doc-markdown .slide-deck { + padding-left: 0; + padding-right: 0; + height: auto; + max-height: none; + min-height: 300px; + } + + .markdown .slide-deck .slide-deck__main, + .theme-doc-markdown .slide-deck .slide-deck__main { + flex: none; + } + + .markdown .slide-deck .slide-deck__container, + .theme-doc-markdown .slide-deck .slide-deck__container { + padding-left: 0; + padding-right: 0; + height: auto; + padding-top: 0; + } + + .markdown .slide-deck .slide-deck__slide-wrapper, + .theme-doc-markdown .slide-deck .slide-deck__slide-wrapper { + width: 100%; + max-width: 100%; + } + + .slide-deck__slide-wrapper { + left: 0; + right: 0; + } + + .slide-deck__side-nav { + width: 28px; + font-size: 1.125rem; + } + + .slide-deck__toolbar { + padding: 0.375rem 0.5rem; + gap: 0.5rem; + } + + .slide-deck__tool-button { + width: 40px; + height: 40px; + font-size: 1rem; + } + + .slide-deck__progress { + font-size: 0.7rem; + } + + /* Mobile fullscreen - fill entire viewport and center content */ + .slide-deck--fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + z-index: 99999; + background: var(--ifm-background-color); + } + + /* Collapse the left area container but keep children visible (nav buttons are fixed positioned) */ + .slide-deck--fullscreen .slide-deck__left-area { + width: 0; + min-width: 0; + overflow: visible; + } + + .slide-deck--fullscreen .slide-deck__main { + flex: 1; + display: flex; + align-items: stretch; + min-height: 0; + } + + .slide-deck--fullscreen .slide-deck__container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 !important; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper { + /* Fill entire viewport on mobile - no 16:9 constraint */ + position: relative !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + aspect-ratio: auto; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 0 2rem 4rem; + overflow-y: auto; + /* Flexbox with min-height allows centering when short, scrolling when tall */ + display: flex; + flex-direction: column; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide .slide__inner { + margin: auto; + flex-shrink: 0; + } + + /* Ensure controls are visible on mobile */ + .slide-deck--fullscreen .slide-deck__toolbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--ifm-background-color); + border-top: 1px solid var(--ifm-color-emphasis-200); + z-index: 10; + } + + .slide-deck--fullscreen .slide-deck__side-nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 10; + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + width: 36px; + height: 36px; + } + + html[data-theme='dark'] .slide-deck--fullscreen .slide-deck__side-nav { + background: rgba(0, 0, 0, 0.7); + } + + .slide-deck--fullscreen .slide-deck__side-nav--prev { + left: 0.5rem; + } + + .slide-deck--fullscreen .slide-deck__side-nav--next { + right: 0.5rem; + } +} + +/* Responsive - Small Mobile */ +@media screen and (max-width: 480px) { .slide-deck__side-nav { - width: 32px; - font-size: 1.25rem; + width: 24px; + font-size: 1rem; + } + + .slide-deck__left-area { + min-width: 24px; } .slide-deck__toolbar { - padding: 0.375rem 0.75rem; - gap: 0.75rem; + padding: 0.25rem 0.5rem; + gap: 0.25rem; } .slide-deck__tool-button { - width: 44px; - height: 44px; - font-size: 1.125rem; + width: 36px; + height: 36px; + font-size: 0.9rem; } .slide-deck__progress { - font-size: 0.75rem; + font-size: 0.65rem; + } +} + +/* Mobile Portrait - specific handling for tall narrow viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen { + /* Account for mobile browser UI (URL bar, etc.) using dvh */ + height: 100dvh; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } + + .slide-deck--fullscreen .slide-deck__main { + /* Leave room for toolbar at bottom */ + height: calc(100dvh - 50px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide { + /* Add top padding to account for any browser UI overlap */ + padding-top: 1rem; + padding-bottom: 5rem; + } + + .slide-deck--fullscreen .slide-deck__toolbar { + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); } } diff --git a/website/src/components/SlideDeck/SlideDeck.tsx b/website/src/components/SlideDeck/SlideDeck.tsx index 1bcdffd150..19555781de 100644 --- a/website/src/components/SlideDeck/SlideDeck.tsx +++ b/website/src/components/SlideDeck/SlideDeck.tsx @@ -1,9 +1,23 @@ import React, { useEffect, useCallback, useState, useRef, Children, isValidElement, ReactElement } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { RiArrowLeftSLine, RiArrowRightSLine, RiFullscreenLine, RiFullscreenExitLine, RiMenuLine, RiSpeakLine } from 'react-icons/ri'; +import { + RiArrowLeftSLine, + RiArrowRightSLine, + RiFullscreenLine, + RiFullscreenExitLine, + RiMenuLine, + RiSpeakLine, + RiArrowGoBackLine, + RiPlayLine, + RiPauseLine, + RiLoader4Line, +} from 'react-icons/ri'; import { SlideDeckProvider, useSlideDeck } from './SlideDeckContext'; import { SlideDrawer } from './SlideDrawer'; import { SlideNotesPanel } from './SlideNotesPanel'; +import { SlideNotesPopout } from './SlideNotesPopout'; +import { TTSPlayer } from './TTSPlayer'; +import { useTTS } from './useTTS'; import { Tooltip } from './Tooltip'; import type { SlideDeckProps } from './types'; import './SlideDeck.css'; @@ -28,8 +42,132 @@ function SlideDeckInner({ toggleFullscreen, showNotes, toggleNotes, + notesPreferences, + setNotesPopout, + currentNotes, } = useSlideDeck(); + const { position: notesPosition, displayMode: notesDisplayMode, isPopout: notesPopout } = notesPreferences; + + // Extract deck name from URL for TTS. + const deckName = typeof window !== 'undefined' + ? window.location.pathname.split('/slides/').pop()?.split('/')[0] || 'unknown' + : 'unknown'; + + // Track auto-play mode - stays true across slide transitions until user stops. + // Use both ref (for callbacks) and state (for UI updates). + const autoPlayRef = useRef(false); + const [isAutoPlaying, setIsAutoPlaying] = useState(false); + const autoAdvanceTimerRef = useRef | null>(null); + + // Configurable delays during auto-play (in milliseconds). + const AUTO_ADVANCE_DELAY = 1000; // Delay before advancing to next slide. + const AUTO_PLAY_DELAY = 1000; // Delay before starting audio on new slide. + + // TTS hook for audio playback. + const tts = useTTS({ + deckName, + onEnded: () => { + // Auto-advance to next slide if not on last slide. + if (currentSlide < totalSlides) { + // Keep autoPlayRef true - we want to continue playing. + // Add delay after audio ends before advancing to next slide. + autoAdvanceTimerRef.current = setTimeout(() => { + nextSlide(); + }, AUTO_ADVANCE_DELAY); + } else { + // Reached last slide - disable auto-play. + autoPlayRef.current = false; + setIsAutoPlaying(false); + } + }, + }); + + // Enable auto-play when user starts playing. + // Also prefetch the next slide's audio in the background. + useEffect(() => { + if (tts.isPlaying) { + autoPlayRef.current = true; + setIsAutoPlaying(true); + + // Prefetch next slide in background while current plays. + if (currentSlide < totalSlides) { + tts.prefetchInBackground(currentSlide + 1); + } + } + }, [tts.isPlaying, currentSlide, totalSlides, tts]); + + // Disable auto-play when user explicitly stops. + const handleStop = useCallback(() => { + autoPlayRef.current = false; + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.stop(); + }, [tts]); + + // Auto-play notes when slide changes if in auto-play mode. + // Start prefetching audio immediately while delay runs in parallel. + useEffect(() => { + if (autoPlayRef.current && currentNotes) { + let cancelled = false; + + // Start prefetch and delay in parallel. + const prefetchPromise = tts.prefetch(currentSlide); + const delayPromise = new Promise(resolve => + autoAdvanceTimerRef.current = setTimeout(resolve, AUTO_PLAY_DELAY) + ); + + // Wait for both, then play. + Promise.all([prefetchPromise, delayPromise]).then(([playPrefetched]) => { + if (!cancelled && autoPlayRef.current) { + playPrefetched(); + } + }); + + return () => { + cancelled = true; + }; + } + }, [currentSlide]); // eslint-disable-line react-hooks/exhaustive-deps + + // Cleanup auto-advance timer on unmount. + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); + + // Handle TTS play/pause toggle. + const handleTTSPlayPause = useCallback(() => { + if (tts.isPlaying) { + autoPlayRef.current = false; // Disable auto-play on pause. + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.pause(); + } else if (tts.isPaused) { + autoPlayRef.current = true; // Re-enable auto-play on resume. + setIsAutoPlaying(true); + tts.resume(); + } else if (currentNotes) { + tts.play(currentSlide); + } + }, [tts, currentNotes, currentSlide]); + + // Toggle popout mode (bring notes back from popout). + const toggleNotesPopout = useCallback(() => { + setNotesPopout(!notesPopout); + }, [notesPopout, setNotesPopout]); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isHovering, setIsHovering] = useState(false); const [showControls, setShowControls] = useState(true); @@ -133,8 +271,14 @@ function SlideDeckInner({ } else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); toggleNotes(); + } else if (e.key === 'p' || e.key === 'P') { + e.preventDefault(); + handleTTSPlayPause(); + } else if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + tts.toggleMute(); } - }, [nextSlide, prevSlide, isFullscreen, toggleFullscreen, isDrawerOpen, closeDrawer, showControlsTemporarily, showNotes, toggleNotes]); + }, [nextSlide, prevSlide, isFullscreen, toggleFullscreen, isDrawerOpen, closeDrawer, showControlsTemporarily, showNotes, toggleNotes, handleTTSPlayPause, tts]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); @@ -159,9 +303,14 @@ function SlideDeckInner({ const controlsVisible = showControls || isDrawerOpen || showNotes; + // Build class names for notes position and display mode. + const notesClasses = showNotes + ? `slide-deck--notes-${notesPosition} slide-deck--notes-${notesDisplayMode}` + : ''; + return (
    + + + {/* TTS Play/Pause button - always show, use isAutoPlaying for state during transitions */} + + @@ -264,6 +431,30 @@ function SlideDeckInner({ )}
    + {/* TTS Player bar - shows when playing, paused, or in auto-play mode (between slides) */} + {(tts.isPlaying || tts.isPaused || isAutoPlaying) && ( + { + autoPlayRef.current = false; + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.pause(); + }} + onResume={() => { + autoPlayRef.current = true; + setIsAutoPlaying(true); + tts.resume(); + }} + /> + )} + {/* Progress bar */}
    )} - {/* 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 + + + +
    +
    + 📝 Speaker Notes + Loading... +
    + +
    + +
    +
    Loading 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>(new Map()); + + // Get or create the persistent audio element. + const getAudioElement = useCallback(() => { + if (!audioRef.current && typeof window !== 'undefined') { + const audio = new Audio(); + audio.onloadedmetadata = () => setDuration(audio.duration); + audio.ontimeupdate = () => { + setCurrentTime(audio.currentTime); + if (audio.duration > 0) { + setProgress((audio.currentTime / audio.duration) * 100); + } + }; + audio.onended = () => { + setIsPlaying(false); + setIsPaused(false); + setProgress(100); + onEndedRef.current?.(); + }; + audio.onerror = () => { + setError('Playback failed'); + setIsPlaying(false); + setIsLoading(false); + }; + audioRef.current = audio; + } + return audioRef.current; + }, []); + + // Load prefs on mount. + useEffect(() => { + const prefs = loadPrefs(); + setVoiceState(prefs.voice); + setPlaybackRateState(prefs.rate); + setIsMuted(prefs.muted); + }, []); + + // Save preferences. + const savePrefs = useCallback((v: TTSVoice, r: number, m: boolean) => { + if (typeof window === 'undefined') return; + localStorage.setItem(TTS_PREFS_KEY, JSON.stringify({ voice: v, rate: r, muted: m })); + }, []); + + const setVoice = useCallback((v: TTSVoice) => { + setVoiceState(v); + savePrefs(v, playbackRate, isMuted); + }, [playbackRate, isMuted, savePrefs]); + + const setPlaybackRate = useCallback((r: number) => { + setPlaybackRateState(r); + const audio = audioRef.current; + if (audio) audio.playbackRate = r; + savePrefs(voice, r, isMuted); + }, [voice, isMuted, savePrefs]); + + const toggleMute = useCallback(() => { + const newMuted = !isMuted; + setIsMuted(newMuted); + const audio = audioRef.current; + if (audio) audio.muted = newMuted; + savePrefs(voice, playbackRate, newMuted); + }, [isMuted, voice, playbackRate, savePrefs]); + + const getTextUrl = useCallback((slideNumber: number) => { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/slides/${deckName}/slide${slideNumber}.txt`; + }, [deckName]); + + // Generate cache key for a slide/voice combination. + const getCacheKey = useCallback((slideNumber: number, v: TTSVoice) => { + return `${slideNumber}-${v}`; + }, []); + + // Fetch audio data from API (internal helper). + const fetchAudioData = useCallback(async (slideNumber: number): Promise => { + const textUrl = getTextUrl(slideNumber); + const apiUrl = `https://cloudposse.com/api/tts?url=${encodeURIComponent(textUrl)}&voice=${voice}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + let errorMsg = 'TTS failed'; + try { + const err = await response.json(); + errorMsg = err.error || errorMsg; + } catch { + // Ignore JSON parse errors. + } + throw new Error(errorMsg); + } + + const data = await response.json(); + return `data:${data.mimeType};base64,${data.audio}`; + }, [getTextUrl, voice]); + + const play = useCallback(async (slideNumber: number) => { + const audio = getAudioElement(); + if (!audio) return; + + // Stop current playback. + audio.pause(); + audio.currentTime = 0; + + setIsLoading(true); + setError(null); + setProgress(0); + setCurrentTime(0); + setDuration(0); + + try { + // Check cache first. + const cacheKey = getCacheKey(slideNumber, voice); + let audioDataUrl = prefetchCacheRef.current.get(cacheKey); + + if (!audioDataUrl) { + // Not cached, fetch from API. + audioDataUrl = await fetchAudioData(slideNumber); + } else { + // Remove from cache after use. + prefetchCacheRef.current.delete(cacheKey); + } + + // Update the existing audio element's source instead of creating a new one. + // This preserves the user-activation state on iOS. + audio.src = audioDataUrl; + audio.playbackRate = playbackRate; + audio.muted = isMuted; + + // Wait for audio to be ready. + await new Promise((resolve, reject) => { + const onCanPlay = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + resolve(); + }; + const onError = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + reject(new Error('Failed to load audio')); + }; + audio.addEventListener('canplaythrough', onCanPlay); + audio.addEventListener('error', onError); + audio.load(); + }); + + setIsLoading(false); + setIsPlaying(true); + setIsPaused(false); + await audio.play(); + } catch (err) { + setIsLoading(false); + setIsPlaying(false); + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }, [getAudioElement, getCacheKey, fetchAudioData, voice, playbackRate, isMuted]); + + // Prefetch audio for a slide without playing it. + // Returns a function that plays the prefetched audio. + // This allows starting the API call in parallel with a delay. + const prefetch = useCallback(async (slideNumber: number): Promise<() => Promise> => { + setIsLoading(true); + setError(null); + + try { + // Check cache first. + const cacheKey = getCacheKey(slideNumber, voice); + let audioDataUrl = prefetchCacheRef.current.get(cacheKey); + + if (!audioDataUrl) { + // Not cached, fetch from API. + audioDataUrl = await fetchAudioData(slideNumber); + } else { + // Remove from cache after use. + prefetchCacheRef.current.delete(cacheKey); + } + + // Return a function that plays the prefetched audio. + return async () => { + const audio = getAudioElement(); + if (!audio) return; + + // Stop current playback. + audio.pause(); + audio.currentTime = 0; + + setProgress(0); + setCurrentTime(0); + setDuration(0); + + // Set the prefetched audio source. + audio.src = audioDataUrl; + audio.playbackRate = playbackRate; + audio.muted = isMuted; + + // Wait for audio to be ready. + await new Promise((resolve, reject) => { + const onCanPlay = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + resolve(); + }; + const onError = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + reject(new Error('Failed to load audio')); + }; + audio.addEventListener('canplaythrough', onCanPlay); + audio.addEventListener('error', onError); + audio.load(); + }); + + setIsLoading(false); + setIsPlaying(true); + setIsPaused(false); + await audio.play(); + }; + } catch (err) { + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Unknown error'); + // Return a no-op function on error. + return async () => {}; + } + }, [getAudioElement, getCacheKey, fetchAudioData, voice, playbackRate, isMuted]); + + // Prefetch audio in the background (doesn't affect loading state). + // Use this to prefetch the next slide while current slide plays. + const prefetchInBackground = useCallback((slideNumber: number): void => { + const cacheKey = getCacheKey(slideNumber, voice); + + // Skip if already cached. + if (prefetchCacheRef.current.has(cacheKey)) { + return; + } + + // Fetch in background, don't await. + fetchAudioData(slideNumber) + .then(audioDataUrl => { + prefetchCacheRef.current.set(cacheKey, audioDataUrl); + }) + .catch(() => { + // Silently ignore background prefetch errors. + }); + }, [getCacheKey, fetchAudioData, voice]); + + const pause = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + setIsPaused(true); + setIsPlaying(false); + } + }, []); + + const resume = useCallback(() => { + const audio = audioRef.current; + if (audio && isPaused) { + audio.play() + .then(() => { + setIsPaused(false); + setIsPlaying(true); + }) + .catch((err) => { + // Handle autoplay blocked or other playback errors. + setError(err instanceof Error ? err.message : 'Resume failed'); + setIsPaused(true); + setIsPlaying(false); + }); + } + }, [isPaused]); + + const stop = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.currentTime = 0; + audio.src = ''; // Clear the source. + } + setIsPlaying(false); + setIsPaused(false); + setProgress(0); + setCurrentTime(0); + setDuration(0); + }, []); + + const seek = useCallback((time: number) => { + const audio = audioRef.current; + if (audio) { + audio.currentTime = time; + } + }, []); + + // Cleanup on unmount. + useEffect(() => { + return () => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.src = ''; + audioRef.current = null; + } + }; + }, []); + + return { + isPlaying, + isLoading, + isPaused, + isMuted, + error, + progress, + duration, + currentTime, + voice, + playbackRate, + play, + prefetch, + prefetchInBackground, + pause, + resume, + stop, + seek, + toggleMute, + setVoice, + setPlaybackRate, + }; +}