mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user