feat: Revamp voice mixer UI with new layout and enhanced voice management features

This commit is contained in:
JB
2025-10-06 05:32:12 -07:00
parent b718dae1b3
commit 0c47067cb8
3 changed files with 557 additions and 263 deletions
+191 -39
View File
@@ -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;
}
}
+347 -223
View File
@@ -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 = "<p class=\"tag\">No voices available.</p>";
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 = "&times;";
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") {
+19 -1
View File
@@ -47,7 +47,25 @@
<span data-role="profile-summary">Select or create a profile to begin.</span>
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
</div>
<div class="voice-editor__grid" data-role="voice-grid"></div>
<div class="voice-editor__canvas">
<section class="voice-available">
<header class="voice-available__header">
<h2>Available voices</h2>
<p class="tag">Drag into the mixer or click Add.</p>
</header>
<div class="voice-available__list" data-role="available-voices"></div>
</section>
<section class="voice-mix">
<header class="voice-mix__header">
<h2>Your mix</h2>
<p class="tag">Drop voices below to set levels.</p>
</header>
<div class="voice-mix__dropzone" data-role="dropzone">
<p class="voice-mix__empty" data-role="mix-empty">Drag voices here or tap “Add”.</p>
<div class="voice-mix__list" data-role="selected-voices"></div>
</div>
</section>
</div>
<div class="voice-editor__actions">
<div class="button-row">
<button type="submit" class="button" data-role="save-profile" disabled>Save profile</button>