Landing page v0.2.0 updates: multi-engine copy, star count, model cards, voice creator section, responsive ControlUI, iOS audio fix

- Replace Qwen-specific copy with multi-engine messaging across hero, meta, and features
- Add GitHub star count fetched server-side via /api/stars with Spacedrive-style navbar badge
- Replace 'Why Voicebox exists' section with model cards for all 4 TTS engines
- Enable Linux download card (was 'Coming soon')
- Update GPU support copy to include ROCm, Intel Arc, DirectML
- Add Voice Creator section with animated 3-tab UI (upload, mic, system audio) and waveform background
- Make ControlUI responsive: horizontal scroll cards on mobile, stacked layout, scroll-to-active profile
- Fix iOS Safari audio autoplay (unlock AudioContext on user gesture)
- Fix hero logo square background with mix-blend-lighten
- Remove generation length green coloring, use gray with accent highlights
- Comment out grain overlay (visible tile seams)
- Remove player close button, stack waveform above controls on mobile
- Fixed-height profile cards (143px) with space between badges and buttons
This commit is contained in:
James Pine
2026-03-15 04:53:28 -07:00
parent 8377152d86
commit f80782a90a
16 changed files with 1756 additions and 462 deletions
+48 -44
View File
@@ -1,6 +1,6 @@
'use client';
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
@@ -10,6 +10,26 @@ 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;
export function unlockAudioContext() {
if (audioContextUnlocked) return;
try {
const ctx = new (
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
)();
const buffer = ctx.createBuffer(1, 1, 22050);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0);
audioContextUnlocked = true;
} catch {
// Silently fail — non-Safari browsers don't need this
}
}
interface LandingAudioPlayerProps {
audioUrl: string;
title: string;
@@ -145,24 +165,23 @@ export function LandingAudioPlayer({
if (!ws || !isReady) return;
if (playing) {
// Resume the AudioContext first (required for iOS Safari after unlock)
const backend = ws.getMediaElement();
if (backend && 'context' in backend) {
const ctx = (backend as unknown as { context: AudioContext }).context;
if (ctx?.state === 'suspended') ctx.resume();
}
ws.play()
.then(() => {
console.log(
'[Player] play succeeded, isPlaying:',
ws.isPlaying(),
'currentTime:',
ws.getCurrentTime(),
);
setTimeout(() => {
console.log(
'[Player] 1s later, isPlaying:',
ws.isPlaying(),
'currentTime:',
ws.getCurrentTime(),
);
}, 1000);
console.log('[Player] play succeeded');
})
.catch((e) => console.error('[Player] play failed', e));
.catch((e: Error) => {
if (e.name === 'NotAllowedError') {
console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
} else {
console.error('[Player] play failed', e);
}
});
} else {
ws.pause();
}
@@ -180,34 +199,27 @@ export function LandingAudioPlayer({
wavesurferRef.current.playPause();
}, []);
const handleClose = useCallback(() => {
if (wavesurferRef.current) {
wavesurferRef.current.pause();
}
setIsPlaying(false);
onClose();
}, [onClose]);
return (
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30">
<div className="px-4 py-3">
<div className="flex items-center gap-4">
<div className="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
{/* Waveform — full width row on mobile, inline on desktop */}
<div className="min-w-0 min-h-[60px] md:min-h-[80px] md:flex-1 md:order-2">
<div ref={waveformRef} className="w-full h-full min-h-[60px] md:min-h-[80px]" />
</div>
{/* Controls row */}
<div className="flex items-center gap-3 md:contents">
{/* Play/Pause */}
<button
onClick={handlePlayPause}
disabled={!isReady}
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50"
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50 md:order-1"
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
</button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" />
</div>
{/* Time */}
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 md:order-3">
<span className="font-mono text-xs">{formatDuration(currentTime)}</span>
<span className="text-xs">/</span>
<span className="font-mono text-xs">{formatDuration(duration)}</span>
@@ -215,7 +227,7 @@ export function LandingAudioPlayer({
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
{title}
</div>
)}
@@ -223,7 +235,7 @@ export function LandingAudioPlayer({
{/* Loop */}
<button
onClick={() => setIsLooping(!isLooping)}
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted ${
className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
isLooping ? 'text-foreground' : 'text-muted-foreground'
}`}
>
@@ -231,7 +243,7 @@ export function LandingAudioPlayer({
</button>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0 w-[120px]">
<div className="flex items-center gap-2 shrink-0 w-[120px] md:order-6">
<button
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
@@ -251,14 +263,6 @@ export function LandingAudioPlayer({
className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
/>
</div>
{/* Close */}
<button
onClick={handleClose}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm shrink-0"
>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
</div>
</div>