Feat: Podcast maker UI improvements and voice clone panel
- Add podcast feature to step4_assets router for podcast mode - Enhance analysis tab navigation with gradient styling - Move cost estimate display to TopicUrlInput component - Add voice clone panel with toggle and preview functionality - Improve podcast dashboard header with gradient background - Add step indicator and improved styling to TopicUrlInput - Update AvatarSelector with refined styling - Enhance PodcastConfiguration with better layout - Improve Header component with gradient and shadow effects
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -15,17 +15,23 @@ import {
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Mic,
|
||||
PlayArrow,
|
||||
Pause,
|
||||
CloudUpload,
|
||||
HelpOutline,
|
||||
AutoAwesome,
|
||||
CheckCircle,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
} from "@mui/icons-material";
|
||||
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
||||
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
|
||||
|
||||
export type VoiceOption = {
|
||||
id: string;
|
||||
@@ -75,6 +81,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
|
||||
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
|
||||
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
|
||||
const [showVoiceClonePanel, setShowVoiceClonePanel] = useState(false);
|
||||
const [voiceCreated, setVoiceCreated] = useState(false);
|
||||
const [useCreatedVoice, setUseCreatedVoice] = useState(true);
|
||||
|
||||
const fetchVoiceClone = async () => {
|
||||
try {
|
||||
setLoadingVoiceClone(true);
|
||||
const result = await getLatestVoiceClone();
|
||||
setVoiceClone(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch voice clone:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setLoadingVoiceClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const options: VoiceOption[] = [...PREDEFINED_VOICES];
|
||||
@@ -101,19 +124,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVoiceClone) return;
|
||||
|
||||
const fetchVoiceClone = async () => {
|
||||
try {
|
||||
setLoadingVoiceClone(true);
|
||||
const result = await getLatestVoiceClone();
|
||||
setVoiceClone(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch voice clone:", error);
|
||||
} finally {
|
||||
setLoadingVoiceClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVoiceClone();
|
||||
}, [showVoiceClone]);
|
||||
|
||||
@@ -154,6 +164,30 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
const isVoiceCloneSelected = value === VOICE_CLONE_ID ||
|
||||
(voiceClone?.success && voiceClone.custom_voice_id && value === voiceClone.custom_voice_id);
|
||||
|
||||
const handleVoiceSet = useCallback(() => {
|
||||
setVoiceCreated(true);
|
||||
setUseCreatedVoice(true);
|
||||
}, []);
|
||||
|
||||
const handleDoneWithVoice = useCallback(() => {
|
||||
if (useCreatedVoice) {
|
||||
fetchVoiceClone();
|
||||
}
|
||||
setShowVoiceClonePanel(false);
|
||||
setVoiceCreated(false);
|
||||
}, [useCreatedVoice]);
|
||||
|
||||
const handleTogglePanel = useCallback(() => {
|
||||
if (showVoiceClonePanel) {
|
||||
setShowVoiceClonePanel(false);
|
||||
setVoiceCreated(false);
|
||||
} else {
|
||||
setShowVoiceClonePanel(true);
|
||||
setVoiceCreated(false);
|
||||
setUseCreatedVoice(true);
|
||||
}
|
||||
}, [showVoiceClonePanel]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<FormControl fullWidth size="small">
|
||||
@@ -183,18 +217,65 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Voice
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.08)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "3px",
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>4</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Mic fontSize="small" sx={{ color: "#667eea" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: "#0f172a", fontSize: "1rem" }}>
|
||||
Voice Selection
|
||||
</Typography>
|
||||
<Tooltip title="Choose a system voice or your custom cloned voice" arrow>
|
||||
<IconButton size="small" sx={{ color: "rgba(0,0,0,0.5)" }}>
|
||||
<IconButton size="small" sx={{ color: "#94a3b8", "&:hover": { color: "#667eea" } }}>
|
||||
<HelpOutline fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{showVoiceClone && loadingVoiceClone && (
|
||||
<CircularProgress size={16} sx={{ ml: 1 }} />
|
||||
<CircularProgress size={16} sx={{ ml: 1, color: "#667eea" }} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -210,8 +291,8 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
);
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Mic fontSize="small" sx={{ color: voice?.isCustom ? "#667eea" : "inherit" }} />
|
||||
<Typography>{voice?.name}</Typography>
|
||||
<Mic fontSize="small" sx={{ color: voice?.isCustom ? "#667eea" : "#64748b" }} />
|
||||
<Typography sx={{ fontWeight: 500 }}>{voice?.name}</Typography>
|
||||
{voice?.isCustom && (
|
||||
<Chip
|
||||
label="Cloned"
|
||||
@@ -221,22 +302,44 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
color: "#667eea",
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
border: "2px solid rgba(102, 126, 234, 0.2)",
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.1)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
boxShadow: "0 0 0 4px rgba(102, 126, 234, 0.1)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 400,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id && (
|
||||
<MenuItem value={VOICE_CLONE_ID} sx={{ borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<MenuItem value={VOICE_CLONE_ID} sx={{ borderBottom: '1px solid rgba(0,0,0,0.1)', py: 1.5 }}>
|
||||
<ListItemIcon>
|
||||
<AutoAwesome sx={{ color: "#667eea" }} />
|
||||
</ListItemIcon>
|
||||
@@ -255,6 +358,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
color: "#10b981",
|
||||
height: 20,
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': { color: "#10b981" }
|
||||
}}
|
||||
/>
|
||||
@@ -273,7 +377,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
previewUrl: voiceClone.preview_audio_url
|
||||
});
|
||||
}}
|
||||
sx={{ mt: 0.5, textTransform: 'none' }}
|
||||
sx={{ mt: 0.5, textTransform: 'none', color: "#667eea" }}
|
||||
>
|
||||
{playingPreview === VOICE_CLONE_ID ? "Stop" : "Preview"}
|
||||
</Button>
|
||||
@@ -283,14 +387,17 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem disabled sx={{ opacity: 0.6 }}>
|
||||
<Typography variant="caption">System Voices</Typography>
|
||||
<MenuItem disabled sx={{ opacity: 0.6, py: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>System Voices</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{voiceOptions.filter(v => !v.isCustom).map((voice) => (
|
||||
<MenuItem key={voice.id} value={voice.id}>
|
||||
<MenuItem key={voice.id} value={voice.id} sx={{ py: 1.5 }}>
|
||||
<ListItemIcon>
|
||||
<Mic fontSize="small" sx={{ color: "#64748b" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={voice.name}
|
||||
primary={<Typography sx={{ fontWeight: 500 }}>{voice.name}</Typography>}
|
||||
secondary={voice.personality?.split(' - ')[0]}
|
||||
/>
|
||||
</MenuItem>
|
||||
@@ -299,23 +406,146 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
</FormControl>
|
||||
|
||||
{selectedVoice?.personality && (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 0.5, display: 'block' }}>
|
||||
{selectedVoice.personality}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: alpha("#f0f4ff", 0.6), borderRadius: 1.5, border: "1px solid rgba(99, 102, 241, 0.15)" }}>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.5 }}>
|
||||
<strong style={{ color: "#0f172a" }}>Voice Personality:</strong> {selectedVoice.personality}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showVoiceClone && !voiceClone?.success && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: alpha("#f8fafc", 0.5), borderRadius: 2, border: '1px dashed rgba(0,0,0,0.1)' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<CloudUpload sx={{ color: "#64748b" }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Don't see your voice? Go to Onboarding → Voice Cloning to create your custom voice clone.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
onClick={handleTogglePanel}
|
||||
startIcon={showVoiceClonePanel ? <ExpandLess /> : <AutoAwesome />}
|
||||
endIcon={showVoiceClonePanel ? <ExpandLess /> : <ExpandMore />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
width: "100%",
|
||||
background: showVoiceClonePanel
|
||||
? "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||
border: showVoiceClonePanel
|
||||
? "1px solid rgba(102, 126, 234, 0.3)"
|
||||
: "1px dashed rgba(102, 126, 234, 0.4)",
|
||||
borderRadius: 2,
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%)",
|
||||
borderColor: "#667eea",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showVoiceClonePanel ? "Hide Voice Cloning" : "Create Your Voice Clone"}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showVoiceClonePanel}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
boxShadow: "inset 0 1px 3px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<VoiceAvatarPlaceholder
|
||||
domainName="Podcast"
|
||||
onVoiceSet={handleVoiceSet}
|
||||
/>
|
||||
|
||||
{voiceCreated && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
|
||||
border: "1px solid rgba(16, 185, 129, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||
<CheckCircle sx={{ color: "#10b981", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: "#10b981", fontWeight: 700 }}>
|
||||
Voice Clone Created Successfully!
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#475569", mb: 1.5, fontSize: "0.875rem" }}>
|
||||
Your custom voice clone is ready. Would you like to use this voice for your podcast?
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useCreatedVoice}
|
||||
onChange={(e) => setUseCreatedVoice(e.target.checked)}
|
||||
sx={{
|
||||
color: "#10b981",
|
||||
"&.Mui-checked": { color: "#10b981" },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ color: "#1e293b", fontWeight: 500, fontSize: "0.9375rem" }}>
|
||||
Use this voice for my podcast
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 2, borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowVoiceClonePanel(false);
|
||||
setVoiceCreated(false);
|
||||
}}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": { color: "#1e293b", background: "rgba(0,0,0,0.04)" },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDoneWithVoice}
|
||||
sx={{
|
||||
background: useCreatedVoice
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
boxShadow: useCreatedVoice
|
||||
? "0 4px 12px rgba(16, 185, 129, 0.3)"
|
||||
: "0 4px 12px rgba(102, 126, 234, 0.3)",
|
||||
"&:hover": {
|
||||
background: useCreatedVoice
|
||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{useCreatedVoice ? "Use This Voice" : "Done"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceSelector;
|
||||
export default VoiceSelector;
|
||||
Reference in New Issue
Block a user