mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Responsive polish: pointer-events-none on animations, sticky header with scroll fade, desktop scroll-to-active fix, iOS audio unlock, player and UI tweaks
- Add pointer-events-none/select-none to feature cards, voice creator, and ControlUI mock - Sticky header with gradient fade overlay (matching real app 3-layer technique) - Fix desktop scroll-to-active: separate mobile/desktop card refs to prevent mobile refs overwriting desktop - Scroll selected card to 2nd row when outside safe zone above generate box - iOS Safari audio unlock via WaveSurfer's actual media element - Player: accent fill play/pause button, padding on volume slider, remove close button - Profile cards: fixed 143px height, mobile edge fades with scroll-aware left fade - Generate box: accent effect pill when active, white fill sparkle icon, edge-aligned on desktop - Voice creator: animated waveform background with height-based bars - 12 profiles (added Attenborough, Zendaya, Obama) for 4-row grid with scroll
This commit is contained in:
@@ -85,6 +85,24 @@ const PROFILES: VoiceProfile[] = [
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
hasEffects: false,
|
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. */
|
/** 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 */}
|
{/* Generate button */}
|
||||||
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
|
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-accent-foreground" />
|
<Sparkles className="h-3.5 w-3.5 text-white fill-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -486,8 +504,14 @@ function FloatingGenerateBox({
|
|||||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
|
||||||
{engine}
|
{engine}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
<span
|
||||||
<Sparkles className="h-2.5 w-2.5" />
|
className={`text-[10px] px-2 py-1 rounded-full border flex items-center gap-1 ${
|
||||||
|
effect
|
||||||
|
? 'border-accent/30 bg-accent/10 text-accent'
|
||||||
|
: 'border-border bg-card text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className={`h-2.5 w-2.5 ${effect ? 'fill-accent' : ''}`} />
|
||||||
{effect || 'Effect'}
|
{effect || 'Effect'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,18 +532,46 @@ export function ControlUI() {
|
|||||||
const [pageHidden, setPageHidden] = useState(false);
|
const [pageHidden, setPageHidden] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const phaseRef = useRef(phase);
|
const phaseRef = useRef(phase);
|
||||||
const profileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const mobileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
const desktopCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
const profileGridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
phaseRef.current = phase;
|
phaseRef.current = phase;
|
||||||
|
|
||||||
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
|
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
|
||||||
const selectedProfile = PROFILES[selectedIndex];
|
const selectedProfile = PROFILES[selectedIndex];
|
||||||
|
|
||||||
// Scroll to selected profile card
|
// Scroll to selected profile card — accounts for generate box overlay on desktop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = profileCardRefs.current.get(selectedIndex);
|
const isMobile = window.innerWidth < 768;
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
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]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
// Visibility detection
|
// Visibility detection
|
||||||
@@ -674,7 +726,7 @@ export function ControlUI() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px]">
|
<div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px] pointer-events-none select-none">
|
||||||
<div className="flex flex-col md:flex-row h-full">
|
<div className="flex flex-col md:flex-row h-full">
|
||||||
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
|
{/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
|
||||||
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
|
<div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
|
||||||
@@ -722,60 +774,76 @@ export function ControlUI() {
|
|||||||
{/* ── Main content ──────────────────────────────────────── */}
|
{/* ── Main content ──────────────────────────────────────── */}
|
||||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||||
{/* Left: Profiles + Generate box */}
|
{/* Left: Profiles + Generate box */}
|
||||||
<div className="flex flex-col min-w-0 relative md:flex-1">
|
<div className="flex flex-col min-w-0 relative md:flex-1 md:overflow-hidden">
|
||||||
{/* Header */}
|
{/* Gradient fade overlay — sits between header and scroll content */}
|
||||||
<div className="px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between relative z-10">
|
<div className="hidden md:block absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-app-box to-transparent z-[1] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Header — floats above everything */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10 px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between">
|
||||||
<h2 className="text-base font-bold">Voicebox</h2>
|
<h2 className="text-base font-bold">Voicebox</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
|
||||||
Import Voice
|
Import Voice
|
||||||
</button>
|
</button>
|
||||||
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center gap-1">
|
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center">
|
||||||
<Sparkles className="h-2.5 w-2.5" />
|
|
||||||
Create Voice
|
Create Voice
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */}
|
{/* Scrollable profile cards — scrolls behind header + gradient */}
|
||||||
<div className="px-4">
|
<div
|
||||||
{/* Mobile: horizontal scroll strip */}
|
ref={profileGridRef}
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2 md:hidden">
|
className="flex-1 min-h-0 md:overflow-y-auto md:pt-14 pt-12"
|
||||||
{PROFILES.map((profile, i) => (
|
>
|
||||||
|
<div className="px-4">
|
||||||
|
{/* Mobile: horizontal scroll strip with edge fade */}
|
||||||
|
<div className="relative md:hidden">
|
||||||
|
{scrollLeft > 0 && (
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-6 bg-gradient-to-r from-app-box to-transparent z-10" />
|
||||||
|
)}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-6 bg-gradient-to-l from-app-box to-transparent z-10" />
|
||||||
<div
|
<div
|
||||||
key={profile.name}
|
className="flex gap-2 overflow-x-auto pb-2"
|
||||||
className="shrink-0 w-[140px]"
|
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
|
||||||
ref={(el) => {
|
|
||||||
if (el) profileCardRefs.current.set(i, el);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{PROFILES.map((profile, i) => (
|
||||||
|
<div
|
||||||
|
key={profile.name}
|
||||||
|
className="shrink-0 w-[140px]"
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) mobileCardRefs.current.set(i, el);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProfileCard
|
||||||
|
profile={profile}
|
||||||
|
selected={i === selectedIndex}
|
||||||
|
selecting={phase === 'selecting'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: 3-col grid */}
|
||||||
|
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-44">
|
||||||
|
{PROFILES.map((profile, i) => (
|
||||||
<ProfileCard
|
<ProfileCard
|
||||||
|
key={profile.name}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
selected={i === selectedIndex}
|
selected={i === selectedIndex}
|
||||||
selecting={phase === 'selecting'}
|
selecting={phase === 'selecting'}
|
||||||
|
cardRef={(el: HTMLDivElement | null) => {
|
||||||
|
if (el) desktopCardRefs.current.set(i, el);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: 3-col grid */}
|
|
||||||
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-24">
|
|
||||||
{PROFILES.map((profile, i) => (
|
|
||||||
<ProfileCard
|
|
||||||
key={profile.name}
|
|
||||||
profile={profile}
|
|
||||||
selected={i === selectedIndex}
|
|
||||||
selecting={phase === 'selecting'}
|
|
||||||
cardRef={(el: HTMLDivElement | null) => {
|
|
||||||
if (el) profileCardRefs.current.set(i, el);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
|
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
|
||||||
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-3 md:right-3 md:bottom-[117px] md:z-20 md:pb-0">
|
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-4 md:right-4 md:bottom-[117px] md:z-20 md:pb-0 md:px-0">
|
||||||
<FloatingGenerateBox
|
<FloatingGenerateBox
|
||||||
phase={phase}
|
phase={phase}
|
||||||
typingText={step.text}
|
typingText={step.text}
|
||||||
|
|||||||
@@ -814,7 +814,9 @@ function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
||||||
<LazyLoad>
|
<LazyLoad>
|
||||||
<Animation />
|
<div className="pointer-events-none select-none">
|
||||||
|
<Animation />
|
||||||
|
</div>
|
||||||
</LazyLoad>
|
</LazyLoad>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
|||||||
@@ -10,10 +10,32 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock Web Audio on iOS Safari — must be called from a user gesture (click/tap)
|
// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
|
||||||
let audioContextUnlocked = false;
|
// Must call .play() on WaveSurfer's actual media element during a user gesture
|
||||||
|
let sharedWaveSurfer: WaveSurfer | null = null;
|
||||||
|
let audioUnlocked = false;
|
||||||
|
|
||||||
export function unlockAudioContext() {
|
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 {
|
try {
|
||||||
const ctx = new (
|
const ctx = new (
|
||||||
window.AudioContext ||
|
window.AudioContext ||
|
||||||
@@ -24,9 +46,8 @@ export function unlockAudioContext() {
|
|||||||
source.buffer = buffer;
|
source.buffer = buffer;
|
||||||
source.connect(ctx.destination);
|
source.connect(ctx.destination);
|
||||||
source.start(0);
|
source.start(0);
|
||||||
audioContextUnlocked = true;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — non-Safari browsers don't need this
|
// Silently fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +158,7 @@ export function LandingAudioPlayer({
|
|||||||
|
|
||||||
ws.load(audioUrl);
|
ws.load(audioUrl);
|
||||||
wavesurferRef.current = ws;
|
wavesurferRef.current = ws;
|
||||||
|
sharedWaveSurfer = ws;
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
@@ -213,9 +235,13 @@ export function LandingAudioPlayer({
|
|||||||
<button
|
<button
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={!isReady}
|
disabled={!isReady}
|
||||||
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50 md:order-1"
|
className="h-10 w-10 rounded-full bg-accent flex items-center justify-center shrink-0 disabled:opacity-50 md:order-1 shadow-lg"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
{isPlaying ? (
|
||||||
|
<Pause className="h-5 w-5 text-accent-foreground fill-accent-foreground" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-5 w-5 ml-0.5 text-accent-foreground fill-accent-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
@@ -243,7 +269,7 @@ export function LandingAudioPlayer({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Volume */}
|
{/* Volume */}
|
||||||
<div className="flex items-center gap-2 shrink-0 w-[120px] md:order-6">
|
<div className="flex items-center gap-2 shrink-0 w-[140px] md:order-6 mr-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
|
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
|
||||||
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
|
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export function VoiceCreator() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Animated UI mock */}
|
{/* Right: Animated UI mock */}
|
||||||
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden">
|
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden pointer-events-none select-none">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">
|
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user