From 7ebf57d8f46d641711153b54a7bb3168f44c45ab Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 20:22:11 -0700 Subject: [PATCH 1/2] feat: gray out unsupported profiles instead of filtering, auto-switch engine on selection - Show all voice profiles with unsupported ones grayed out (opacity) instead of hidden - Clicking a grayed-out profile selects it and auto-switches the engine to a compatible one - Sort supported profiles first, with info tip about compatibility at the bottom - Scroll to selected profile after engine/sort changes with safe margin - Fix engine desync on tab navigation by initializing form engine from store Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Generation/FloatingGenerateBox.tsx | 24 +++--- .../components/VoiceProfiles/ProfileCard.tsx | 8 +- .../components/VoiceProfiles/ProfileList.tsx | 78 ++++++++++++------- app/src/lib/hooks/useGenerationForm.ts | 4 +- backend/routes/profiles.py | 1 + 5 files changed, 69 insertions(+), 46 deletions(-) diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index f1cd571d..956c6547 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -125,22 +125,22 @@ export function FloatingGenerateBox({ }, [watchedEngine, setSelectedEngine]); // Sync generation form language, engine, and effects with selected profile + type EngineValue = 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice'; useEffect(() => { if (selectedProfile?.language) { form.setValue('language', selectedProfile.language as LanguageCode); } - // Auto-switch engine if profile has a default - if (selectedProfile?.default_engine) { - form.setValue( - 'engine', - selectedProfile.default_engine as - | 'qwen' - | 'luxtts' - | 'chatterbox' - | 'chatterbox_turbo' - | 'tada' - | 'kokoro', - ); + // Auto-switch engine to match the profile + const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine; + if (engine) { + form.setValue('engine', engine as EngineValue); + } else if (selectedProfile && selectedProfile.voice_type !== 'preset') { + // Cloned/designed profile with no default — ensure a compatible (non-preset) engine + const currentEngine = form.getValues('engine'); + const presetEngines = new Set(['kokoro', 'qwen_custom_voice']); + if (presetEngines.has(currentEngine)) { + form.setValue('engine', 'qwen'); + } } // Pre-fill effects from profile defaults if ( diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e634c38c..c55382d5 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record = { interface ProfileCardProps { profile: VoiceProfileResponse; + disabled?: boolean; } -export function ProfileCard({ profile }: ProfileCardProps) { +export function ProfileCard({ profile, disabled }: ProfileCardProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const deleteProfile = useDeleteProfile(); @@ -80,8 +81,9 @@ export function ProfileCard({ profile }: ProfileCardProps) { <> = { - kokoro: 'Kokoro', - qwen_custom_voice: 'Qwen CustomVoice', -}; - export function ProfileList() { const { data: profiles, isLoading, error } = useProfiles(); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedEngine = useUIStore((state) => state.selectedEngine); + const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const cardRefs = useRef>(new Map()); + + // Scroll to the selected profile after engine/sort changes + useEffect(() => { + if (!selectedProfileId) return; + // Wait a frame for the DOM to update after re-sort + requestAnimationFrame(() => { + const el = cardRefs.current.get(selectedProfileId); + if (!el) return; + + // Temporarily apply scroll-margin so it doesn't land flush at the top + el.style.scrollMarginTop = '180px'; + el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + // Clean up after scroll completes + setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); + }); + }, [selectedProfileId, selectedEngine]); if (isLoading) { return null; @@ -35,10 +48,18 @@ export function ProfileList() { const allProfiles = profiles || []; const isPresetEngine = PRESET_ENGINES.has(selectedEngine); - // Filter profiles based on selected engine - const filteredProfiles = isPresetEngine - ? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine) - : allProfiles.filter((p) => p.voice_type !== 'preset'); + /** Whether a profile is supported by the currently selected engine. */ + const isSupported = (p: (typeof allProfiles)[number]) => + isPresetEngine + ? p.voice_type === 'preset' && p.preset_engine === selectedEngine + : p.voice_type !== 'preset'; + + // Sort so supported profiles come first + const sortedProfiles = [...allProfiles].sort( + (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), + ); + + const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); return (
@@ -56,29 +77,26 @@ export function ProfileList() { - ) : filteredProfiles.length === 0 && isPresetEngine ? ( - - - -

- No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet. -

-

- Create a profile to choose a specific voice before generating. -

- -
-
) : (
- {filteredProfiles.map((profile) => ( -
- + {sortedProfiles.map((profile) => ( +
{ + if (el) cardRefs.current.set(profile.id, el); + else cardRefs.current.delete(profile.id); + }} + > +
))} + {hasUnsupported && ( +
+ + Only supported voice profiles can be selected for the current model. +
+ )}
)}
diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts index 0acdabbf..d6a44bfc 100644 --- a/app/src/lib/hooks/useGenerationForm.ts +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useGenerationStore } from '@/stores/generationStore'; import { useServerStore } from '@/stores/serverStore'; +import { useUIStore } from '@/stores/uiStore'; const generationSchema = z.object({ text: z.string().min(1, '').max(50000), @@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { const maxChunkChars = useServerStore((state) => state.maxChunkChars); const crossfadeMs = useServerStore((state) => state.crossfadeMs); const normalizeAudio = useServerStore((state) => state.normalizeAudio); + const selectedEngine = useUIStore((state) => state.selectedEngine); const [downloadingModelName, setDownloadingModelName] = useState(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); @@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { seed: undefined, modelSize: '1.7B', instruct: '', - engine: 'qwen', + engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen', ...options.defaultValues, }, }); diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 7bc075c5..706055de 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -4,6 +4,7 @@ import json as _json import logging import tempfile +from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile From a10024fbd8b35dc72a7bfa30af3aef91e9b1b8c2 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 21:20:05 -0700 Subject: [PATCH 2/2] fix: clean up scroll effect timers and fix disabled+selected card toggle - Add cleanup for requestAnimationFrame and setTimeout in scroll effect to prevent stale DOM writes on unmount or rapid selection changes - Fix disabled+selected card click: bounce the selection to re-trigger the engine auto-switch instead of deselecting Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/VoiceProfiles/ProfileCard.tsx | 6 ++++++ app/src/components/VoiceProfiles/ProfileList.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index c55382d5..b3d5ffee 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -41,6 +41,12 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { const isSelected = selectedProfileId === profile.id; const handleSelect = () => { + // If disabled but already selected, bounce the selection to re-trigger engine auto-switch + if (disabled && isSelected) { + setSelectedProfileId(null); + setTimeout(() => setSelectedProfileId(profile.id), 0); + return; + } setSelectedProfileId(isSelected ? null : profile.id); }; diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 51d4469d..3c18a296 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -20,17 +20,20 @@ export function ProfileList() { // Scroll to the selected profile after engine/sort changes useEffect(() => { if (!selectedProfileId) return; - // Wait a frame for the DOM to update after re-sort - requestAnimationFrame(() => { + let timeoutId: ReturnType | null = null; + const rafId = requestAnimationFrame(() => { const el = cardRefs.current.get(selectedProfileId); if (!el) return; // Temporarily apply scroll-margin so it doesn't land flush at the top el.style.scrollMarginTop = '180px'; el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - // Clean up after scroll completes - setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); + timeoutId = setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); }); + return () => { + cancelAnimationFrame(rafId); + if (timeoutId) clearTimeout(timeoutId); + }; }, [selectedProfileId, selectedEngine]); if (isLoading) {