diff --git a/landing/src/components/ControlUI.tsx b/landing/src/components/ControlUI.tsx index b7babe96..dcef66ac 100644 --- a/landing/src/components/ControlUI.tsx +++ b/landing/src/components/ControlUI.tsx @@ -85,6 +85,24 @@ const PROFILES: VoiceProfile[] = [ language: 'en', hasEffects: false, }, + { + name: 'David Attenborough', + description: 'Warm, reverent narration with wonder and precision', + language: 'en', + hasEffects: false, + }, + { + name: 'Zendaya', + description: 'Relaxed, modern delivery with effortless cool', + language: 'en', + hasEffects: false, + }, + { + name: 'Barack Obama', + description: 'Measured cadence with rhythmic pauses and gravitas', + language: 'en', + hasEffects: false, + }, ]; /** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */ @@ -474,7 +492,7 @@ function FloatingGenerateBox({ {/* Generate button */} @@ -486,8 +504,14 @@ function FloatingGenerateBox({ {engine} - - + + {effect || 'Effect'} @@ -508,18 +532,46 @@ export function ControlUI() { const [pageHidden, setPageHidden] = useState(false); const containerRef = useRef(null); const phaseRef = useRef(phase); - const profileCardRefs = useRef>(new Map()); + const mobileCardRefs = useRef>(new Map()); + const desktopCardRefs = useRef>(new Map()); + const profileGridRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(0); phaseRef.current = phase; const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length]; const selectedProfile = PROFILES[selectedIndex]; - // Scroll to selected profile card + // Scroll to selected profile card — accounts for generate box overlay on desktop useEffect(() => { - const el = profileCardRefs.current.get(selectedIndex); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + const isMobile = window.innerWidth < 768; + + if (isMobile) { + const el = mobileCardRefs.current.get(selectedIndex); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + return; } + + // Desktop + const el = desktopCardRefs.current.get(selectedIndex); + const scrollContainer = profileGridRef.current; + if (!el || !scrollContainer) return; + + const containerTop = scrollContainer.getBoundingClientRect().top; + const elTop = el.getBoundingClientRect().top; + const elRelTop = elTop - containerTop + scrollContainer.scrollTop; + + const rowHeight = 145; + const generateBoxHeight = 200; + const visibleTop = scrollContainer.scrollTop; + const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight; + const elRelBottom = elRelTop + el.offsetHeight; + + if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) { + return; + } + + const target = elRelTop - rowHeight; + scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' }); }, [selectedIndex]); // Visibility detection @@ -674,7 +726,7 @@ export function ControlUI() { -
+
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
@@ -722,60 +774,76 @@ export function ControlUI() { {/* ── Main content ──────────────────────────────────────── */}
{/* Left: Profiles + Generate box */} -
- {/* Header */} -
+
+ {/* Gradient fade overlay — sits between header and scroll content */} +
+ + {/* Header — floats above everything */} +

Voicebox

-
- {/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */} -
- {/* Mobile: horizontal scroll strip */} -
- {PROFILES.map((profile, i) => ( + {/* Scrollable profile cards — scrolls behind header + gradient */} +
+
+ {/* Mobile: horizontal scroll strip with edge fade */} +
+ {scrollLeft > 0 && ( +
+ )} +
{ - if (el) profileCardRefs.current.set(i, el); - }} + className="flex gap-2 overflow-x-auto pb-2" + onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)} > + {PROFILES.map((profile, i) => ( +
{ + if (el) mobileCardRefs.current.set(i, el); + }} + > + +
+ ))} +
+
+ + {/* Desktop: 3-col grid */} +
+ {PROFILES.map((profile, i) => ( { + if (el) desktopCardRefs.current.set(i, el); + }} /> -
- ))} -
- - {/* Desktop: 3-col grid */} -
- {PROFILES.map((profile, i) => ( - { - if (el) profileCardRefs.current.set(i, el); - }} - /> - ))} + ))} +
{/* Floating generate box — desktop: absolute overlay, mobile: inline */} -
+
- +
+ +
diff --git a/landing/src/components/LandingAudioPlayer.tsx b/landing/src/components/LandingAudioPlayer.tsx index 61c76e2c..a92cd443 100644 --- a/landing/src/components/LandingAudioPlayer.tsx +++ b/landing/src/components/LandingAudioPlayer.tsx @@ -10,10 +10,32 @@ function formatDuration(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -// Unlock Web Audio on iOS Safari — must be called from a user gesture (click/tap) -let audioContextUnlocked = false; +// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari +// Must call .play() on WaveSurfer's actual media element during a user gesture +let sharedWaveSurfer: WaveSurfer | null = null; +let audioUnlocked = false; + export function unlockAudioContext() { - if (audioContextUnlocked) return; + if (audioUnlocked) return; + audioUnlocked = true; + + // Unlock WaveSurfer's internal audio element + if (sharedWaveSurfer) { + const media = sharedWaveSurfer.getMediaElement(); + if (media) { + media.muted = true; + media + .play() + .then(() => { + media.pause(); + media.muted = false; + media.currentTime = 0; + }) + .catch(() => {}); + } + } + + // Also unlock a standalone AudioContext as fallback try { const ctx = new ( window.AudioContext || @@ -24,9 +46,8 @@ export function unlockAudioContext() { source.buffer = buffer; source.connect(ctx.destination); source.start(0); - audioContextUnlocked = true; } catch { - // Silently fail — non-Safari browsers don't need this + // Silently fail } } @@ -137,6 +158,7 @@ export function LandingAudioPlayer({ ws.load(audioUrl); wavesurferRef.current = ws; + sharedWaveSurfer = ws; }; setIsReady(false); @@ -213,9 +235,13 @@ export function LandingAudioPlayer({ {/* Time */} @@ -243,7 +269,7 @@ export function LandingAudioPlayer({ {/* Volume */} -
+
{/* Right: Animated UI mock */} -
+
{/* Tab bar */}