e.stopPropagation()}>
- {micPanelPhase === MicPanelPhase.LoadingList ? (
-
Загрузка списка микрофонов…
- ) : null}
- {micPanelPhase === MicPanelPhase.NoMicrophones ? (
+ ) : null}
+ {micFlow === MicFlow.LoadingDevices ? (
+
Запрос доступа к микрофону…
+ ) : null}
+ {micFlow === MicFlow.NoMicrophones ? (
+ <>
Нет доступных микрофонов. Проверьте разрешения браузера.
- ) : null}
- {showMicDeviceRow ? (
+
{
+ e.stopPropagation()
+ void requestMicAccessAndLoadDevices()
+ }}
+ >
+ Повторить
+
+ >
+ ) : null}
+ {showMicDeviceRow ? (
+
void onMicStartStop(ev)}
>
- {micPanelPhase === MicPanelPhase.Recording ? 'Остановить' : 'Начать'}
+
+ {micFlow === MicFlow.Recording ? 'Стоп' : 'Начать'}
+
- ) : null}
+
+ ) : null}
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ openFileDialog()
+ }
+ }}
+ >
+
+
- ) : null}
+
+ Загрузите примеры голоса
для клонирования
+
+
+ аудиофайлы в формате .mp3 или{' '}
+ .wav, не более{' '}
+ 10MB каждый
+
+
e.stopPropagation()}>
+ или
+
+
+ Записать аудио с микрофона
+
+
@@ -460,7 +543,10 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) {
disabled={training}
onClick={resetFile}
>
- Попробовать ещё
+
+ Попробовать ещё
+
+
+
{typo}
)
}
-/** Иконки из /public (как в api-keys, account) */
-const VOICE_ICON_EDIT = '/svg/account/pencil.svg'
+/** Иконка сохранения — как раньше из /public */
const VOICE_ICON_SAVE = '/svg/tic.svg'
-const VOICE_ICON_DELETE = '/svg/main_menu/trash.svg'
+
+function VoiceEditPencilIcon(props: SVGProps) {
+ return (
+
+ )
+}
+
+function VoiceDeleteBucketIcon(props: SVGProps) {
+ return (
+
+ )
+}
/** Высота поля селекта и строк меню (px) */
const VOICE_SELECT_ROW_HEIGHT = 58
@@ -77,6 +109,7 @@ const voiceActionIconButtonSx = {
height: 32,
minWidth: 32,
padding: 0,
+ fill: '#8280ff',
borderRadius: '8px',
color: '#8280ff',
'&:hover': {
@@ -155,16 +188,16 @@ const VOICE_SELECT_MENU_PROPS: Partial = {
}
type Props = {
- voices: MediaVoiceItem[]
- presets: MediaVoiceItem[]
+ voices: MediaVoice[]
+ presets: MediaPreset[]
value: string
loading: boolean
- onSelect: (item: MediaVoiceItem) => void
+ onSelect: (item: MediaVoiceOrPreset) => void
/** Токен для PATCH/DELETE голосов; без него кнопки не показываются */
accessToken?: string
onVoiceListChanged?: () => void
- /** После удаления — сбросить выбранный uid, если удалён он */
- onDeletedVoice?: (uid: string) => void
+ /** После удаления — сбросить выбранный ключ, если удалён он */
+ onDeletedVoice?: (key: string) => void
}
export function VoiceCloneSelect({
@@ -180,27 +213,29 @@ export function VoiceCloneSelect({
const { showMessage } = useShowDataStore()
const all = [...voices, ...presets]
const audioRef = useRef(null)
- const [playingUid, setPlayingUid] = useState(null)
- const [editingUid, setEditingUid] = useState(null)
+ const [playingKey, setPlayingKey] = useState(null)
+ const [editingKey, setEditingKey] = useState(null)
const [editDraft, setEditDraft] = useState('')
- const [mutatingUid, setMutatingUid] = useState(null)
+ const [mutatingKey, setMutatingKey] = useState(null)
+ const [deleteConfirm, setDeleteConfirm] = useState<{ voice: MediaVoice; anchorEl: HTMLElement } | null>(null)
const stopPreview = useCallback(() => {
const el = audioRef.current
if (el) {
el.pause()
}
- setPlayingUid(null)
+ setPlayingKey(null)
}, [])
const togglePreview = useCallback(
- (item: MediaVoiceItem) => {
+ (item: MediaVoiceOrPreset) => {
const el = audioRef.current
if (!el || !item.file?.trim()) return
- if (playingUid === item.uid) {
+ const key = getMediaVoiceOrPresetKey(item)
+ if (playingKey === key) {
el.pause()
- setPlayingUid(null)
+ setPlayingKey(null)
return
}
@@ -209,16 +244,16 @@ export function VoiceCloneSelect({
el.currentTime = 0
const p = el.play()
if (p !== undefined) {
- p.then(() => setPlayingUid(item.uid)).catch(() => setPlayingUid(null))
+ p.then(() => setPlayingKey(key)).catch(() => setPlayingKey(null))
}
},
- [playingUid]
+ [playingKey]
)
useEffect(() => {
const el = audioRef.current
if (!el) return
- const onEnded = () => setPlayingUid(null)
+ const onEnded = () => setPlayingKey(null)
el.addEventListener('ended', onEnded)
return () => {
el.removeEventListener('ended', onEnded)
@@ -233,8 +268,8 @@ export function VoiceCloneSelect({
)
const onChange = (e: SelectChangeEvent) => {
- const uid = e.target.value
- const item = all.find((v) => v.uid === uid) ?? null
+ const key = e.target.value
+ const item = all.find((v) => getMediaVoiceOrPresetKey(v) === key) ?? null
if (item) {
onSelect(item)
}
@@ -242,73 +277,77 @@ export function VoiceCloneSelect({
const closeMenuExtras = useCallback(() => {
stopPreview()
- setEditingUid(null)
+ setEditingKey(null)
setEditDraft('')
+ setDeleteConfirm(null)
}, [stopPreview])
const commitRename = useCallback(
- async (item: MediaVoiceItem) => {
+ async (voice: MediaVoice) => {
if (!accessToken) return
const trimmed = editDraft.trim()
if (!trimmed) {
showMessage('Введите название')
return
}
- setMutatingUid(item.uid)
+ const key = getMediaVoiceOrPresetKey(voice)
+ setMutatingKey(key)
try {
- const res = await patchMediaVoiceTitle(item.uid, trimmed, accessToken)
+ const res = await patchMediaVoiceTitle(voice.id, trimmed, accessToken)
if (res.status >= 400) {
showMessage('Не удалось изменить название')
return
}
- setEditingUid(null)
+ setEditingKey(null)
setEditDraft('')
onVoiceListChanged?.()
showMessage('Название изменено', 'success')
} finally {
- setMutatingUid(null)
+ setMutatingKey(null)
}
},
[accessToken, editDraft, onVoiceListChanged, showMessage]
)
- const removeVoice = useCallback(
- async (item: MediaVoiceItem) => {
+ const executeDeleteVoice = useCallback(
+ async (voice: MediaVoice) => {
if (!accessToken) return
- if (!window.confirm('Удалить этот голос?')) return
- setMutatingUid(item.uid)
+ setDeleteConfirm(null)
+ const key = getMediaVoiceOrPresetKey(voice)
+ setMutatingKey(key)
try {
- const res = await deleteMediaVoice(item.uid, accessToken)
+ const res = await deleteMediaVoice(voice.id, accessToken)
if (res.status >= 400) {
showMessage('Не удалось удалить голос')
return
}
- if (editingUid === item.uid) {
- setEditingUid(null)
+ if (editingKey === key) {
+ setEditingKey(null)
setEditDraft('')
}
- onDeletedVoice?.(item.uid)
+ onDeletedVoice?.(key)
onVoiceListChanged?.()
showMessage('Голос удалён', 'success')
} finally {
- setMutatingUid(null)
+ setMutatingKey(null)
}
},
- [accessToken, editingUid, onDeletedVoice, onVoiceListChanged, showMessage]
+ [accessToken, editingKey, onDeletedVoice, onVoiceListChanged, showMessage]
)
const flatDisabled = !loading && all.length === 0
- const renderVoiceMenuItem = (item: MediaVoiceItem, optionIndex: number, isUserVoice: boolean) => {
+ const renderVoiceMenuItem = (item: MediaVoiceOrPreset, optionIndex: number, isUserVoice: boolean) => {
+ const key = getMediaVoiceOrPresetKey(item)
const hasPreview = Boolean(item.file?.trim())
- const isEditing = editingUid === item.uid
- const busy = mutatingUid === item.uid
- const showActions = isUserVoice && Boolean(accessToken)
+ const isEditing = editingKey === key
+ const busy = mutatingKey === key
+ const showActions = isUserVoice && Boolean(accessToken) && isMediaVoice(item)
return (