This commit is contained in:
330
www/sounds.js
330
www/sounds.js
@@ -1,31 +1,49 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
listen: `<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2a4 4 0 0 0-4 4v3a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm0 12a5 5 0 0 0 5-5H3a5 5 0 0 0 5 5z"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listEl = el("soundList");
|
||||
const emptyEl = el("soundListEmpty");
|
||||
const createBtnEl = el("soundCreateBtn");
|
||||
const filterInputEl = el("soundsFilterInput");
|
||||
const filterCountEl = el("soundsFilterCount");
|
||||
const pageLabelEl = el("soundsPageLabel");
|
||||
const dialogEl = el("soundEditDialog");
|
||||
const formEl = el("soundEditForm");
|
||||
const titleEl = el("soundEditTitle");
|
||||
const systemBadgeEl = el("soundEditSystemBadge");
|
||||
const nameEl = el("soundEditName");
|
||||
const descEl = el("soundEditDescription");
|
||||
const volumeEl = el("soundEditVolume");
|
||||
const volumeOutEl = el("soundEditVolumeOut");
|
||||
const enabledEl = el("soundEditEnabled");
|
||||
const fileMetaEl = el("soundEditFileMeta");
|
||||
const fileSectionEl = el("soundEditFileSection");
|
||||
const uploadInputEl = el("soundEditUploadInput");
|
||||
const uploadBtnEl = el("soundEditUploadBtn");
|
||||
const playBtnEl = el("soundEditPlayBtn");
|
||||
const listenBtnEl = el("soundEditListenBtn");
|
||||
const playRobotBtnEl = el("soundEditPlayRobotBtn");
|
||||
const deleteBtnEl = el("soundEditDeleteBtn");
|
||||
const deleteConfirmDialogEl = el("soundDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("soundDeleteConfirmText");
|
||||
|
||||
const store = {
|
||||
sounds: [],
|
||||
editingId: null,
|
||||
previewAudio: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
if (!window.AuthApp?.canWrite) return true;
|
||||
return window.AuthApp.canWrite("integrations");
|
||||
return window.AuthApp.canWrite("sounds") || window.AuthApp.canWrite("integrations");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -66,38 +84,124 @@
|
||||
return `${sec}s`;
|
||||
}
|
||||
|
||||
function currentEditingSound() {
|
||||
return store.editingId ? store.sounds.find((s) => s.id === store.editingId) : null;
|
||||
}
|
||||
|
||||
function canListen(sound) {
|
||||
return !!(sound?.file_name);
|
||||
}
|
||||
|
||||
function sortedSounds() {
|
||||
return [...store.sounds].sort((a, b) => {
|
||||
if (!!a.is_system !== !!b.is_system) return a.is_system ? -1 : 1;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
}
|
||||
|
||||
function filteredSounds() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = sortedSounds();
|
||||
if (q) {
|
||||
items = items.filter((sound) => {
|
||||
const name = (sound.name || "").toLowerCase();
|
||||
const file = (sound.file_name || "").toLowerCase();
|
||||
const desc = (sound.description || "").toLowerCase();
|
||||
return name.includes(q) || file.includes(q) || desc.includes(q);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function pagedItems(items) {
|
||||
const totalPages = pageCount(items.length);
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return items.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatePagerUi(totalItems) {
|
||||
const totalPages = pageCount(totalItems);
|
||||
if (filterCountEl) filterCountEl.textContent = t("sounds.itemsFound", { n: totalItems });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("sounds.pageOf", { page: store.page, total: totalPages });
|
||||
const atStart = store.page <= 1;
|
||||
const atEnd = store.page >= totalPages;
|
||||
el("soundsPageFirst")?.toggleAttribute("disabled", atStart);
|
||||
el("soundsPagePrev")?.toggleAttribute("disabled", atStart);
|
||||
el("soundsPageNext")?.toggleAttribute("disabled", atEnd);
|
||||
el("soundsPageLast")?.toggleAttribute("disabled", atEnd);
|
||||
}
|
||||
|
||||
function soundMetaLine(sound) {
|
||||
const hasFile = !!sound.file_name;
|
||||
const parts = [
|
||||
sound.enabled === false ? t("common.disabled") : t("common.enabled"),
|
||||
t("sounds.volumeShort", { volume: sound.volume != null ? sound.volume : 100 }),
|
||||
hasFile ? sound.file_name : t("sounds.noFile"),
|
||||
];
|
||||
if (sound.duration_ms != null) parts.push(formatDuration(sound.duration_ms));
|
||||
if (sound.is_system) parts.push(t("sounds.systemShort"));
|
||||
return parts.map((p) => escapeHtml(p)).join(" · ");
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = "";
|
||||
if (emptyEl) emptyEl.hidden = store.sounds.length > 0;
|
||||
const items = filteredSounds();
|
||||
const pageItems = pagedItems(items);
|
||||
updatePagerUi(items.length);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const showEmpty = items.length === 0;
|
||||
if (listEl) listEl.hidden = showEmpty;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = !showEmpty;
|
||||
emptyEl.textContent = store.filter.trim() ? t("sounds.emptyFilter") : t("sounds.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((sound) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "soundsMirRow";
|
||||
row.setAttribute("role", "listitem");
|
||||
row.dataset.id = sound.id;
|
||||
|
||||
const listenDisabled = !canListen(sound);
|
||||
const editDisabled = !canWrite();
|
||||
|
||||
store.sounds.forEach((sound) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "missionListItem soundListItem";
|
||||
const hasFile = !!sound.file_name;
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(sound.name || sound.id)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
${sound.enabled === false ? t("common.disabled") : t("common.enabled")}
|
||||
· ${hasFile ? escapeHtml(sound.file_name) : t("sounds.noFile")}
|
||||
${sound.duration_ms != null ? ` · ${formatDuration(sound.duration_ms)}` : ""}
|
||||
</div>
|
||||
<div class="soundsMirRowMain">
|
||||
<button type="button" class="soundsMirRowTitle soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
|
||||
${escapeHtml(sound.name || sound.id)}
|
||||
</button>
|
||||
<div class="soundsMirRowMeta">${soundMetaLine(sound)}</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle soundPlayBtn" data-id="${escapeHtml(sound.id)}" ${hasFile ? "" : "disabled"}>${t("sounds.play")}</button>
|
||||
<button type="button" class="btn subtle soundEditBtn" data-id="${escapeHtml(sound.id)}">${t("common.edit")}</button>
|
||||
</div>
|
||||
`;
|
||||
<div class="soundsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn soundListenBtn" data-id="${escapeHtml(sound.id)}" ${listenDisabled ? "disabled" : ""} title="${escapeHtml(t("sounds.listen"))}">
|
||||
${ICONS.listen}
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline soundEditBtn" data-id="${escapeHtml(sound.id)}" ${editDisabled ? "disabled" : ""}>
|
||||
${escapeHtml(t("common.edit"))}
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
row.querySelectorAll(".soundEditBtn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (!canWrite()) return;
|
||||
openDialog(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
row.querySelector(".soundListenBtn")?.addEventListener("click", () => listenSound(sound.id));
|
||||
row.addEventListener("dblclick", () => {
|
||||
if (canWrite()) openDialog(sound.id);
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
|
||||
listEl.querySelectorAll(".soundEditBtn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => openDialog(btn.dataset.id));
|
||||
});
|
||||
listEl.querySelectorAll(".soundPlayBtn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => playSound(btn.dataset.id));
|
||||
});
|
||||
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
@@ -107,13 +211,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(id) {
|
||||
function listenSound(id) {
|
||||
const sound = store.sounds.find((s) => s.id === id);
|
||||
if (!canListen(sound)) return;
|
||||
stopPreview();
|
||||
const audio = new Audio(`/api/sounds/${encodeURIComponent(id)}/file`);
|
||||
store.previewAudio = audio;
|
||||
audio.play().catch(() => alert(t("sounds.playFailed")));
|
||||
}
|
||||
|
||||
function readVolume() {
|
||||
return Number(volumeEl?.value) || 0;
|
||||
}
|
||||
|
||||
function syncVolumeOut() {
|
||||
if (volumeOutEl) volumeOutEl.textContent = String(readVolume());
|
||||
}
|
||||
|
||||
function updateFileMeta(sound) {
|
||||
if (!fileMetaEl) return;
|
||||
if (sound?.file_name) {
|
||||
@@ -121,45 +235,84 @@
|
||||
name: sound.file_name,
|
||||
duration: formatDuration(sound.duration_ms),
|
||||
});
|
||||
} else if (sound?.is_system) {
|
||||
fileMetaEl.textContent = t("sounds.systemNoFile");
|
||||
} else {
|
||||
fileMetaEl.textContent = t("sounds.noFile");
|
||||
}
|
||||
if (playBtnEl) playBtnEl.disabled = !sound?.file_name;
|
||||
const listenOk = canListen(sound);
|
||||
if (listenBtnEl) listenBtnEl.disabled = !listenOk;
|
||||
if (playRobotBtnEl) playRobotBtnEl.disabled = !sound?.id || sound.enabled === false;
|
||||
}
|
||||
|
||||
function applyDialogPermissions(sound) {
|
||||
const ro = !canWrite();
|
||||
const isSystem = !!sound?.is_system;
|
||||
if (nameEl) nameEl.readOnly = ro || isSystem;
|
||||
if (descEl) descEl.readOnly = ro;
|
||||
if (volumeEl) volumeEl.disabled = ro;
|
||||
if (enabledEl) enabledEl.disabled = ro;
|
||||
if (uploadBtnEl) uploadBtnEl.hidden = isSystem || ro;
|
||||
if (fileSectionEl) fileSectionEl.hidden = false;
|
||||
if (deleteBtnEl) deleteBtnEl.hidden = !sound || isSystem || ro;
|
||||
if (systemBadgeEl) systemBadgeEl.hidden = !isSystem;
|
||||
}
|
||||
|
||||
function openDialog(id = null) {
|
||||
store.editingId = id;
|
||||
const existing = id ? store.sounds.find((s) => s.id === id) : null;
|
||||
if (titleEl) {
|
||||
titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
|
||||
}
|
||||
if (titleEl) titleEl.textContent = existing ? t("sounds.editTitle") : t("sounds.createTitle");
|
||||
if (nameEl) nameEl.value = existing?.name || "";
|
||||
if (descEl) descEl.value = existing?.description || "";
|
||||
if (enabledEl) enabledEl.checked = existing?.enabled !== false;
|
||||
if (volumeEl) volumeEl.value = existing?.volume != null ? existing.volume : 100;
|
||||
syncVolumeOut();
|
||||
updateFileMeta(existing);
|
||||
if (deleteBtnEl) deleteBtnEl.hidden = !existing || !canWrite();
|
||||
if (uploadBtnEl) uploadBtnEl.disabled = !canWrite();
|
||||
if (nameEl) nameEl.readOnly = !canWrite();
|
||||
if (descEl) descEl.readOnly = !canWrite();
|
||||
if (enabledEl) enabledEl.disabled = !canWrite();
|
||||
applyDialogPermissions(existing);
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
function readPayload() {
|
||||
return {
|
||||
name: nameEl?.value.trim() || "",
|
||||
description: descEl?.value.trim() || "",
|
||||
enabled: enabledEl?.checked !== false,
|
||||
volume: readVolume(),
|
||||
};
|
||||
}
|
||||
|
||||
function isReservedName(name) {
|
||||
return ["beep", "horn", "chime"].includes(String(name || "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function nameExists(name, exceptId = null) {
|
||||
const lower = String(name || "").trim().toLowerCase();
|
||||
return store.sounds.some(
|
||||
(s) => s.id !== exceptId && String(s.name || "").trim().toLowerCase() === lower,
|
||||
);
|
||||
}
|
||||
|
||||
async function saveDialog() {
|
||||
if (!canWrite()) return;
|
||||
const name = nameEl?.value.trim() || "";
|
||||
if (!name) {
|
||||
const soundId = store.editingId;
|
||||
const payload = readPayload();
|
||||
if (!payload.name) {
|
||||
alert(t("sounds.nameRequired"));
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
description: descEl?.value.trim() || "",
|
||||
enabled: enabledEl?.checked !== false,
|
||||
};
|
||||
if (!soundId) {
|
||||
if (isReservedName(payload.name)) {
|
||||
alert(t("sounds.reservedName"));
|
||||
return;
|
||||
}
|
||||
if (nameExists(payload.name)) {
|
||||
alert(t("sounds.nameDuplicate"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (store.editingId) {
|
||||
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, {
|
||||
if (soundId) {
|
||||
await apiJson(`/api/sounds/${encodeURIComponent(soundId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -174,8 +327,9 @@
|
||||
}
|
||||
await refreshSounds();
|
||||
renderList();
|
||||
const updated = store.sounds.find((s) => s.id === store.editingId);
|
||||
const updated = currentEditingSound();
|
||||
updateFileMeta(updated);
|
||||
applyDialogPermissions(updated);
|
||||
if (!uploadInputEl?.files?.length) {
|
||||
dialogEl?.close();
|
||||
}
|
||||
@@ -186,6 +340,8 @@
|
||||
|
||||
async function uploadFile() {
|
||||
if (!canWrite() || !store.editingId) return;
|
||||
const sound = currentEditingSound();
|
||||
if (sound?.is_system) return;
|
||||
const file = uploadInputEl?.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
@@ -198,17 +354,26 @@
|
||||
uploadInputEl.value = "";
|
||||
await refreshSounds();
|
||||
renderList();
|
||||
updateFileMeta(store.sounds.find((s) => s.id === store.editingId));
|
||||
updateFileMeta(currentEditingSound());
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSound() {
|
||||
function openDeleteConfirm() {
|
||||
const sound = currentEditingSound();
|
||||
if (!sound || sound.is_system || !canWrite()) return;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("sounds.deleteConfirmText", { name: sound.name || sound.id });
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!canWrite() || !store.editingId) return;
|
||||
if (!confirm(t("sounds.deleteConfirm"))) return;
|
||||
try {
|
||||
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}`, { method: "DELETE" });
|
||||
deleteConfirmDialogEl?.close();
|
||||
dialogEl?.close();
|
||||
store.editingId = null;
|
||||
await refreshSounds();
|
||||
@@ -218,6 +383,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function playOnRobot() {
|
||||
if (!store.editingId) return;
|
||||
try {
|
||||
await apiJson(`/api/sounds/${encodeURIComponent(store.editingId)}/play`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ volume: readVolume() }),
|
||||
});
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
createBtnEl?.addEventListener("click", () => {
|
||||
if (!canWrite()) return;
|
||||
@@ -229,26 +414,65 @@
|
||||
});
|
||||
el("soundEditCancelBtn")?.addEventListener("click", () => {
|
||||
stopPreview();
|
||||
store.editingId = null;
|
||||
dialogEl?.close();
|
||||
});
|
||||
dialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
stopPreview();
|
||||
store.editingId = null;
|
||||
dialogEl?.close();
|
||||
});
|
||||
volumeEl?.addEventListener("input", syncVolumeOut);
|
||||
uploadBtnEl?.addEventListener("click", () => uploadInputEl?.click());
|
||||
uploadInputEl?.addEventListener("change", () => {
|
||||
saveDialog().then(() => uploadFile());
|
||||
});
|
||||
playBtnEl?.addEventListener("click", () => {
|
||||
if (store.editingId) playSound(store.editingId);
|
||||
listenBtnEl?.addEventListener("click", () => {
|
||||
if (store.editingId) listenSound(store.editingId);
|
||||
});
|
||||
deleteBtnEl?.addEventListener("click", () => deleteSound());
|
||||
playRobotBtnEl?.addEventListener("click", () => playOnRobot());
|
||||
deleteBtnEl?.addEventListener("click", () => openDeleteConfirm());
|
||||
el("soundDeleteCancelBtn")?.addEventListener("click", () => deleteConfirmDialogEl?.close());
|
||||
el("soundDeleteYesBtn")?.addEventListener("click", () => confirmDelete());
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("soundsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("soundsPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("soundsPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("soundsPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("soundsPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredSounds().length);
|
||||
renderList();
|
||||
});
|
||||
el("soundsHelpBtn")?.addEventListener("click", () => {
|
||||
alert(t("sounds.helpBody"));
|
||||
});
|
||||
|
||||
window.addEventListener("lm:locale-change", () => renderList());
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
stopPreview();
|
||||
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||
document.body.classList.toggle("auth-readonly-sounds", !canWrite());
|
||||
try {
|
||||
await refreshSounds();
|
||||
renderList();
|
||||
@@ -257,12 +481,14 @@
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = e.message;
|
||||
}
|
||||
if (listEl) listEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
stopPreview();
|
||||
dialogEl?.close();
|
||||
deleteConfirmDialogEl?.close();
|
||||
}
|
||||
|
||||
function getSounds() {
|
||||
|
||||
Reference in New Issue
Block a user