- Added a new 'Table View' button to the library sort bar. - Implemented a CSS grid layout to display voice properties in a high-density table. - Added sortable column headers (Name, Lang, Gender, Speed, dBFS, Length, Rating, Source, Seed, Note, Tags, Active). - Aligned CSS grid to account for the bulk edit checkbox injection. - Added drag-and-drop profile image support to the Inspector's large avatar. - Ensured picture updates instantly synchronize across the List, Table, and Inspector views.
507 lines
25 KiB
JavaScript
507 lines
25 KiB
JavaScript
// ── Voice inspector (3-pane workbench) ───────────────────────────────────
|
|
let _selectedVoiceWrap = null;
|
|
|
|
function selectVoice(wrap) {
|
|
const inspector = document.getElementById('voices-inspector');
|
|
if (!inspector) return;
|
|
|
|
function restoreToRow(targetWrap) {
|
|
// Restore all extracted elements back to their original DOM parents
|
|
(targetWrap._extracted || []).forEach(({el, target}) => {
|
|
if (el && target) target.appendChild(el);
|
|
});
|
|
targetWrap._extracted = [];
|
|
// Move main-row and optimizer back from inspector body to wrap
|
|
inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el));
|
|
}
|
|
|
|
// Deselect previous voice
|
|
if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) {
|
|
_selectedVoiceWrap.classList.remove('vr-selected');
|
|
restoreToRow(_selectedVoiceWrap);
|
|
}
|
|
|
|
if (_selectedVoiceWrap === wrap) {
|
|
restoreToRow(wrap);
|
|
wrap.classList.remove('vr-selected');
|
|
_selectedVoiceWrap = null;
|
|
inspector.innerHTML = '<div class="inspector-placeholder"><span><span class="mdi mdi-account-voice"></span></span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
|
if (typeof window.onMobileInspectorClose === 'function') window.onMobileInspectorClose();
|
|
return;
|
|
}
|
|
|
|
_selectedVoiceWrap = wrap;
|
|
wrap.classList.add('vr-selected');
|
|
if (typeof window.onMobileInspectorOpen === 'function') window.onMobileInspectorOpen();
|
|
|
|
const voiceId = wrap.dataset.id || '';
|
|
const color = wrap.dataset.color || '#9575CD';
|
|
const isClone = wrap.dataset.hasRef === 'true';
|
|
const dbfs = wrap.dataset.dbfs || '-';
|
|
const hasPicture = wrap.dataset.hasPicture === 'true';
|
|
|
|
// Live voice object — authoritative for mutable fields
|
|
const v = (_voices || []).find(vv => vv.id === voiceId) || {};
|
|
const langCode = v.lang || wrap.dataset.lang || '';
|
|
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
|
|
const rating = v.rating || 0;
|
|
|
|
const nameParts = voiceId.split('_');
|
|
// Prefer an explicit display name (e.g. the character name for Rehearser voices);
|
|
// otherwise fall back to the last segment of the ID (EN_F_Anna → "Anna").
|
|
const dispName = (v.name && String(v.name).trim())
|
|
? String(v.name).trim()
|
|
: (nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId);
|
|
const initial = (dispName[0] || voiceId[0] || '?').toUpperCase();
|
|
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
|
|
const inspFlagCc = flagCc || langCode;
|
|
const inspFlagIcon = inspFlagCc
|
|
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
|
|
|
|
const inspIconHtml = window.voiceAvatarIcon ? window.voiceAvatarIcon(v.avatar, 48) : null;
|
|
const avatarHtml = picSrcInsp
|
|
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
|
: inspIconHtml || inspFlagIcon || initial;
|
|
const avatarBgStyle = (picSrcInsp || inspIconHtml || inspFlagIcon) ? '' : `style="background:${color}"`;
|
|
|
|
const flagIconHtml = inspFlagIcon || '';
|
|
|
|
const makeStars = n => [1,2,3,4,5].map(i =>
|
|
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}"><span class="mdi mdi-star"></span></span>`
|
|
).join('');
|
|
|
|
const inspBenchText = typeof fmtBenchmark === 'function' ? fmtBenchmark(v) : '-';
|
|
const inspBenchTitle = typeof benchmarkTitle === 'function' ? benchmarkTitle(v) : '';
|
|
const inspBenchCls = typeof benchmarkClass === 'function' ? benchmarkClass(v) : '';
|
|
|
|
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
|
|
|
|
// Gender maps — used in template AND in event handlers
|
|
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
|
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
|
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
|
const curGender = v.gender || 'N';
|
|
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
|
|
|
inspector.innerHTML = `
|
|
<div class="inspector-header">
|
|
<div class="insp-hd-top">
|
|
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
|
|
<div class="insp-title-stack">
|
|
<div class="insp-hd-row1">
|
|
<h3 class="insp-disp-name" title="Double-click to rename the display name">${escHtml(dispName)}</h3>
|
|
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
|
|
<div class="insp-actions-save"></div>
|
|
</div>
|
|
<div class="insp-hd-row2">
|
|
<div class="insp-id-block">
|
|
<span class="insp-full-id" title="Click to rename the voice ID">${escHtml(voiceId)}</span>
|
|
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
|
|
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
|
</div>
|
|
<div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
|
|
<div class="insp-actions-active"></div>
|
|
${(v.seed !== undefined && v.seed !== null) ? `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px;">Pin Seed # ${v.seed}</div>` : `<div class="insp-pinned-seed" style="color:#d32f2f; font-weight:600; font-size:12px; display:none;"></div>`}
|
|
</div>
|
|
</div>
|
|
<div class="insp-hd-divider"></div>
|
|
<div class="insp-subtitle">
|
|
<span class="insp-flag" title="Click to change accent flag">
|
|
<span class="insp-flag-icon">${flagIconHtml}</span>
|
|
</span>
|
|
<span class="insp-lang-wrap" title="Click to change language">
|
|
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
|
</span>
|
|
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
|
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
|
${inspBenchText !== '-' ? `<span class="insp-bench-chip ${escHtml(inspBenchCls)}" title="${escHtml(inspBenchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(inspBenchText)}</span>` : ''}
|
|
<span class="insp-stars-wrap">
|
|
<span class="insp-stars">${makeStars(rating)}</span>
|
|
<span class="insp-rating-label">${rating}/5</span>
|
|
</span>
|
|
<input class="insp-tag-input" type="text" placeholder="tag1, tag2…" value="${escHtml(v.tag||'')}" autocomplete="off">
|
|
</div>
|
|
<div class="insp-note-delete-row">
|
|
<div class="insp-note-slot"></div>
|
|
<div class="insp-actions-delete"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="inspector-body"></div>
|
|
`;
|
|
|
|
// Inject mobile back button after HTML is set (inspector DOM recreated above)
|
|
if (window.innerWidth <= 767 && typeof window.onMobileInspectorOpen === 'function') {
|
|
window.onMobileInspectorOpen();
|
|
}
|
|
|
|
const saveSlot = inspector.querySelector('.insp-actions-save');
|
|
const activeSlot = inspector.querySelector('.insp-actions-active');
|
|
const deleteSlot = inspector.querySelector('.insp-actions-delete');
|
|
const body = inspector.querySelector('.inspector-body');
|
|
|
|
// ── Save changes → row 1 right ───────────────────────────────────────────
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.className = 'btn-primary insp-save-btn';
|
|
saveBtn.textContent = 'Save changes';
|
|
saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click());
|
|
saveSlot.appendChild(saveBtn);
|
|
|
|
// ── Active toggle → row 2 right ──────────────────────────────────────────
|
|
const detailRow = wrap.querySelector('.vr-detail-row');
|
|
const activeEl = detailRow?.querySelector('.vr-detail-active');
|
|
const deleteEl = detailRow?.querySelector('.vr-detail-delete');
|
|
if (activeEl) activeSlot.appendChild(activeEl);
|
|
|
|
// ── Delete → note row right ───────────────────────────────────────────────
|
|
if (deleteEl) {
|
|
deleteSlot.appendChild(deleteEl);
|
|
const _dBtn = deleteEl.querySelector('.delete-btn');
|
|
const _dCnl = deleteEl.querySelector('.delete-confirm-cancel');
|
|
_dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending'));
|
|
_dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending'));
|
|
}
|
|
|
|
// ── Avatar click → photo upload ───────────────────────────────────────────
|
|
const inspAvatar = inspector.querySelector('.inspector-avatar');
|
|
inspAvatar.addEventListener('click', () => {
|
|
body.querySelector('.photo-input')?.click();
|
|
});
|
|
|
|
inspAvatar.addEventListener('dragenter', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
|
inspAvatar.addEventListener('dragover', e => { e.preventDefault(); inspAvatar.classList.add('drag-over'); });
|
|
inspAvatar.addEventListener('dragleave', () => inspAvatar.classList.remove('drag-over'));
|
|
inspAvatar.addEventListener('drop', async e => {
|
|
e.preventDefault();
|
|
inspAvatar.classList.remove('drag-over');
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const photoInput = body.querySelector('.photo-input');
|
|
if (photoInput) {
|
|
photoInput.files = e.dataTransfer.files;
|
|
photoInput.dispatchEvent(new Event('change'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
let url = e.dataTransfer.getData('text/uri-list');
|
|
if (!url) {
|
|
const html = e.dataTransfer.getData('text/html');
|
|
if (html) {
|
|
const match = html.match(/src=["'](.*?)["']/);
|
|
if (match) url = match[1];
|
|
}
|
|
}
|
|
if (!url) url = e.dataTransfer.getData('text/plain');
|
|
|
|
if (url && /^https?:\/\//i.test(url)) {
|
|
if (typeof status === 'function') status('Downloading picture from URL...');
|
|
try {
|
|
const r = await fetch('/api/voice/picture-url', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({voice_id: voiceId, image_url: url})
|
|
});
|
|
if (!r.ok) throw new Error((await r.json()).detail);
|
|
|
|
// Trigger the photo update in the row
|
|
const photoCell = body.querySelector('.vr-photo');
|
|
if (photoCell) photoCell.dispatchEvent(new Event('update-photo'));
|
|
|
|
if (typeof toast === 'function') toast('Photo saved from URL', 'success');
|
|
if (typeof status === 'function') status('Photo saved successfully');
|
|
} catch(err) {
|
|
if (typeof toast === 'function') toast('Photo URL download failed: ' + err.message, 'error');
|
|
if (typeof status === 'function') status('Photo URL download failed');
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Copy ID button ────────────────────────────────────────────────────────
|
|
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
|
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
|
});
|
|
|
|
// ── Editing: the big header is the DISPLAY NAME (v.name); "edit ID" / the
|
|
// full id renames the underlying voice ID. ───────────────────────────────
|
|
const dispNameEl = inspector.querySelector('.insp-disp-name');
|
|
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
|
const fullIdEl = inspector.querySelector('.insp-full-id');
|
|
let _editMode = null; // 'name' | 'id'
|
|
let _editCancelled = false;
|
|
|
|
const showEditor = (mode, val) => {
|
|
_editMode = mode; _editCancelled = false;
|
|
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
|
|
nameEditEl.style.display = 'block';
|
|
nameEditEl.placeholder = mode === 'name' ? 'Display name' : 'Voice ID';
|
|
nameEditEl.value = val; nameEditEl.focus(); nameEditEl.select();
|
|
};
|
|
const startNameEdit = () => showEditor('name', dispName); // edit display name
|
|
const startInspRename = () => showEditor('id', voiceId); // rename voice ID
|
|
|
|
const commitEdit = async () => {
|
|
const mode = _editMode; _editMode = null;
|
|
dispNameEl.style.display = ''; fullIdEl.style.display = '';
|
|
nameEditEl.style.display = 'none';
|
|
if (_editCancelled || !mode) return;
|
|
const val = nameEditEl.value.trim();
|
|
if (mode === 'id') {
|
|
if (!val || val === voiceId) return;
|
|
const rowInput = wrap.querySelector('.vr-name-input');
|
|
const rowOk = wrap.querySelector('.rename-ok');
|
|
if (rowInput && rowOk) { rowInput.value = val; rowOk.click(); }
|
|
return;
|
|
}
|
|
// mode === 'name' → save a display name to the voice's metadata
|
|
if (val === dispName) return;
|
|
try {
|
|
const r = await fetch('/api/voice/meta', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice_id: voiceId, name: val }),
|
|
});
|
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
|
v.name = val;
|
|
dispNameEl.textContent = val || (voiceId.split('_').pop());
|
|
if (typeof toast === 'function') toast('Display name updated', 'success');
|
|
if (typeof renderVoiceList === 'function') renderVoiceList();
|
|
} catch (e) {
|
|
if (typeof toast === 'function') toast('Rename failed: ' + (e.message || e), 'error');
|
|
}
|
|
};
|
|
|
|
dispNameEl.addEventListener('dblclick', startNameEdit);
|
|
fullIdEl.addEventListener('dblclick', startInspRename);
|
|
fullIdEl.addEventListener('click', startInspRename);
|
|
inspector.querySelector('.insp-edit-id-btn')?.addEventListener('click', startInspRename);
|
|
nameEditEl.addEventListener('blur', commitEdit);
|
|
nameEditEl.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') nameEditEl.blur();
|
|
if (e.key === 'Escape') { _editCancelled = true; nameEditEl.blur(); }
|
|
});
|
|
|
|
// ── Flag (accent/country) — decoupled from language ──────────────────────
|
|
const flagSpan = inspector.querySelector('.insp-flag');
|
|
const flagIconEl = inspector.querySelector('.insp-flag-icon');
|
|
|
|
const applyFlag = async (cc) => {
|
|
v.flag = cc; wrap.dataset.flagCc = cc;
|
|
const fi = `<span class="fi fi-${cc.toLowerCase()}" role="img" aria-label="${cc}"></span>`;
|
|
if (flagIconEl) flagIconEl.innerHTML = fi;
|
|
await saveMeta(voiceId, { flag: cc });
|
|
};
|
|
flagSpan.addEventListener('click', () => {
|
|
// Show this language's accents first, then the complete world list — accent is
|
|
// decoupled from language, so e.g. an English voice can still be set to German.
|
|
const langList = FLAG_OPTIONS[langCode.toUpperCase()] || [];
|
|
const items = uniqueFlagOptions([langList, ALL_FLAGS]).map(([cc, name]) => [cc.toLowerCase(), name]);
|
|
createSearchablePicker(flagSpan, items, applyFlag, {
|
|
placeholder: 'Country or accent…',
|
|
renderItem: (cc, name) =>
|
|
`<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span><span>${escHtml(name)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── Language — click lang code to change ─────────────────────────────────
|
|
const langCodeEl = inspector.querySelector('.insp-lang-code');
|
|
const langWrap = inspector.querySelector('.insp-lang-wrap');
|
|
|
|
const applyLang = async (newLang) => {
|
|
v.lang = newLang; wrap.dataset.lang = newLang;
|
|
if (langCodeEl) langCodeEl.textContent = newLang;
|
|
await saveMeta(voiceId, { lang: newLang });
|
|
};
|
|
langWrap?.addEventListener('click', () => {
|
|
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
|
createSearchablePicker(langWrap, items, applyLang, {
|
|
placeholder: 'Language…',
|
|
renderItem: (l, label) =>
|
|
`<span class="ipi-code">${escHtml(l)}</span><span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`,
|
|
});
|
|
});
|
|
|
|
// ── Gender label (subtitle) — click to cycle ──────────────────────────────
|
|
const genderLabelEl = inspector.querySelector('.insp-gender-label');
|
|
const genderSel = inspector.querySelector('.insp-gender-sel');
|
|
const applyGender = async (ng) => {
|
|
v.gender = ng;
|
|
if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`;
|
|
if (genderSel) genderSel.value = ng;
|
|
const gBadge = wrap.querySelector('.gender-badge');
|
|
if (gBadge) {
|
|
gBadge.innerHTML = `<span class="gender-sym">${_gM[ng]||'?'}</span><span class="gender-txt">${_gL[ng]||'—'}</span>`;
|
|
gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n');
|
|
}
|
|
await saveMeta(voiceId, { gender: ng });
|
|
};
|
|
genderLabelEl?.addEventListener('click', () => {
|
|
const cycle = ['F','M','N'];
|
|
applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]);
|
|
});
|
|
genderSel?.addEventListener('change', () => applyGender(genderSel.value));
|
|
|
|
// ── Tag input — comma-separated, autocomplete from DB + localStorage ────────
|
|
const tagInput = inspector.querySelector('.insp-tag-input');
|
|
|
|
const getAllKnownTags = () => {
|
|
const fromDb = (_voices || []).flatMap(vv =>
|
|
(vv.tag || '').split(',').map(t => t.trim()).filter(Boolean));
|
|
const fromStorage = getStoredTags();
|
|
return [...new Set([...fromStorage, ...fromDb])].sort((a, b) => a.localeCompare(b));
|
|
};
|
|
const tagLastToken = val => val.split(',').pop().trimStart();
|
|
const tagReplaceLastToken = (val, rep) => {
|
|
const parts = val.split(',');
|
|
parts[parts.length - 1] = parts.length > 1 ? ' ' + rep : rep;
|
|
return parts.join(',');
|
|
};
|
|
|
|
let _tagDrop = null;
|
|
const hideTagDrop = () => { _tagDrop?.remove(); _tagDrop = null; };
|
|
const showTagDrop = () => {
|
|
hideTagDrop();
|
|
const token = tagLastToken(tagInput.value);
|
|
const all = getAllKnownTags();
|
|
const matches = all.filter(t =>
|
|
t.toLowerCase().startsWith(token.toLowerCase()) && t.toLowerCase() !== token.toLowerCase()
|
|
);
|
|
if (!matches.length) return;
|
|
|
|
_tagDrop = document.createElement('div');
|
|
_tagDrop.className = 'tag-suggest';
|
|
matches.slice(0, 10).forEach(tag => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button'; btn.className = 'tag-suggest-item';
|
|
btn.textContent = tag;
|
|
btn.addEventListener('mousedown', e => {
|
|
e.preventDefault();
|
|
tagInput.value = tagReplaceLastToken(tagInput.value, tag);
|
|
tagInput.dispatchEvent(new Event('input'));
|
|
hideTagDrop();
|
|
tagInput.focus();
|
|
});
|
|
_tagDrop.appendChild(btn);
|
|
});
|
|
document.body.appendChild(_tagDrop);
|
|
const r = tagInput.getBoundingClientRect();
|
|
_tagDrop.style.top = (r.bottom + 3) + 'px';
|
|
_tagDrop.style.left = r.left + 'px';
|
|
_tagDrop.style.minWidth = Math.max(r.width, 140) + 'px';
|
|
};
|
|
|
|
tagInput.addEventListener('input', () => showTagDrop());
|
|
tagInput.addEventListener('focus', () => showTagDrop());
|
|
tagInput.addEventListener('blur', () => setTimeout(hideTagDrop, 150));
|
|
tagInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') hideTagDrop();
|
|
if (e.key === ',' && _tagDrop) setTimeout(showTagDrop, 10);
|
|
});
|
|
|
|
const saveTagValue = debounce(async () => {
|
|
const tags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean);
|
|
tags.forEach(addStoredTag);
|
|
v.tag = tagInput.value;
|
|
await saveMeta(voiceId, { tag: tagInput.value });
|
|
}, 700);
|
|
tagInput.addEventListener('input', saveTagValue);
|
|
tagInput.addEventListener('change', saveTagValue);
|
|
|
|
// ── Interactive rating (subtitle + meta kept in sync) ─────────────────────
|
|
const updateRating = async (newRating) => {
|
|
v.rating = newRating; wrap.dataset.rating = newRating;
|
|
inspector.querySelectorAll('.insp-star').forEach(s =>
|
|
s.classList.toggle('on', parseInt(s.dataset.val) <= newRating));
|
|
inspector.querySelectorAll('.insp-rating-label, .insp-meta-rating-label').forEach(el =>
|
|
el.textContent = `${newRating}/5`);
|
|
wrap.querySelectorAll('.vr-rating .star').forEach((s, i) =>
|
|
s.classList.toggle('on', i < newRating));
|
|
await saveMeta(voiceId, { rating: newRating });
|
|
};
|
|
['.insp-stars', '.insp-meta-stars'].forEach(sel => {
|
|
const stars = [...inspector.querySelectorAll(`${sel} .insp-star`)];
|
|
stars.forEach(s => {
|
|
s.addEventListener('click', () => {
|
|
const val = parseInt(s.dataset.val);
|
|
updateRating(val === (v.rating||0) ? 0 : val);
|
|
});
|
|
s.addEventListener('mouseenter', () => {
|
|
const val = parseInt(s.dataset.val);
|
|
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= val));
|
|
});
|
|
s.addEventListener('mouseleave', () =>
|
|
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= (v.rating||0))));
|
|
});
|
|
});
|
|
|
|
// ── Note row ──────────────────────────────────────────────────────────────
|
|
const noteEl = detailRow?.querySelector('.vr-note');
|
|
const noteRowEl = inspector.querySelector('.insp-note-slot');
|
|
if (noteEl && noteRowEl) noteRowEl.appendChild(noteEl);
|
|
|
|
// ── Move main-row and optimizer into inspector body ───────────────────────
|
|
const mainRow = wrap.querySelector('.vr-main-row');
|
|
const optimizer = wrap.querySelector('.vr-optimizer');
|
|
if (mainRow) body.appendChild(mainRow);
|
|
if (optimizer) body.appendChild(optimizer);
|
|
|
|
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e));
|
|
|
|
// ── Persona panel ─────────────────────────────────────────────────────────
|
|
const personaVal = v.persona || '';
|
|
const personaPanel = document.createElement('div');
|
|
personaPanel.className = 'insp-persona-panel';
|
|
personaPanel.innerHTML = `
|
|
<div class="insp-persona-head">
|
|
<span class="insp-persona-title">Character persona</span>
|
|
<span class="insp-persona-hint">Used by LLM to rewrite text in this voice's style. Leave empty to skip.</span>
|
|
</div>
|
|
<textarea class="insp-persona-input" rows="3" placeholder="e.g. calm British narrator with a warm tone, measured pace, formal vocabulary…">${escHtml(personaVal)}</textarea>
|
|
<div class="insp-persona-actions">
|
|
<button class="btn-primary btn-sm insp-persona-save-btn">Save persona</button>
|
|
<span class="insp-persona-status"></span>
|
|
</div>
|
|
`;
|
|
body.appendChild(personaPanel);
|
|
|
|
const personaTextarea = personaPanel.querySelector('.insp-persona-input');
|
|
const personaStatus = personaPanel.querySelector('.insp-persona-status');
|
|
personaPanel.querySelector('.insp-persona-save-btn').addEventListener('click', async () => {
|
|
const pText = personaTextarea.value.trim();
|
|
try {
|
|
await fetch('/api/voice/meta', { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ voice_id: voiceId, persona: pText }) });
|
|
v.persona = pText;
|
|
personaStatus.textContent = 'Saved.';
|
|
setTimeout(() => { personaStatus.textContent = ''; }, 2000);
|
|
toast('Persona saved', 'success');
|
|
} catch(e) { toast('Save failed: ' + e.message, 'error'); }
|
|
});
|
|
|
|
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
|
if (maintTitle) {
|
|
maintTitle.innerHTML = `<span class="opt-chevron"></span><span class="opt-title-text">Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span></span>`;
|
|
}
|
|
|
|
// ── Collapsible opt-groups ────────────────────────────────────────────────
|
|
body.querySelectorAll('.opt-group').forEach(group => {
|
|
const title = group.querySelector(':scope > .opt-group-title');
|
|
if (!title) return;
|
|
title.addEventListener('click', () => {
|
|
group.classList.toggle('open');
|
|
});
|
|
});
|
|
|
|
// ── Seed Finder panel ────────────────────────────────────────────────────
|
|
if (typeof attachSeedFinder === 'function') {
|
|
attachSeedFinder(voiceId, body);
|
|
}
|
|
|
|
// Track extracted elements for restoreToRow
|
|
wrap._extracted = [
|
|
activeEl ? {el: activeEl, target: detailRow} : null,
|
|
deleteEl ? {el: deleteEl, target: detailRow} : null,
|
|
noteEl ? {el: noteEl, target: detailRow} : null,
|
|
].filter(Boolean);
|
|
|
|
}
|