@@ -170,65 +173,21 @@ function App() {
}
return (
-
-
+
- {activeTab === 'settings' ? (
-
-
-
-
-
- {isTauri() &&
}
-
-
-
- ) : (
- // Main view: Profiles top left, Generator bottom left, History right
-
- {/* Left Column */}
-
- {/* Profiles - Top Left */}
-
-
- {/* Generator - Bottom Left */}
- {/*
-
-
*/}
-
-
- {/* Right Column - History */}
-
-
-
-
- {/* Floating Generate Box */}
-
-
- )}
+ {activeTab === 'main' &&
}
+ {activeTab === 'voices' &&
}
+ {activeTab === 'audio' &&
}
+ {activeTab === 'server' &&
}
+ {activeTab === 'models' &&
}
- {/* Audio Player - always visible except on settings */}
- {activeTab !== 'settings' && }
-
{/* Show download toasts for any active downloads (from anywhere) */}
{activeDownloads.map((download) => {
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
@@ -242,7 +201,7 @@ function App() {
})}
-
+
);
}
diff --git a/app/src/components/AppFrame/AppFrame.tsx b/app/src/components/AppFrame/AppFrame.tsx
new file mode 100644
index 00000000..2caaa0dc
--- /dev/null
+++ b/app/src/components/AppFrame/AppFrame.tsx
@@ -0,0 +1,18 @@
+import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
+import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
+import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
+import { cn } from '@/lib/utils/cn';
+
+interface AppFrameProps {
+ children: React.ReactNode;
+}
+
+export function AppFrame({ children }: AppFrameProps) {
+ return (
+
+ );
+}
diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx
index 1e125e13..742f9361 100644
--- a/app/src/components/AudioPlayer/AudioPlayer.tsx
+++ b/app/src/components/AudioPlayer/AudioPlayer.tsx
@@ -1,8 +1,12 @@
-import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { invoke } from '@tauri-apps/api/core';
+import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
+import { apiClient } from '@/lib/api/client';
+import { isTauri } from '@/lib/tauri';
import { formatAudioDuration } from '@/lib/utils/audio';
import { usePlayerStore } from '@/stores/playerStore';
@@ -10,6 +14,7 @@ export function AudioPlayer() {
const {
audioUrl,
audioId,
+ profileId,
title,
isPlaying,
currentTime,
@@ -23,13 +28,58 @@ export function AudioPlayer() {
setVolume,
toggleLoop,
clearRestartFlag,
+ reset,
} = usePlayerStore();
+ // Check if profile has assigned channels (for native audio routing)
+ const { data: profileChannels } = useQuery({
+ queryKey: ['profile-channels', profileId],
+ queryFn: () => {
+ if (!profileId) return { channel_ids: [] };
+ return apiClient.getProfileChannels(profileId);
+ },
+ enabled: !!profileId && isTauri(),
+ });
+
+ const { data: channels } = useQuery({
+ queryKey: ['channels'],
+ queryFn: () => apiClient.listChannels(),
+ enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
+ });
+
+ // Determine if we should use native playback
+ const useNativePlayback = useMemo(() => {
+ console.log('useNativePlayback memo:', {
+ isTauri: isTauri(),
+ profileId,
+ profileChannels,
+ channels,
+ });
+
+ if (!isTauri() || !profileChannels || !channels) {
+ console.log('useNativePlayback: false - missing requirements');
+ return false;
+ }
+
+ const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
+
+ console.log('Assigned channels:', assignedChannels);
+
+ // Use native playback if any assigned channel has non-default devices
+ const shouldUseNative = assignedChannels.some(
+ (ch) => ch.device_ids.length > 0 && !ch.is_default,
+ );
+
+ console.log('useNativePlayback result:', shouldUseNative);
+ return shouldUseNative;
+ }, [profileChannels, channels, profileId]);
+
const waveformRef = useRef
(null);
const wavesurferRef = useRef(null);
const loadingRef = useRef(false);
const previousAudioIdRef = useRef(null);
const hasInitializedRef = useRef(false);
+ const isUsingNativePlaybackRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
@@ -122,7 +172,7 @@ export function AudioPlayer() {
});
// Update store when duration is loaded
- wavesurfer.on('ready', () => {
+ wavesurfer.on('ready', async () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
@@ -136,14 +186,188 @@ export function AudioPlayer() {
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
+ // (unless we're using native playback, which will be set later)
const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
+ if (mediaElement && !isUsingNativePlaybackRef.current) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
- // Auto-play when ready
+ // Auto-play when ready - check if we should use native playback
+ // Get current values from the store and queries at runtime (not captured closure values)
+ const currentAudioUrl = usePlayerStore.getState().audioUrl;
+ const currentProfileId = usePlayerStore.getState().profileId;
+
+ console.log('Auto-play check - capturing runtime values...');
+
+ // Fetch profile channels at runtime (not using captured value)
+ let runtimeProfileChannels = null;
+ let runtimeChannels = null;
+
+ if (isTauri() && currentProfileId) {
+ try {
+ runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
+ console.log('Runtime profileChannels:', runtimeProfileChannels);
+
+ if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
+ runtimeChannels = await apiClient.listChannels();
+ console.log('Runtime channels:', runtimeChannels);
+ }
+ } catch (error) {
+ console.error('Failed to fetch runtime channel data:', error);
+ }
+ }
+
+ console.log('Auto-play check:', {
+ isTauri: isTauri(),
+ currentAudioUrl,
+ currentProfileId,
+ hasProfileChannels: !!runtimeProfileChannels,
+ hasChannels: !!runtimeChannels,
+ });
+
+ if (
+ isTauri() &&
+ currentAudioUrl &&
+ currentProfileId &&
+ runtimeProfileChannels &&
+ runtimeChannels
+ ) {
+ console.log('Attempting native audio playback...');
+
+ // Stop any existing native playback first
+ if (isUsingNativePlaybackRef.current) {
+ try {
+ await invoke('stop_audio_playback');
+ console.log('Stopped existing native playback before starting new one');
+ } catch (error) {
+ console.error('Failed to stop existing playback:', error);
+ }
+ }
+
+ try {
+ // Collect all device IDs from assigned channels
+ const assignedChannels = runtimeChannels.filter((ch: any) =>
+ runtimeProfileChannels.channel_ids.includes(ch.id),
+ );
+ console.log('Assigned channels for playback:', assignedChannels);
+
+ // Check if any assigned channel has non-default devices
+ const shouldUseNative = assignedChannels.some(
+ (ch: any) => ch.device_ids.length > 0 && !ch.is_default,
+ );
+ console.log('Should use native playback:', shouldUseNative);
+
+ if (!shouldUseNative) {
+ console.log('No custom devices assigned, falling back to WaveSurfer');
+ // Reset native playback flag and unmute WaveSurfer
+ isUsingNativePlaybackRef.current = false;
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ const currentVolume = usePlayerStore.getState().volume;
+ mediaElement.volume = currentVolume;
+ mediaElement.muted = false;
+ console.log(
+ 'WaveSurfer unmuted for normal playback - volume:',
+ mediaElement.volume,
+ 'muted:',
+ mediaElement.muted,
+ );
+ }
+ } else {
+ const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
+ console.log('Device IDs to play to:', deviceIds);
+
+ if (deviceIds.length > 0) {
+ console.log('Fetching audio data from:', currentAudioUrl);
+ // Fetch audio data
+ const response = await fetch(currentAudioUrl);
+ const audioData = new Uint8Array(await response.arrayBuffer());
+ console.log('Audio data size:', audioData.length);
+
+ // Play via native audio
+ console.log('Invoking play_audio_to_devices...');
+ try {
+ const result = await invoke('play_audio_to_devices', {
+ audioData: Array.from(audioData),
+ deviceIds: deviceIds,
+ });
+ console.log('play_audio_to_devices completed successfully, result:', result);
+
+ // Mark that we're using native playback
+ isUsingNativePlaybackRef.current = true;
+
+ // Mute WaveSurfer's audio element to prevent UI audio output
+ // Keep WaveSurfer running for visualization
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ mediaElement.volume = 0;
+ mediaElement.muted = true;
+ console.log(
+ 'WaveSurfer muted for native playback - volume:',
+ mediaElement.volume,
+ 'muted:',
+ mediaElement.muted,
+ );
+ }
+
+ // Start WaveSurfer playback for visualization (muted)
+ wavesurfer.play().catch((error) => {
+ console.error('Failed to start WaveSurfer visualization:', error);
+ });
+
+ setIsPlaying(true);
+ console.log('Auto-playing via native audio routing - SUCCESS');
+ return;
+ } catch (invokeError) {
+ console.error('play_audio_to_devices invoke failed:', invokeError);
+ throw invokeError;
+ }
+ } else {
+ console.log('No device IDs found, falling back to WaveSurfer');
+ }
+ }
+ } catch (error) {
+ console.error(
+ 'Native playback failed during auto-play, falling back to WaveSurfer:',
+ error,
+ );
+ // Reset native playback flag and unmute WaveSurfer
+ isUsingNativePlaybackRef.current = false;
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ const currentVolume = usePlayerStore.getState().volume;
+ mediaElement.volume = currentVolume;
+ mediaElement.muted = false;
+ console.log(
+ 'WaveSurfer unmuted after native playback failure - volume:',
+ mediaElement.volume,
+ 'muted:',
+ mediaElement.muted,
+ );
+ }
+ // Fall through to WaveSurfer playback
+ }
+ } else {
+ console.log('Not using native playback, using WaveSurfer');
+ // Reset native playback flag and unmute WaveSurfer
+ isUsingNativePlaybackRef.current = false;
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ const currentVolume = usePlayerStore.getState().volume;
+ mediaElement.volume = currentVolume;
+ mediaElement.muted = false;
+ console.log(
+ 'WaveSurfer unmuted for normal playback - volume:',
+ mediaElement.volume,
+ 'muted:',
+ mediaElement.muted,
+ );
+ }
+ }
+
+ // Standard WaveSurfer auto-play
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
@@ -156,13 +380,27 @@ export function AudioPlayer() {
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
- // Ensure audio element is not muted when playing
+ // Ensure audio element volume is set correctly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
- mediaElement.muted = false;
- const currentVolume = usePlayerStore.getState().volume;
- mediaElement.volume = currentVolume;
- console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
+ // Double-check: if using native playback, keep WaveSurfer muted
+ // Otherwise, ensure it's unmuted
+ if (isUsingNativePlaybackRef.current) {
+ mediaElement.volume = 0;
+ mediaElement.muted = true;
+ console.log('Playing (native mode) - WaveSurfer muted for visualization only');
+ } else {
+ // Ensure WaveSurfer is unmuted for normal playback
+ const currentVolume = usePlayerStore.getState().volume;
+ mediaElement.volume = currentVolume;
+ mediaElement.muted = false;
+ console.log(
+ 'Playing (normal mode) - volume:',
+ mediaElement.volume,
+ 'muted:',
+ mediaElement.muted,
+ );
+ }
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
@@ -268,10 +506,35 @@ export function AudioPlayer() {
setDuration(0);
setCurrentTime(0);
setError(null);
+ // Reset native playback flag
+ isUsingNativePlaybackRef.current = false;
}
return;
}
+ // Stop native playback if it was active
+ if (isUsingNativePlaybackRef.current && isTauri()) {
+ (async () => {
+ try {
+ await invoke('stop_audio_playback');
+ console.log('Stopped native audio playback');
+ } catch (error) {
+ console.error('Failed to stop native playback:', error);
+ }
+ })();
+ }
+
+ // Reset native playback flag when loading new audio
+ // Also unmute WaveSurfer if it was muted
+ if (isUsingNativePlaybackRef.current) {
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ mediaElement.muted = false;
+ mediaElement.volume = usePlayerStore.getState().volume;
+ }
+ }
+ isUsingNativePlaybackRef.current = false;
+
// CRITICAL: Force stop any current playback and cancel any pending loads
// This must happen BEFORE any early returns
console.log('Audio URL changed to:', audioUrl);
@@ -352,9 +615,16 @@ export function AudioPlayer() {
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
- mediaElement.volume = volume;
- mediaElement.muted = volume === 0;
- console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
+ // If using native playback, keep WaveSurfer muted regardless of volume setting
+ if (isUsingNativePlaybackRef.current) {
+ mediaElement.volume = 0;
+ mediaElement.muted = true;
+ console.log('Volume sync: Using native playback, keeping WaveSurfer muted');
+ } else {
+ mediaElement.volume = volume;
+ mediaElement.muted = volume === 0;
+ console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
+ }
}
}
}, [volume]);
@@ -388,14 +658,16 @@ export function AudioPlayer() {
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
-
+
// Clear the restart flag
clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Handle loop - WaveSurfer handles this via the 'finish' event
- const handlePlayPause = () => {
+ const handlePlayPause = async () => {
+ // Standard WaveSurfer playback (works for both normal and native playback modes)
+ // When using native playback, WaveSurfer is muted but still controls visualization
if (!wavesurferRef.current) {
console.error('WaveSurfer not initialized');
return;
@@ -408,9 +680,86 @@ export function AudioPlayer() {
return;
}
+ // If using native playback
+ if (useNativePlayback && audioUrl && profileChannels && channels) {
+ if (isPlaying) {
+ // Pause: stop native playback and pause WaveSurfer visualization
+ try {
+ await invoke('stop_audio_playback');
+ console.log('Stopped native audio playback');
+ } catch (error) {
+ console.error('Failed to stop native playback:', error);
+ }
+ wavesurferRef.current.pause();
+ return;
+ }
+
+ // Play: trigger native playback
+ try {
+ // Stop any existing native playback first
+ try {
+ await invoke('stop_audio_playback');
+ } catch (_error) {
+ // Ignore errors when stopping (might not be playing)
+ console.log('No existing playback to stop');
+ }
+
+ // Collect all device IDs from assigned channels
+ const assignedChannels = channels.filter((ch) =>
+ profileChannels.channel_ids.includes(ch.id),
+ );
+ const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
+
+ if (deviceIds.length > 0) {
+ // Fetch audio data
+ const response = await fetch(audioUrl);
+ const audioData = new Uint8Array(await response.arrayBuffer());
+
+ // Play via native audio
+ await invoke('play_audio_to_devices', {
+ audioData: Array.from(audioData),
+ deviceIds: deviceIds,
+ });
+
+ // Mark that we're using native playback
+ isUsingNativePlaybackRef.current = true;
+
+ // Mute WaveSurfer and start it for visualization
+ const mediaElement = wavesurferRef.current.getMediaElement();
+ if (mediaElement) {
+ mediaElement.volume = 0;
+ mediaElement.muted = true;
+ }
+
+ // Start WaveSurfer for visualization (muted)
+ wavesurferRef.current.play().catch((error) => {
+ console.error('Failed to start WaveSurfer visualization:', error);
+ setIsPlaying(false);
+ setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
+ });
+
+ return;
+ }
+ } catch (error) {
+ console.error('Native playback failed, falling back to WaveSurfer:', error);
+ // Fall through to WaveSurfer playback
+ isUsingNativePlaybackRef.current = false;
+ }
+ }
+
+ // Standard WaveSurfer playback (or fallback from native playback failure)
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
+ // Ensure WaveSurfer is not muted if not using native playback
+ if (!isUsingNativePlaybackRef.current) {
+ const mediaElement = wavesurferRef.current.getMediaElement();
+ if (mediaElement) {
+ mediaElement.muted = false;
+ mediaElement.volume = volume;
+ }
+ }
+
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
setIsPlaying(false);
@@ -429,6 +778,22 @@ export function AudioPlayer() {
setVolume(value[0] / 100);
};
+ const handleClose = () => {
+ // Stop any native playback
+ if (isUsingNativePlaybackRef.current && isTauri()) {
+ invoke('stop_audio_playback').catch((error) => {
+ console.error('Failed to stop native playback:', error);
+ });
+ }
+ // Stop WaveSurfer
+ if (wavesurferRef.current) {
+ wavesurferRef.current.pause();
+ wavesurferRef.current.seekTo(0);
+ }
+ // Reset player state
+ reset();
+ };
+
// Don't render if no audio
if (!audioUrl) {
return null;
@@ -509,6 +874,17 @@ export function AudioPlayer() {
className="flex-1"
/>