hfstudio / frontend /src /routes /+page.svelte
GitHub Action
Sync from GitHub: cc6e14ca1fa8589facaeddabf00f15828d1a5209
eef3408
raw
history blame
44.5 kB
<script>
import {
Play,
Download,
Loader2,
AlertCircle,
ChevronDown,
Copy,
RefreshCw,
Share,
MoreHorizontal,
Settings,
Sliders,
Pause,
SkipBack,
SkipForward,
Layout,
Code,
X,
RotateCcw,
} from 'lucide-svelte';
import { onMount } from 'svelte';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-bash';
let text = `In a hole in the ground there lived a hobbit.`;
let selectedVoice = 'Lily';
let selectedModel = 'Chatterbox';
let mode = 'api';
let viewMode = 'ui';
let modelDropdownOpen = false;
let isGenerating = false;
let codeHistory = [];
let setupCode = generateSetupCode(); // Always show setup code
let importCode = null;
let audioUrl = null;
let copyNotification = null;
let exaggeration = 0.25;
let temperature = 0.7;
let showSettings = true;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let audioTitle = '';
let audioElement = null;
let sampleAudioElement = null;
let playingSampleVoice = null;
let showErrorModal = false;
let errorMessage = '';
let errorDetails = '';
let currentUsername = null;
const models = [
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' },
{ id: 'kokoro', name: 'Kokoro', badge: 'coming soon', disabled: true },
];
const voices = [
{
id: 'lily',
name: 'Lily',
description: 'Warm, conversational voice from a female in her 30s',
sample: '/voices/lily.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/lily.mp3',
},
{
id: 'andrew',
name: 'Andrew',
description: 'Older British man who speaks clearly and kindly',
sample: '/voices/andrew.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/andrew.mp3',
},
{
id: 'fairy',
name: 'Fairy',
description: 'High and airy female voice that bursts with excitement',
sample: '/voices/fairy.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/fairy.mp3',
},
{
id: 'pirate',
name: 'Pirate',
description: 'Young pirate that speaks gruffly and passionately',
sample: '/voices/pirate.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/pirate.mp3',
},
];
async function generateSpeech() {
if (!text.trim()) return;
const accessToken = getAccessToken();
if (!accessToken) {
window.dispatchEvent(new CustomEvent('show-signin-popover'));
return;
}
isGenerating = true;
audioUrl = null;
currentTime = 0;
// Generate import code when user starts interacting
if (!importCode) {
importCode = generateImportCode();
}
const ttsCode = generateTTSCode();
isPlaying = false;
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text;
try {
const accessToken = getAccessToken();
const requestBody = {
text: text,
voice_id: selectedVoice.toLowerCase(),
model_id: selectedModel.toLowerCase(),
mode: mode,
access_token: accessToken,
parameters: {
exaggeration: exaggeration,
temperature: temperature,
},
};
const response = await fetch('/api/tts/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
}
const result = await response.json();
if (result.success && result.audio_url) {
audioUrl = result.audio_url;
// Add to history with result
addCodeToHistory(ttsCode, {
type: 'audio',
url: result.audio_url,
title: audioTitle,
duration: result.duration,
});
if (viewMode === 'ui') {
// Autoplay the newly generated audio
setTimeout(() => {
if (audioElement) {
audioElement.play().catch(() => {
// Ignore autoplay failures (browser restrictions)
});
}
}, 100); // Small delay to ensure audio element is ready
}
} else {
const errorMessage = result.error || 'Unknown error occurred';
showError('Generation Failed', errorMessage);
audioUrl = null;
}
} catch (error) {
showError(
'Network Error',
'Failed to connect to the server. Please check your connection and try again.'
);
audioUrl = null;
} finally {
isGenerating = false;
}
}
function getAccessToken() {
if (typeof window !== 'undefined' && window.gradio && window.gradio.auth_token) {
return window.gradio.auth_token;
}
const metaToken = document.querySelector('meta[name="hf-oauth-token"]');
if (metaToken) {
const token = metaToken.getAttribute('content');
if (token) {
return token;
}
}
const possibleKeys = [
'hf_access_token',
'hf_token',
'huggingface_token',
'oauth_token',
'access_token',
];
for (const key of possibleKeys) {
const token = localStorage.getItem(key);
if (token) {
return token;
}
}
for (const key of possibleKeys) {
const token = sessionStorage.getItem(key);
if (token) {
return token;
}
}
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name && (name.includes('token') || name.includes('hf') || name.includes('oauth'))) {
return decodeURIComponent(value);
}
}
try {
const authHeader = document.querySelector('script[data-hf-token]');
if (authHeader) {
const token = authHeader.getAttribute('data-hf-token');
if (token) {
return token;
}
}
} catch (e) {}
return null;
}
function togglePlayPause() {
if (audioElement) {
if (isPlaying) {
audioElement.pause();
} else {
audioElement.play();
}
}
}
function handleAudioLoad() {
if (audioElement) {
duration = audioElement.duration;
}
}
function handleTimeUpdate() {
if (audioElement) {
currentTime = audioElement.currentTime;
}
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function downloadAudio() {
if (audioUrl) {
const a = document.createElement('a');
a.href = audioUrl;
a.download = 'speech.wav';
a.click();
}
}
function shareAudio() {}
function playSampleVoice(voice, event) {
event.stopPropagation();
if (playingSampleVoice === voice.name) {
if (sampleAudioElement) {
sampleAudioElement.pause();
sampleAudioElement.currentTime = 0;
}
playingSampleVoice = null;
} else {
if (sampleAudioElement) {
sampleAudioElement.pause();
}
playingSampleVoice = voice.name;
const sampleUrl = voice.sample || '/samples/harvard.wav';
if (!sampleAudioElement) {
sampleAudioElement = new Audio(sampleUrl);
sampleAudioElement.addEventListener('ended', () => {
playingSampleVoice = null;
});
} else {
sampleAudioElement.src = sampleUrl;
}
sampleAudioElement.play().catch((err) => {
playingSampleVoice = null;
});
}
}
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
generateSpeech();
}
}
function handleClickOutside(event) {
if (!event.target.closest('.model-dropdown')) {
modelDropdownOpen = false;
}
}
function addCodeToHistory(code, result = null) {
const entry = {
id: Date.now() + Math.random(),
code,
result,
};
codeHistory = [...codeHistory, entry];
saveHistoryToStorage();
return entry;
}
function saveHistoryToStorage() {
if (!currentUsername) return;
const storageKey = `hfstudio_history_${currentUsername}`;
const historyData = {
username: currentUsername,
setupCode,
importCode,
history: codeHistory,
};
localStorage.setItem(storageKey, JSON.stringify(historyData));
}
function loadHistoryFromStorage() {
if (!currentUsername) return;
const storageKey = `hfstudio_history_${currentUsername}`;
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const data = JSON.parse(stored);
if (data.username === currentUsername) {
setupCode = generateSetupCode(); // Always regenerate setup code
importCode = data.importCode || null;
codeHistory = data.history || [];
}
} catch (e) {
console.error('Error loading history:', e);
}
}
}
function resetHistory() {
codeHistory = [];
setupCode = generateSetupCode(); // Keep setup code but regenerate it
importCode = null; // Reset import code to show "Start using UI" message
if (currentUsername) {
const storageKey = `hfstudio_history_${currentUsername}`;
localStorage.removeItem(storageKey);
}
}
function generateSetupCode() {
if (mode === 'local') {
return `pip install huggingface-hub hfstudio uv
hfstudio start ${selectedModel.toLowerCase()} --port 7861`;
} else {
return `pip install huggingface-hub`;
}
}
function generateClientInitCode() {
// Generate the exact same client initialization code used in the server
if (mode === 'local') {
const port = 7861; // Default port from server.py line 297
return `client = InferenceClient(base_url="http://localhost:${port}/api/v1")`;
} else {
// Use endpoint_model from spec file (e.g., "ResembleAI/chatterbox" for chatterbox)
const endpointModel =
selectedModel.toLowerCase() === 'chatterbox'
? 'ResembleAI/chatterbox'
: selectedModel.toLowerCase();
return `client = InferenceClient(
api_key="YOUR_HF_TOKEN", # Get your token from https://huggingface.co/settings/tokens
model="${endpointModel}",
)`;
}
}
function generateImportCode() {
const clientCode = generateClientInitCode();
if (mode === 'local') {
return `from huggingface_hub import InferenceClient
${clientCode}`;
} else {
return `from huggingface_hub import InferenceClient
${clientCode}`;
}
}
function generateTTSCode() {
// Get voice URL from the selected voice data (which comes from the server)
const selectedVoiceData = voices.find((v) => v.name === selectedVoice);
const voiceUrl = selectedVoiceData?.preview_url || selectedVoiceData?.sample;
if (mode === 'local') {
return `text = """${text}"""
# audio is in bytes format
audio = client.text_to_speech(
text,
extra_body={
"audio_url": "${voiceUrl}",
"exaggeration": ${exaggeration},
"temperature": ${temperature}
}
)`;
} else {
return `text = """${text}"""
# audio is in bytes format
audio = client.text_to_speech(
text,
extra_body={
"audio_url": "${voiceUrl}",
"exaggeration": ${exaggeration},
"temperature": ${temperature}
}
)`;
}
}
function generateSaveCode() {
return `# Save the audio to a file
output_filename = "output_speech.wav"
with open(output_filename, "wb") as f:
f.write(audio_bytes)
print(f"✓ Audio saved to {output_filename}")
# Optional: Play the audio (requires additional packages)
# from playsound import playsound
# playsound(output_filename)`;
}
function copyToClipboard(text, message = 'Copied to clipboard!') {
navigator.clipboard.writeText(text).then(() => {
copyNotification = message;
setTimeout(() => {
copyNotification = null;
}, 2000);
});
}
function showError(message, details = '') {
errorMessage = message;
errorDetails = details;
showErrorModal = true;
}
function closeErrorModal() {
showErrorModal = false;
errorMessage = '';
errorDetails = '';
}
function copyErrorMessage() {
const fullError = errorDetails ? `${errorMessage}\n\nDetails:\n${errorDetails}` : errorMessage;
copyToClipboard(fullError, 'Error message copied!');
}
function copyAllCode() {
const parts = [];
// Add setup section with proper markdown formatting
if (setupCode) {
const isTerminalCommand =
setupCode.includes('pip install') || setupCode.includes('hfstudio start');
const language = isTerminalCommand ? 'bash' : '';
parts.push(`## Setup (Run in Terminal)\n\n\`\`\`${language}\n${setupCode}\n\`\`\``);
}
// Add imports section with python code blocks
if (importCode) {
parts.push(`## Imports (Python)\n\n\`\`\`python\n${importCode}\n\`\`\``);
}
// Add history entries with python code blocks
codeHistory.forEach((entry, i) => {
parts.push(`## Cell ${i + 1}\n\n\`\`\`python\n${entry.code}\n\`\`\``);
});
const markdownContent = parts.join('\n\n');
copyToClipboard(markdownContent, 'All code copied as Markdown!');
}
onMount(() => {
// Get username from parent layout or localStorage
const checkUsername = () => {
const token = localStorage.getItem('hf_access_token');
if (token) {
// Try to get username from parent component or make a quick API call
fetchUserInfo(token);
}
};
checkUsername();
// Listen for auth changes
window.addEventListener('storage', (e) => {
if (e.key === 'hf_access_token') {
checkUsername();
}
});
});
async function fetchUserInfo(token) {
try {
const response = await fetch('https://huggingface.co/api/whoami-v2', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const userData = await response.json();
currentUsername =
userData.name || userData.fullname || userData.login || userData.username || 'User';
loadHistoryFromStorage();
}
} catch (error) {
console.error('Error fetching user info:', error);
}
}
// Update setup code when mode changes
$: if (mode) {
setupCode = generateSetupCode();
// Update import code if it already exists (when mode changes)
if (importCode) {
importCode = generateImportCode();
}
}
function toggleHistoryAudio(entry) {
if (!entry.audioElement) {
// Create audio element if it doesn't exist
entry.audioElement = new Audio(entry.result.url);
entry.audioElement.addEventListener('ended', () => {
entry.isPlaying = false;
codeHistory = [...codeHistory]; // Trigger reactivity
});
}
if (entry.isPlaying) {
entry.audioElement.pause();
entry.isPlaying = false;
} else {
// Pause any other playing audio
codeHistory.forEach((e) => {
if (e !== entry && e.isPlaying && e.audioElement) {
e.audioElement.pause();
e.isPlaying = false;
}
});
entry.audioElement.play();
entry.isPlaying = true;
}
codeHistory = [...codeHistory]; // Trigger reactivity
}
function downloadHistoryAudio(url, title) {
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'audio'}.wav`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<div class="flex flex-col h-full" on:click={handleClickOutside}>
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-end px-4 py-2">
<div class="flex items-center gap-2">
<!-- View mode toggle -->
<div class="flex items-center bg-gray-100 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors {viewMode ===
'ui'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (viewMode = 'ui')}
>
<Layout size={14} />
UI
</button>
<button
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors relative {viewMode ===
'code'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (viewMode = 'code')}
>
<Code size={14} />
Code Recorder
{#if codeHistory.length > 0}
<span
class="ml-1 px-1.5 py-0.5 text-xs bg-gray-500 text-white rounded-full min-w-[18px] h-[18px] flex items-center justify-center"
>
{codeHistory.length}
</span>
{/if}
</button>
</div>
</div>
</div>
</header>
<!-- Main content area -->
{#if viewMode === 'ui'}
<div class="flex-1 flex">
<!-- Main content area -->
<div class="flex-1 flex flex-col p-6">
<!-- Text input area -->
<div class="flex-1 pb-24">
<textarea
bind:value={text}
class="w-full h-full p-6 bg-white resize-none border-0 focus:outline-none text-gray-900 text-base leading-relaxed"
placeholder="In a hole in the ground there lived a hobbit."
/>
</div>
<!-- Fixed bottom generate button -->
<div class="fixed bottom-0 left-56 right-80 p-4 bg-white border-t border-gray-200">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-gray-500">{text.length} / 5,000 characters</span>
</div>
<button
on:click={generateSpeech}
disabled={isGenerating || !text.trim()}
class="w-full px-6 py-3 bg-gradient-to-r from-amber-400 to-orange-500 text-white rounded-lg font-medium hover:from-amber-500 hover:to-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-sm"
>
{#if isGenerating}
<Loader2 size={20} class="animate-spin" />
Generating...
{:else}
<Play size={20} />
Generate speech
{/if}
</button>
</div>
<!-- Generated audio section -->
{#if audioUrl}
<div class="p-4 border border-gray-200 rounded-lg bg-white">
<!-- Audio title and voice info -->
<div class="flex items-center gap-3 mb-4">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<div class="flex-1">
<h3 class="font-medium text-gray-900 text-sm">{audioTitle}</h3>
<p class="text-xs text-gray-500">{selectedVoice} • Created 1 second ago</p>
</div>
<!-- Mini action buttons -->
<div class="flex items-center gap-2">
<button
on:click={shareAudio}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
>
<Share size={14} class="text-gray-600" />
<span class="text-gray-700">Share</span>
</button>
<button
on:click={downloadAudio}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
>
<span class="text-gray-700">Download</span>
<Download size={14} class="text-gray-600" />
</button>
</div>
</div>
<!-- Mini audio controls -->
<div class="flex items-center gap-3 mb-4">
<!-- Play/Pause button -->
<button
on:click={togglePlayPause}
class="w-8 h-8 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
>
{#if isPlaying}
<div class="pause-filled text-white"></div>
{:else}
<Play size={14} class="text-white ml-0.5" />
{/if}
</button>
<!-- Progress bar -->
<div class="flex-1 flex items-center gap-2">
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span>
<div class="flex-1 h-1 bg-gray-200 rounded-full cursor-pointer">
<div
class="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all"
style="width: {(currentTime / duration) * 100}%"
></div>
</div>
<span class="text-xs text-gray-500 font-mono">{formatTime(duration)}</span>
</div>
</div>
<!-- Full audio player controls -->
<div class="flex items-center gap-4 mb-4">
<!-- Skip back button -->
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip back">
<SkipBack size={20} class="text-gray-600" />
</button>
<!-- Play/Pause button -->
<button
on:click={togglePlayPause}
class="w-12 h-12 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors"
>
{#if isPlaying}
<div class="pause-filled text-white scale-150"></div>
{:else}
<Play size={20} class="text-white ml-0.5" />
{/if}
</button>
<!-- Skip forward button -->
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip forward">
<SkipForward size={20} class="text-gray-600" />
</button>
<!-- Progress bar -->
<div class="flex-1 flex items-center gap-3">
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span>
<div class="flex-1 h-1 bg-gray-200 rounded-full">
<div
class="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all"
style="width: {(currentTime / duration) * 100}%"
></div>
</div>
<span class="text-xs text-gray-500 font-mono">{formatTime(duration)}</span>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<button
on:click={shareAudio}
class="flex items-center gap-2 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50"
>
<Share size={14} />
Share
</button>
<button
on:click={downloadAudio}
class="p-2 hover:bg-gray-100 rounded-md"
title="Download"
>
<Download size={16} class="text-gray-600" />
</button>
<button class="p-2 hover:bg-gray-100 rounded-md" title="More options">
<MoreHorizontal size={16} class="text-gray-600" />
</button>
</div>
</div>
<!-- Hidden audio element -->
{#if audioUrl}
<audio
bind:this={audioElement}
src={audioUrl}
on:loadedmetadata={handleAudioLoad}
on:timeupdate={handleTimeUpdate}
on:play={handlePlay}
on:pause={handlePause}
style="display: none;"
/>
{/if}
</div>
{/if}
</div>
<!-- Right panel -->
<div class="w-80 border-l border-gray-200 bg-white p-4 overflow-y-auto">
<!-- Model selector -->
<div class="mb-6 relative model-dropdown">
<h3 class="font-medium text-gray-900 mb-3">Model</h3>
<button
on:click={() => (modelDropdownOpen = !modelDropdownOpen)}
class="w-full p-3 border border-gray-200 rounded-lg bg-white text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent appearance-none bg-no-repeat bg-right pr-10 shadow-sm text-left flex items-center justify-between"
>
<span>
{#each models as model}
{#if model.name === selectedModel}
{model.name}{#if model.badge}&nbsp;<span class="text-xs text-gray-500"
>({model.badge})</span
>{/if}
{/if}
{/each}
</span>
<ChevronDown size={16} class="text-gray-500" />
</button>
{#if modelDropdownOpen}
<div
class="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10"
>
{#each models as model}
<button
class="w-full px-3 py-2 text-left transition-colors text-sm {model.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50'} {model.name === selectedModel ? 'bg-gray-100' : ''}"
disabled={model.disabled}
on:click={() => {
if (!model.disabled) {
selectedModel = model.name;
modelDropdownOpen = false;
}
}}
>
{model.name}{#if model.badge}&nbsp;<span class="text-xs text-gray-500"
>({model.badge})</span
>{/if}
</button>
{/each}
</div>
{/if}
<!-- Pricing info -->
<div class="mt-2 text-xs text-gray-500">
Estimated $0.025 per 1000 characters • <a
href="https://huggingface.co/settings/billing"
target="_blank"
class="text-amber-600 hover:text-amber-700 underline">Billing ⤴</a
>
</div>
</div>
<div class="mb-6">
<div class="mb-3">
<h3 class="font-medium text-gray-900">Voice</h3>
</div>
<div class="space-y-2">
{#each voices as voice}
<button
class="w-full flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors text-left group border border-transparent
{voice.name === selectedVoice ? 'bg-gray-100 border-gray-200' : ''}"
on:click={() => (selectedVoice = voice.name)}
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="w-10 h-10 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-sm font-semibold flex-shrink-0"
>
{voice.name[0]}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 mb-1">{voice.name}</div>
<div class="text-xs text-gray-500 leading-relaxed">
{voice.description}
</div>
</div>
</div>
<button
on:click={(e) => playSampleVoice(voice, e)}
class="p-2 rounded-full hover:bg-gray-200 transition-colors flex-shrink-0 ml-2 w-8 h-8 flex items-center justify-center"
title="Play sample"
>
{#if playingSampleVoice === voice.name}
<Pause size={16} class="text-gray-600" />
{:else}
<Play size={16} class="text-gray-600" />
{/if}
</button>
</button>
{/each}
<!-- Clone voice option -->
<button
class="w-full flex items-center justify-between p-2 rounded-lg opacity-50 cursor-not-allowed text-left border border-transparent"
disabled
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="w-10 h-10 bg-gray-400 rounded-full flex items-center justify-center text-white text-sm font-medium flex-shrink-0"
>
+
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-600 mb-1">Clone your voice</div>
<div class="text-xs text-gray-400">(coming soon)</div>
</div>
</div>
</button>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-gray-200">
<!-- Exaggeration control -->
<div>
<div class="flex justify-between mb-1">
<label for="exaggeration-slider" class="text-sm font-medium text-gray-700"
>Exaggeration</label
>
<span class="text-sm text-gray-500">{exaggeration.toFixed(2)}</span>
</div>
<input
id="exaggeration-slider"
type="range"
bind:value={exaggeration}
min="0"
max="1"
step="0.01"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>None</span>
<span>Exaggerated</span>
</div>
</div>
<!-- Stability control -->
<div>
<div class="flex justify-between mb-1">
<label for="temperature-slider" class="text-sm font-medium text-gray-700"
>Stability</label
>
<span class="text-sm text-gray-500">{temperature.toFixed(2)}</span>
</div>
<input
id="temperature-slider"
type="range"
bind:value={temperature}
min="0"
max="1"
step="0.01"
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>More stable</span>
<span>More variable</span>
</div>
</div>
</div>
</div>
</div>
{:else}
<!-- Code view -->
<div class="flex-1 bg-gray-50 overflow-y-auto">
<div class="max-w-4xl mx-auto p-8">
<!-- Header -->
<div class="mb-6">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Code Recorder</h2>
<p class="text-sm text-gray-600 mt-1">
{#if mode === 'local'}
Python code to reproduce your actions using a local HFStudio server
{:else}
Python code to reproduce your actions via the API
{/if}
</p>
</div>
<!-- Toggle and Copy All button row -->
<div class="flex items-center justify-between mt-4">
<!-- API/Local Mode Toggle -->
<div class="flex items-center bg-gray-100 rounded-md p-0.5">
<button
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'api'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (mode = 'api')}
>
API
</button>
<button
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'local'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (mode = 'local')}
>
Local
</button>
</div>
{#if codeHistory.length > 0 || setupCode || importCode}
<div class="flex items-center gap-2">
<button
on:click={resetHistory}
class="flex items-center bg-red-50 hover:bg-red-100 rounded-md px-3 py-1.5 transition-colors"
title="Clear history"
>
<RotateCcw size={16} class="text-red-600" />
<span class="ml-2 text-sm font-medium text-red-600">Reset history</span>
</button>
<button
on:click={copyAllCode}
class="flex items-center bg-gray-100 hover:bg-gray-200 rounded-md px-3 py-1.5 transition-colors"
>
<Copy size={16} class="text-gray-600" />
<span class="ml-2 text-sm font-medium text-gray-600">Copy all as Markdown</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Code sections -->
<div class="space-y-6">
<!-- Setup Section - Always shown -->
{#if setupCode}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
class="flex items-center justify-between px-4 py-2 bg-amber-50 border-b border-amber-200"
>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-amber-900">Setup (Run in Terminal)</span>
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded"
>Run once</span
>
</div>
<button
on:click={() => copyToClipboard(setupCode)}
class="p-1.5 hover:bg-amber-100 rounded transition-colors"
title="Copy setup code"
>
<Copy size={14} class="text-amber-600" />
</button>
</div>
<div class="relative">
{#if setupCode === 'pip install huggingface-hub'}
<pre class="p-4 overflow-x-auto bg-gray-50"><code
class="language-bash text-sm text-black">{setupCode}</code
></pre>
{:else}
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-bash text-sm"
>{@html Prism.highlight(setupCode, Prism.languages.bash, 'bash')}</code
></pre>
{/if}
</div>
</div>
{/if}
<!-- Import Section -->
{#if importCode}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
class="flex items-center justify-between px-4 py-2 bg-blue-50 border-b border-blue-200"
>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-blue-900">Imports (Python)</span>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">Run once</span
>
</div>
<button
on:click={() => copyToClipboard(importCode)}
class="p-1.5 hover:bg-blue-100 rounded transition-colors"
title="Copy import code"
>
<Copy size={14} class="text-blue-600" />
</button>
</div>
<div class="relative">
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-python text-sm"
>{@html Prism.highlight(importCode, Prism.languages.python, 'python')}</code
></pre>
</div>
</div>
{/if}
<!-- Show "start using UI" message when no import code or history -->
{#if !importCode && codeHistory.length === 0}
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center">
<p class="text-gray-500">Start using the UI to see generated code here</p>
{#if currentUsername}
<p class="text-xs text-gray-400 mt-2">Logged in as: {currentUsername}</p>
{/if}
</div>
{/if}
<!-- History entries -->
{#each codeHistory as entry, i (entry.id)}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm">
<!-- Code cell -->
<div class="border-b border-gray-200">
<div
class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100"
>
<span class="text-sm font-medium text-gray-700">Cell {i + 1}</span>
<button
on:click={() => copyToClipboard(entry.code)}
class="p-1.5 hover:bg-gray-200 rounded transition-colors"
title="Copy code"
>
<Copy size={14} class="text-gray-600" />
</button>
</div>
<div class="relative">
<pre class="p-4 overflow-x-auto bg-gray-50"><code class="language-python text-sm"
>{@html Prism.highlight(entry.code, Prism.languages.python, 'python')}</code
></pre>
</div>
</div>
<!-- Result (audio player) -->
{#if entry.result && entry.result.type === 'audio'}
<div class="bg-gradient-to-b from-gray-50 to-white p-4">
<div class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 flex-1">
<button
on:click={() => toggleHistoryAudio(entry)}
class="w-10 h-10 bg-gradient-to-r from-amber-500 to-orange-500 rounded-full flex items-center justify-center text-white hover:from-amber-600 hover:to-orange-600 transition-colors shadow-md"
>
{#if entry.isPlaying}
<Pause size={18} />
{:else}
<Play size={18} class="ml-0.5" />
{/if}
</button>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900 truncate">
{entry.result.title || 'Generated Audio'}
</div>
<div class="text-xs text-gray-500">
Duration: {formatDuration(entry.result.duration || 0)}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<button
on:click={() =>
downloadHistoryAudio(entry.result.url, entry.result.title)}
class="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Download"
>
<Download size={16} class="text-gray-600" />
</button>
<button
class="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Share"
>
<Share size={16} class="text-gray-600" />
</button>
</div>
</div>
<audio
bind:this={entry.audioElement}
src={entry.result.url}
on:ended={() => (entry.isPlaying = false)}
class="hidden"
/>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Copy notification toast -->
{#if copyNotification}
<div
class="fixed bottom-4 right-4 px-4 py-2 bg-gray-900 text-white rounded-lg shadow-lg z-50 animate-fade-in"
>
{copyNotification}
</div>
{/if}
<!-- Error Modal -->
{#if showErrorModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] flex flex-col">
<!-- Header -->
<div
class="flex items-center justify-between p-6 border-b border-gray-200 bg-red-50 flex-shrink-0"
>
<div class="flex items-center gap-3 min-w-0">
<div
class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<AlertCircle size={20} class="text-red-600" />
</div>
<div class="min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">{errorMessage}</h3>
<p class="text-sm text-gray-600">An error occurred while processing your request</p>
</div>
</div>
<button
on:click={closeErrorModal}
class="p-2 hover:bg-red-100 rounded-full transition-colors flex-shrink-0"
title="Close"
>
<X size={20} class="text-gray-500" />
</button>
</div>
<!-- Content -->
<div class="p-6 overflow-y-auto flex-1 min-h-0">
{#if errorDetails}
<div class="bg-gray-50 rounded-lg p-4 border">
<h4 class="text-sm font-medium text-gray-900 mb-2">Error Details:</h4>
<pre
class="text-xs text-gray-700 whitespace-pre-wrap font-mono leading-relaxed break-words">{errorDetails}</pre>
</div>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50 flex-shrink-0"
>
<button
on:click={copyErrorMessage}
class="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
>
<Copy size={16} />
Copy Error
</button>
<button
on:click={closeErrorModal}
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Close
</button>
</div>
</div>
</div>
{/if}
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
@keyframes sweep {
0% {
left: -100%;
}
20% {
left: -100%;
}
80% {
left: 100%;
}
100% {
left: 100%;
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
}
50% {
box-shadow: 0 0 0 6px rgba(251, 191, 36, 0.4);
}
100% {
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0);
}
}
</style>