diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 8f8b244..612a769 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -509,80 +509,223 @@ progress.progress::-moz-progress-bar { flex-wrap: wrap; } -.voice-editor__grid { +.voice-editor__canvas { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - max-height: 60vh; - overflow-y: auto; - padding-right: 0.5rem; + grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr); + gap: 1.5rem; + align-items: start; } -.voice-card { - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 18px; - background: rgba(15, 23, 42, 0.33); - padding: 1rem; +.voice-available, +.voice-mix { display: flex; flex-direction: column; - gap: 0.75rem; - transition: border 0.2s ease, transform 0.2s ease; + gap: 1rem; } -.voice-card:hover { - border-color: var(--accent); - transform: translateY(-1px); -} - -.voice-card__header { +.voice-available__header, +.voice-mix__header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; - flex-wrap: wrap; } -.voice-card__toggle { +.voice-available__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + max-height: 55vh; + overflow-y: auto; + padding-right: 0.5rem; +} + +.voice-available__card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.33); + padding: 0.9rem 1rem; display: flex; align-items: center; - gap: 0.5rem; - cursor: pointer; + justify-content: space-between; + gap: 0.75rem; + cursor: grab; + transition: border 0.2s ease, transform 0.2s ease, background 0.2s ease; } -.voice-card__checkbox { - width: 1rem; - height: 1rem; +.voice-available__card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; } -.voice-card__name { +.voice-available__card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.voice-available__card.is-active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + cursor: default; +} + +.voice-available__info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.voice-available__name { font-weight: 600; } -.voice-card__meta { - font-size: 0.8rem; +.voice-available__meta { + font-size: 0.78rem; color: var(--muted); } -.voice-card__value { - margin-left: auto; - font-size: 0.8rem; +.voice-available__add { + border: none; + border-radius: 12px; + padding: 0.4rem 0.85rem; + cursor: pointer; + background: rgba(56, 189, 248, 0.15); color: var(--accent); + font-weight: 600; + transition: background 0.2s ease, color 0.2s ease; } -.voice-card__body { +.voice-available__add:hover:not(:disabled) { + background: rgba(56, 189, 248, 0.25); + color: #fff; +} + +.voice-available__add:disabled { + cursor: default; + background: rgba(148, 163, 184, 0.2); + color: var(--muted); +} + +.voice-mix__dropzone { + border: 2px dashed rgba(148, 163, 184, 0.25); + border-radius: 20px; + padding: 1.25rem; + min-height: 260px; + background: rgba(15, 23, 42, 0.25); + transition: border 0.2s ease, background 0.2s ease; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-mix__dropzone.is-hovered { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.08); +} + +.voice-mix__empty { + margin: 0; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} + +.voice-mix__list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mix-voice { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.4); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.mix-voice__header { display: flex; align-items: center; gap: 0.75rem; } -.voice-card__slider { - flex: 1; - accent-color: var(--accent); +.mix-voice__info { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.voice-card__number { - width: 4.5rem; - text-align: center; +.mix-voice__title { + font-weight: 600; +} + +.mix-voice__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.mix-voice__weight { + margin-left: auto; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); +} + +.mix-voice__remove { + border: none; + border-radius: 12px; + width: 28px; + height: 28px; + background: rgba(248, 113, 113, 0.2); + color: #fca5a5; + font-size: 1.1rem; + cursor: pointer; + line-height: 1; + display: grid; + place-items: center; + transition: background 0.2s ease, color 0.2s ease; +} + +.mix-voice__remove:hover { + background: rgba(248, 113, 113, 0.35); + color: #fff; +} + +.mix-slider { + width: 100%; + appearance: none; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.25); + outline: none; + cursor: pointer; + transition: box-shadow 0.2s ease; +} + +.mix-slider:focus-visible { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2); +} + +.mix-slider::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); + border: none; +} + +.mix-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); } .voice-editor__actions { @@ -651,4 +794,13 @@ progress.progress::-moz-progress-bar { padding-bottom: 1.5rem; margin-bottom: 1.5rem; } + + .voice-editor__canvas { + grid-template-columns: 1fr; + } + + .voice-available__list { + max-height: none; + padding-right: 0; + } } diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index b8b18e0..3a9bdba 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -1,13 +1,16 @@ const setupVoiceMixer = () => { const data = window.ABOGEN_VOICE_MIXER_DATA || {}; const languages = data.languages || {}; - const voiceCatalog = data.voice_catalog || []; + const voiceCatalog = Array.isArray(data.voice_catalog) ? data.voice_catalog : []; const samples = data.sample_voice_texts || {}; let profiles = data.voice_profiles_data || {}; const app = document.getElementById("voice-mixer-app"); + if (!app) { + return; + } + const profileListEl = app.querySelector('[data-role="profile-list"]'); - const voiceGridEl = app.querySelector('[data-role="voice-grid"]'); const statusEl = app.querySelector('[data-role="status"]'); const saveBtn = app.querySelector('[data-role="save-profile"]'); const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]'); @@ -22,18 +25,25 @@ const setupVoiceMixer = () => { const languageSelect = document.getElementById("profile-language"); const speedInput = document.getElementById("preview-speed"); const importInput = document.getElementById("voice-import-input"); - const headerActions = document.querySelector('.voice-mixer__header-actions'); + const headerActions = document.querySelector(".voice-mixer__header-actions"); + const availableListEl = app.querySelector('[data-role="available-voices"]'); + const selectedListEl = app.querySelector('[data-role="selected-voices"]'); + const dropzoneEl = app.querySelector('[data-role="dropzone"]'); + const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); - if (!app) { + if (!profileListEl || !availableListEl || !selectedListEl) { return; } - if (!voiceCatalog.length) { - if (profileListEl) { - profileListEl.innerHTML = "
No voices available.
"; + const voiceLookup = new Map(); + voiceCatalog.forEach((voice) => { + if (voice && voice.id) { + voiceLookup.set(voice.id, voice); } - return; - } + }); + + const availableCards = new Map(); + const selectedControls = new Map(); const state = { selectedProfile: null, @@ -47,11 +57,25 @@ const setupVoiceMixer = () => { }, }; - const voiceControls = new Map(); let statusTimeout = null; - const voiceGenderIcon = (gender) => (gender === "Female" ? "♀" : gender === "Male" ? "♂" : "•"); - const voiceLanguageLabel = (code) => languages[code] || code.toUpperCase(); + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + const formatWeight = (value) => value.toFixed(2); + + const setSliderFill = (slider, weight) => { + const percent = Math.round(clamp(weight, 0, 1) * 100); + slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; + }; + + const voiceGenderIcon = (gender) => { + if (!gender) return "•"; + const initial = gender[0].toLowerCase(); + if (initial === "f") return "♀"; + if (initial === "m") return "♂"; + return "•"; + }; + + const voiceLanguageLabel = (code) => languages[code] || code?.toUpperCase() || ""; const clearStatus = () => { if (statusTimeout) { @@ -76,10 +100,6 @@ const setupVoiceMixer = () => { } }; - const clamp = (value, min, max) => Math.min(Math.max(value, min), max); - - const formatWeight = (value) => value.toFixed(2); - const mixTotal = () => { let total = 0; state.draft.voices.forEach((weight) => { @@ -88,16 +108,6 @@ const setupVoiceMixer = () => { return total; }; - const updateActionButtons = () => { - const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); - if (duplicateBtn) { - duplicateBtn.disabled = !hasSelection; - } - if (deleteBtn) { - deleteBtn.disabled = !hasSelection; - } - }; - const updateMixSummary = () => { if (mixTotalEl) { mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`; @@ -127,6 +137,213 @@ const setupVoiceMixer = () => { } }; + const ensureEmptyState = () => { + if (!emptyStateEl) return; + emptyStateEl.hidden = state.draft.voices.size > 0; + }; + + const updateAvailableState = () => { + availableCards.forEach(({ card, addButton }, voiceId) => { + const isActive = state.draft.voices.has(voiceId); + card.classList.toggle("is-active", isActive); + if (addButton) { + addButton.disabled = isActive; + addButton.textContent = isActive ? "Added" : "Add"; + } + }); + }; + + const setSliderFocus = (voiceId) => { + const control = selectedControls.get(voiceId); + if (control?.slider) { + control.slider.focus({ preventScroll: false }); + } + }; + + const renderSelectedVoices = () => { + selectedControls.clear(); + selectedListEl.innerHTML = ""; + + state.draft.voices.forEach((weight, voiceId) => { + const meta = voiceLookup.get(voiceId) || {}; + const card = document.createElement("div"); + card.className = "mix-voice"; + card.dataset.voiceId = voiceId; + + const header = document.createElement("div"); + header.className = "mix-voice__header"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "mix-voice__info"; + + const title = document.createElement("div"); + title.className = "mix-voice__title"; + title.textContent = meta.display_name || meta.name || voiceId; + + const metaLabel = document.createElement("div"); + metaLabel.className = "mix-voice__meta"; + const languageCode = meta.language || voiceId.charAt(0) || "a"; + metaLabel.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(meta.gender)}`; + + titleWrap.appendChild(title); + titleWrap.appendChild(metaLabel); + + const weightLabel = document.createElement("span"); + weightLabel.className = "mix-voice__weight"; + weightLabel.textContent = formatWeight(weight); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "mix-voice__remove"; + removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from mix`); + removeBtn.innerHTML = "×"; + removeBtn.addEventListener("click", () => { + state.draft.voices.delete(voiceId); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + }); + + header.appendChild(titleWrap); + header.appendChild(weightLabel); + header.appendChild(removeBtn); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "100"; + slider.step = "1"; + slider.className = "mix-slider"; + slider.value = String(Math.round(clamp(weight, 0, 1) * 100)); + setSliderFill(slider, weight); + slider.addEventListener("input", () => { + const value = clamp(Number(slider.value) / 100, 0, 1); + state.draft.voices.set(voiceId, value); + weightLabel.textContent = formatWeight(value); + setSliderFill(slider, value); + updateMixSummary(); + markDirty(); + }); + + card.appendChild(header); + card.appendChild(slider); + selectedListEl.appendChild(card); + + selectedControls.set(voiceId, { slider, weightLabel }); + }); + + ensureEmptyState(); + }; + + const renderAvailableVoices = () => { + availableCards.clear(); + availableListEl.innerHTML = ""; + + const sortedVoices = voiceCatalog + .slice() + .sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id)); + + sortedVoices.forEach((voice) => { + if (!voice?.id) { + return; + } + const card = document.createElement("article"); + card.className = "voice-available__card"; + card.draggable = true; + card.dataset.voiceId = voice.id; + card.tabIndex = 0; + + card.addEventListener("dragstart", (event) => { + card.classList.add("is-dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData("text/plain", voice.id); + } + }); + + card.addEventListener("dragend", () => { + card.classList.remove("is-dragging"); + }); + + card.addEventListener("dblclick", () => { + addVoiceToDraft(voice.id); + }); + + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + addVoiceToDraft(voice.id); + } + }); + + const info = document.createElement("div"); + info.className = "voice-available__info"; + + const name = document.createElement("div"); + name.className = "voice-available__name"; + name.textContent = voice.display_name || voice.id; + + const meta = document.createElement("div"); + meta.className = "voice-available__meta"; + const languageCode = voice.language || voice.id.charAt(0) || "a"; + meta.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(voice.gender)}`; + + info.appendChild(name); + info.appendChild(meta); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "voice-available__add"; + addButton.textContent = "Add"; + addButton.addEventListener("click", (event) => { + event.stopPropagation(); + addVoiceToDraft(voice.id); + }); + + card.appendChild(info); + card.appendChild(addButton); + availableListEl.appendChild(card); + + availableCards.set(voice.id, { card, addButton }); + }); + + updateAvailableState(); + }; + + const addVoiceToDraft = (voiceId, weight = 0.6) => { + if (!voiceLookup.has(voiceId)) { + return; + } + if (state.draft.voices.has(voiceId)) { + setSliderFocus(voiceId); + return; + } + state.draft.voices.set(voiceId, clamp(weight, 0.05, 1)); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + setSliderFocus(voiceId); + }; + + const buildProfilePayload = () => + Array.from(state.draft.voices.entries()).map(([voiceId, weight]) => ({ + id: voiceId, + weight, + enabled: weight > 0, + })); + + const updateActionButtons = () => { + const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); + if (duplicateBtn) { + duplicateBtn.disabled = !hasSelection; + } + if (deleteBtn) { + deleteBtn.disabled = !hasSelection; + } + }; + const applyDraftToControls = () => { if (nameInput) { nameInput.value = state.draft.name || ""; @@ -134,213 +351,23 @@ const setupVoiceMixer = () => { if (languageSelect) { languageSelect.value = state.draft.language || "a"; } - - voiceControls.forEach((control, voiceId) => { - const weight = state.draft.voices.get(voiceId) || 0; - const enabled = weight > 0; - control.checkbox.checked = enabled; - control.slider.disabled = !enabled; - control.number.disabled = !enabled; - control.slider.value = String(Math.round(weight * 100)); - control.number.value = formatWeight(enabled ? weight : 0); - control.weightLabel.textContent = `${formatWeight(weight)}`; - }); - + renderSelectedVoices(); updateMixSummary(); - resetDirty(); + updateAvailableState(); updateActionButtons(); - }; - - const setVoiceWeight = (voiceId, weight, enabled) => { - const normalized = enabled ? clamp(weight, 0, 1) : 0; - if (normalized > 0) { - state.draft.voices.set(voiceId, normalized); - } else { - state.draft.voices.delete(voiceId); - } - updateMixSummary(); - markDirty(); - }; - - const buildVoiceCard = (voice) => { - const card = document.createElement("div"); - card.className = "voice-card"; - card.dataset.voiceId = voice.id; - - const header = document.createElement("div"); - header.className = "voice-card__header"; - - const toggleLabel = document.createElement("label"); - toggleLabel.className = "voice-card__toggle"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.className = "voice-card__checkbox"; - toggleLabel.appendChild(checkbox); - - const nameSpan = document.createElement("span"); - nameSpan.className = "voice-card__name"; - nameSpan.textContent = voice.display_name || voice.id; - toggleLabel.appendChild(nameSpan); - - header.appendChild(toggleLabel); - - const meta = document.createElement("span"); - meta.className = "voice-card__meta"; - meta.textContent = `${voiceLanguageLabel(voice.language)} · ${voiceGenderIcon(voice.gender)}`; - header.appendChild(meta); - - const weightLabel = document.createElement("span"); - weightLabel.className = "voice-card__value"; - weightLabel.textContent = "0.00"; - header.appendChild(weightLabel); - - const body = document.createElement("div"); - body.className = "voice-card__body"; - - const slider = document.createElement("input"); - slider.type = "range"; - slider.min = "0"; - slider.max = "100"; - slider.step = "1"; - slider.value = "0"; - slider.disabled = true; - slider.className = "voice-card__slider"; - - const number = document.createElement("input"); - number.type = "number"; - number.min = "0"; - number.max = "1"; - number.step = "0.01"; - number.value = "0.00"; - number.disabled = true; - number.className = "voice-card__number"; - - body.appendChild(slider); - body.appendChild(number); - - card.appendChild(header); - card.appendChild(body); - - checkbox.addEventListener("change", () => { - const enabled = checkbox.checked; - slider.disabled = !enabled; - number.disabled = !enabled; - if (!enabled) { - slider.value = "0"; - number.value = "0.00"; - } - const weight = enabled ? parseFloat(number.value || "0") : 0; - weightLabel.textContent = formatWeight(enabled ? weight : 0); - setVoiceWeight(voice.id, weight, enabled); - }); - - slider.addEventListener("input", () => { - const weight = clamp(parseInt(slider.value, 10) / 100, 0, 1); - number.value = formatWeight(weight); - weightLabel.textContent = formatWeight(weight); - if (!checkbox.checked && weight > 0) { - checkbox.checked = true; - slider.disabled = false; - number.disabled = false; - } - setVoiceWeight(voice.id, weight, true); - }); - - number.addEventListener("change", () => { - const weight = clamp(parseFloat(number.value || "0"), 0, 1); - number.value = formatWeight(weight); - slider.value = String(Math.round(weight * 100)); - weightLabel.textContent = formatWeight(weight); - if (!checkbox.checked && weight > 0) { - checkbox.checked = true; - slider.disabled = false; - number.disabled = false; - } - setVoiceWeight(voice.id, weight, checkbox.checked); - }); - - voiceControls.set(voice.id, { checkbox, slider, number, weightLabel }); - return card; - }; - - const buildVoiceGrid = () => { - if (!voiceGridEl) return; - voiceGridEl.innerHTML = ""; - voiceCatalog.forEach((voice) => { - voiceGridEl.appendChild(buildVoiceCard(voice)); - }); - }; - - const loadSampleText = () => { - if (!previewTextEl || !languageSelect) return; - const lang = languageSelect.value || "a"; - previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; - }; - - const selectProfile = (name) => { - state.selectedProfile = name; - state.originalName = name; - const profile = profiles[name]; - state.draft = { - name, - language: profile?.language || "a", - voices: new Map(), - }; - if (Array.isArray(profile?.voices)) { - profile.voices.forEach((entry) => { - if (Array.isArray(entry) && entry.length >= 2) { - const [voiceId, weight] = entry; - const value = parseFloat(weight); - if (!Number.isNaN(value) && value > 0) { - state.draft.voices.set(String(voiceId), clamp(value, 0, 1)); - } - } - }); - } - applyDraftToControls(); - renderProfileList(); - loadSampleText(); - setStatus(`Loaded profile “${name}”.`, "info", 2500); - }; - - const createNewProfile = () => { - state.selectedProfile = null; - state.originalName = null; - state.draft = { - name: "", - language: languageSelect ? languageSelect.value || "a" : "a", - voices: new Map(), - }; - applyDraftToControls(); - renderProfileList(); - loadSampleText(); - }; - - const buildProfilePayload = () => { - const payload = []; - voiceControls.forEach((control, voiceId) => { - const enabled = control.checkbox.checked; - const weight = enabled ? clamp(parseFloat(control.number.value || "0"), 0, 1) : 0; - payload.push({ id: voiceId, weight, enabled }); - }); - return payload; + resetDirty(); }; const renderProfileList = () => { - if (!profileListEl) return; profileListEl.innerHTML = ""; const header = document.createElement("div"); header.className = "voice-list__header"; - const title = document.createElement("h2"); - title.textContent = "Saved profiles"; - header.appendChild(title); + const heading = document.createElement("h2"); + heading.textContent = "Saved profiles"; + header.appendChild(heading); profileListEl.appendChild(header); - const list = document.createElement("ul"); - list.className = "voice-list"; - const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b)); if (!names.length) { const empty = document.createElement("p"); @@ -350,6 +377,9 @@ const setupVoiceMixer = () => { return; } + const list = document.createElement("ul"); + list.className = "voice-list"; + names.forEach((name) => { const li = document.createElement("li"); li.className = "voice-list__item"; @@ -400,8 +430,48 @@ const setupVoiceMixer = () => { profileListEl.appendChild(list); }; + const selectProfile = (name) => { + state.selectedProfile = name; + state.originalName = name; + const profile = profiles[name]; + state.draft = { + name, + language: profile?.language || "a", + voices: new Map(), + }; + if (Array.isArray(profile?.voices)) { + profile.voices.forEach((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + const [voiceId, weight] = entry; + const value = clamp(parseFloat(weight), 0, 1); + if (!Number.isNaN(value) && value > 0) { + state.draft.voices.set(String(voiceId), value); + } + } + }); + } + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + setStatus(`Loaded profile “${name}”.`, "info", 2500); + }; + + const createNewProfile = () => { + state.selectedProfile = null; + state.originalName = null; + state.draft = { + name: "", + language: languageSelect ? languageSelect.value || "a" : "a", + voices: new Map(), + }; + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + }; + const refreshProfiles = (nextProfiles, selectedName = null) => { profiles = nextProfiles || {}; + renderProfileList(); if (selectedName && profiles[selectedName]) { selectProfile(selectedName); } else if (state.selectedProfile && profiles[state.selectedProfile]) { @@ -417,6 +487,12 @@ const setupVoiceMixer = () => { updateActionButtons(); }; + const loadSampleText = () => { + if (!previewTextEl || !languageSelect) return; + const lang = languageSelect.value || "a"; + previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; + }; + const withJson = async (response) => { if (response.ok) { return response.json(); @@ -625,6 +701,7 @@ const setupVoiceMixer = () => { nameInput.addEventListener("input", () => { state.draft.name = nameInput.value; markDirty(); + updateMixSummary(); }); } @@ -654,15 +731,62 @@ const setupVoiceMixer = () => { }); } - buildVoiceGrid(); + if (dropzoneEl) { + const setHover = (hovered) => { + dropzoneEl.classList.toggle("is-hovered", hovered); + }; + [dropzoneEl, selectedListEl].forEach((target) => { + target.addEventListener("dragover", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragenter", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragleave", (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setHover(false); + } + }); + target.addEventListener("drop", (event) => { + event.preventDefault(); + const voiceId = event.dataTransfer?.getData("text/plain"); + if (voiceId) { + addVoiceToDraft(voiceId); + } + setHover(false); + }); + }); + + dropzoneEl.addEventListener("click", () => { + const firstInactive = Array.from(availableCards.entries()).find( + ([voiceId]) => !state.draft.voices.has(voiceId), + ); + if (firstInactive) { + addVoiceToDraft(firstInactive[0]); + } + }); + } + + renderAvailableVoices(); renderProfileList(); createNewProfile(); + if (Object.keys(profiles).length) { const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0]; selectProfile(first); } + loadSampleText(); + updateActionButtons(); app.dataset.state = "ready"; + + window.addEventListener("beforeunload", () => { + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + } + }); }; if (document.readyState === "loading") { diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index f3aa57b..e9fb99e 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -47,7 +47,25 @@ Select or create a profile to begin. Total weight: 0.00 - +Drag into the mixer or click Add.
+Drop voices below to set levels.
+Drag voices here or tap “Add”.
+ +