Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions app/src/components/Generation/FloatingGenerateBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,7 @@ export function FloatingGenerateBox({
| 'kokoro'
| 'qwen_custom_voice';
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Auto-switch engine to match the profile
// 1. Auto-switch engine to match the profile FIRST
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
if (engine) {
form.setValue('engine', engine as EngineValue);
Expand All @@ -168,6 +165,11 @@ export function FloatingGenerateBox({
form.setValue('engine', 'qwen');
}
}

// 2. Set language AFTER engine has been switched so language dropdown receives valid options
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Pre-fill effects from profile defaults
if (
selectedProfile?.effects_chain &&
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/History/HistoryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ export function HistoryTable() {
<div
ref={scrollRef}
className={cn(
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
'flex-1 min-h-0 overflow-y-auto hover-scrollbar space-y-2 pb-4',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
Expand Down
41 changes: 32 additions & 9 deletions app/src/components/MainEditor/MainEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Sparkles, Upload } from 'lucide-react';
import { Search, Sparkles, Upload, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
Expand All @@ -12,6 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';

Expand All @@ -30,6 +31,7 @@ export function MainEditor() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [search, setSearch] = useState('');
const { toast } = useToast();

const handleImportClick = () => {
Expand All @@ -40,6 +42,7 @@ export function MainEditor() {
const file = e.target.files?.[0];
if (file) {
if (!file.name.endsWith('.voicebox.zip')) {
e.target.value = '';
toast({
title: t('main.import.invalidTitle'),
description: t('main.import.invalidDescription'),
Expand Down Expand Up @@ -78,12 +81,12 @@ export function MainEditor() {
};

return (
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
<div className="flex flex-col lg:grid lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
<div className="flex flex-col h-[40vh] max-h-[40vh] lg:h-full lg:max-h-none min-h-0 overflow-hidden relative shrink-0 lg:shrink border-b border-border/30 lg:border-b-0 pb-2 mb-2 lg:mb-0 lg:pb-0">
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />

<div className="absolute top-0 left-0 right-0 z-10">
<div className="flex items-center justify-between mb-4 px-1">
<div className="absolute top-0 left-0 right-0 z-10 pb-2 bg-background/80 backdrop-blur-sm">
<div className="flex items-center justify-between mb-3 px-1">
<h2 className="text-2xl font-bold">Voicebox</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
Expand All @@ -103,21 +106,41 @@ export function MainEditor() {
</Button>
</div>
</div>

<div className="relative px-1">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
placeholder={t('main.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9 pr-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
aria-label={t('main.clearSearch')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors z-10"
>
<X className="h-3.5 w-3.5" />
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</div>
</div>

<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
className={cn('flex-1 min-h-0 overflow-y-auto hover-scrollbar pt-24 pb-4', isPlayerVisible && 'lg:pb-32')}
>
<div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col">
<ProfileList />
<ProfileList search={search} onClearSearch={() => setSearch('')} />
</div>
</div>
</div>
</div>

<div className="flex flex-col min-h-0 overflow-hidden">
<div className="flex flex-col h-[38vh] max-h-[38vh] lg:h-full lg:max-h-none min-h-0 overflow-hidden mt-2 lg:mt-0 shrink-0 lg:shrink">
<HistoryTable />
</div>

Expand Down
9 changes: 6 additions & 3 deletions app/src/components/VoiceProfiles/ProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
<>
<Card
className={cn(
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
'cursor-pointer transition-all flex flex-col h-[162px] border-2',
disabled
? 'opacity-40 hover:opacity-60 border-border/40'
: isSelected
? 'border-accent shadow-md bg-accent/5'
: 'border-border/40 hover:border-border hover:shadow-sm',
)}
onClick={handleSelect}
tabIndex={0}
Expand Down
92 changes: 68 additions & 24 deletions app/src/components/VoiceProfiles/ProfileList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Info, Mic, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Info, Mic, Search, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
Expand All @@ -11,7 +11,12 @@ import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);

export function ProfileList() {
interface ProfileListProps {
search?: string;
onClearSearch?: () => void;
}

export function ProfileList({ search = '', onClearSearch }: ProfileListProps) {
const { t } = useTranslation();
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
Expand Down Expand Up @@ -40,6 +45,43 @@ export function ProfileList() {
};
}, [selectedProfileId, selectedEngine]);

const allProfiles = useMemo(() => profiles || [], [profiles]);
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);

/** 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, selected profile at top, then alphabetical by name
const sortedProfiles = useMemo(() => {
return [...allProfiles].sort((a, b) => {
const suppA = isSupported(a) ? 0 : 1;
const suppB = isSupported(b) ? 0 : 1;
if (suppA !== suppB) return suppA - suppB;

const selA = selectedProfileId === a.id ? 0 : 1;
const selB = selectedProfileId === b.id ? 0 : 1;
if (selA !== selB) return selA - selB;

return a.name.localeCompare(b.name);
});
}, [allProfiles, selectedEngine, selectedProfileId]);

const filteredProfiles = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return sortedProfiles;
return sortedProfiles.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.language.toLowerCase().includes(q) ||
p.preset_engine?.toLowerCase().includes(q) ||
p.default_engine?.toLowerCase().includes(q),
);
}, [sortedProfiles, search]);

if (isLoading) {
return null;
}
Expand All @@ -54,21 +96,7 @@ export function ProfileList() {
);
}

const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);

/** 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));
const hasUnsupported = filteredProfiles.some((p) => !isSupported(p));

return (
<div className="flex flex-col">
Expand All @@ -84,12 +112,27 @@ export function ProfileList() {
</Button>
</CardContent>
</Card>
) : filteredProfiles.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-10 w-10 text-muted-foreground mb-3 opacity-50" />
<p className="font-medium text-sm mb-1">{t('main.noVoicesFound')}</p>
<p className="text-xs text-muted-foreground mb-4">
{t('profiles.list.noVoicesMatch', { query: search })}
</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{onClearSearch && (
<Button variant="outline" size="sm" onClick={onClearSearch}>
{t('main.clearSearch')}
</Button>
)}
</CardContent>
</Card>
) : (
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{sortedProfiles.map((profile) => (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 p-1 pb-12 lg:pb-24">
{filteredProfiles.map((profile) => (
<div
key={profile.id}
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
className="w-full min-w-0"
ref={(el) => {
if (el) cardRefs.current.set(profile.id, el);
else cardRefs.current.delete(profile.id);
Expand All @@ -99,9 +142,9 @@ export function ProfileList() {
</div>
))}
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>{t('profiles.list.unsupportedNote')}</span>
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground pt-3 pb-8 border-t border-border/20 mt-1">
<Info className="h-4 w-4 text-accent shrink-0" />
<span className="leading-normal">{t('profiles.list.unsupportedNote')}</span>
</div>
)}
</div>
Expand All @@ -112,3 +155,4 @@ export function ProfileList() {
</div>
);
}

Loading