Files
voicebox/app/src/App.tsx
T
James Pine 2c63dfff25 overhaul settings: split into routed sub-tabs, add server logs, changelog, reusable setting components
- Rename Server tab to Settings with horizontal sub-tab navigation (General, Generation, GPU, Logs, Changelog)
- All sub-tabs are proper routes under /settings/* with /server redirect for backwards compat
- General: connection settings, link cards (docs + discord), API reference card, app updates
- Generation: auto-chunking, crossfade, normalize, autoplay as SettingRow components
- GPU: info card with platform-aware icons (Apple logo for MPS), CUDA management, explainer text
- Logs: real-time server log viewer piped from Tauri sidecar via event system (Tauri-only)
- Changelog: parsed from CHANGELOG.md at build time via Vite virtual module plugin
- New reusable SettingRow/SettingSection components for consistent settings layout
- New Toggle (switch) UI component replacing checkboxes in settings
- Toast viewport now offsets when audio player is open
- Sidebar stays active on settings sub-routes (fuzzy matching)
2026-03-16 09:31:14 -07:00

189 lines
6.9 KiB
TypeScript

import { RouterProvider } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import { useServerStore } from '@/stores/serverStore';
const LOADING_MESSAGES = [
'Warming up tensors...',
'Calibrating synthesizer engine...',
'Initializing voice models...',
'Loading neural networks...',
'Preparing audio pipelines...',
'Optimizing waveform generators...',
'Tuning frequency analyzers...',
'Building voice embeddings...',
'Configuring text-to-speech cores...',
'Syncing audio buffers...',
'Establishing model connections...',
'Preprocessing training data...',
'Validating voice samples...',
'Compiling inference engines...',
'Mapping phoneme sequences...',
'Aligning prosody parameters...',
'Activating speech synthesis...',
'Fine-tuning acoustic models...',
'Preparing voice cloning matrices...',
'Initializing Qwen TTS framework...',
];
function App() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
// Automatically check for app updates on startup and show toast notifications
useAutoUpdater({ checkOnMount: true, showToast: true });
// Sync stored setting to Rust on startup
useEffect(() => {
if (platform.metadata.isTauri) {
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
console.error('Failed to sync initial setting to Rust:', error);
});
}
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Setup lifecycle callbacks
useEffect(() => {
platform.lifecycle.onServerReady = () => {
setServerReady(true);
};
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.lifecycle]);
// Subscribe to server logs
useEffect(() => {
const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => {
useLogStore.getState().addEntry(entry);
});
return unsubscribe;
}, [platform.lifecycle]);
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
if (!platform.metadata.isTauri) {
setServerReady(true); // Web assumes server is running
return;
}
// Setup window close handler to check setting and stop server if needed
// This works in both dev and prod, but will only stop server if it was started by the app
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
console.error('Failed to setup window close handler:', error);
});
// Only auto-start server in production mode
// In dev mode, user runs server separately
if (!import.meta.env?.PROD) {
console.log('Dev mode: Skipping auto-start of server (run it separately)');
setServerReady(true); // Mark as ready so UI doesn't show loading screen
// Mark that server was not started by app (so we don't try to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
return;
}
// Auto-start server in production
if (serverStartingRef.current) {
return;
}
serverStartingRef.current = true;
const isRemote = useServerStore.getState().mode === 'remote';
const customModelsDir = useServerStore.getState().customModelsDir;
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
platform.lifecycle
.startServer(isRemote, customModelsDir)
.then((serverUrl) => {
console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port
useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
// Note: Window close is handled separately in Tauri Rust code
return () => {
// Window close event handles server shutdown based on setting
serverStartingRef.current = false;
};
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Cycle through loading messages every 3 seconds
useEffect(() => {
if (!platform.metadata.isTauri || serverReady) {
return;
}
const interval = setInterval(() => {
setLoadingMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
}, 3000);
return () => clearInterval(interval);
}, [serverReady, platform.metadata.isTauri]);
// Show loading screen while server is starting in Tauri
if (platform.metadata.isTauri && !serverReady) {
return (
<div
className={cn(
'min-h-screen bg-background flex items-center justify-center',
TOP_SAFE_AREA_PADDING,
)}
>
<TitleBarDragRegion />
<div className="text-center space-y-6">
<div className="flex justify-center relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full bg-accent/20 blur-3xl" />
</div>
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
/>
</div>
<div className="animate-fade-in-delayed">
<ShinyText
text={LOADING_MESSAGES[loadingMessageIndex]}
className="text-lg font-medium text-muted-foreground"
speed={2}
color="hsl(var(--muted-foreground))"
shineColor="hsl(var(--foreground))"
/>
</div>
</div>
</div>
);
}
return <RouterProvider router={router} />;
}
export default App;