You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1028 lines
41 KiB

{% extends "base.html" %}
{% block content %}
<header class="topbar reveal">
<div>
<h1>Tableau de bord</h1>
</div>
<div class="topbar-actions">
<button type="button" class="danger" id="btn-stop-audio">Arrêter l'audio</button>
<a href="{{ url_for('ui.audio_storage') }}" class="ghost-link">Stockage audio</a>
<a href="{{ url_for('ui.logout') }}" class="ghost-link">Déconnexion</a>
</div>
</header>
<main class="dashboard-grid">
<section class="panel reveal delay-1">
<h2>Ajouter ou modifier un trigger</h2>
<form method="post" action="{{ url_for('ui.save_trigger') }}" class="form-grid" id="trigger-form">
<input type="hidden" name="original_id" id="original_id" />
<label>
Type du trigger (obligatoire)
<input type="text" name="type" id="trigger_type" placeholder="GPIO3" required />
</label>
<label>
Nom
<input type="text" name="name" id="trigger_name" placeholder="Bouton entrée" required />
</label>
<label>
Fichier audio
<div class="audio-select-row">
<select name="music_file" id="music_file" required
data-stream-url="{{ url_for('ui.stream_audio', filename='__FILE__') }}">
<option value="" disabled {% if not audio_files %}selected{% endif %}>
{% if audio_files %}Choisir un fichier…{% else %}Aucun fichier disponible{% endif %}
</option>
{% for f in audio_files %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
<button type="button" class="small btn-preview" id="btn-preview-trigger-audio"
title="Prévisualiser le fichier sélectionné"
{% if not audio_files %}disabled{% endif %}>&#9654;</button>
</div>
</label>
<div class="time-row">
<label>
Début (s)
<input type="number" step="0.1" min="0" name="start_seconds" id="start_seconds" value="0" />
</label>
<label>
Fin (s, optionnel)
<input type="number" step="0.1" min="0" name="end_seconds" id="end_seconds" />
</label>
</div>
<label class="volume-row">
<span>Volume : <span id="trigger_volume_display">80</span>%</span>
<input type="range" min="0" max="100" name="volume" id="trigger_volume" value="80" />
</label>
<div class="time-row">
<label>
Fade in (s)
<input type="number" step="0.1" min="0" name="fade_in_seconds" id="fade_in_seconds" value="0" />
</label>
<label>
Fade out (s)
<input type="number" step="0.1" min="0" name="fade_out_seconds" id="fade_out_seconds" value="0" />
</label>
</div>
<label>
Nombre de répétitions
<input type="number" step="1" min="0" name="repeat_count" id="repeat_count" value="0" />
</label>
<label class="normalize-row">
<input type="checkbox" name="normalize_audio" id="normalize_audio" />
Normaliser le niveau sonore
</label>
<button type="submit">Enregistrer le trigger</button>
</form>
</section>
<section class="panel reveal delay-2">
<h2>Liste des triggers</h2>
<p class="muted" id="no-triggers-msg"{% if triggers %} hidden{% endif %}>Aucun trigger configuré.</p>
<div class="table-wrap"{% if not triggers %} hidden{% endif %} id="triggers-table-wrap">
<table
id="triggers-table"
data-play-url="{{ url_for('ui.play_trigger') }}"
data-delete-url="{{ url_for('ui.delete_trigger') }}"
>
<thead>
<tr>
<th>Nom</th>
<th>Type</th>
<th>Audio</th>
<th>Début</th>
<th>Fin</th>
<th>Volume</th>
<th>Fade in</th>
<th>Fade out</th>
<th>Répétitions</th>
<th>Normalisation</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="triggers-tbody">
{% for trigger_id, trigger in triggers.items() %}
<tr
class="trigger-row"
data-trigger-id="{{ trigger_id }}"
data-trigger-type="{{ trigger.get('type', '') }}"
data-trigger-name="{{ trigger.get('name', '') }}"
data-trigger-music="{{ trigger.get('music_file', '') }}"
data-trigger-start="{{ trigger.get('start_seconds', 0) }}"
data-trigger-end="{{ '' if trigger.get('end_seconds') is none else trigger.get('end_seconds') }}"
data-trigger-volume="{{ trigger.get('volume', 80) }}"
data-trigger-fade-in="{{ trigger.get('fade_in_seconds', 0.0) }}"
data-trigger-fade-out="{{ trigger.get('fade_out_seconds', 0.0) }}"
data-trigger-repeat-count="{{ trigger.get('repeat_count', 0) }}"
data-trigger-normalize="{{ 1 if trigger.get('normalize_audio', false) else 0 }}"
title="Cliquer pour charger ce trigger dans le formulaire"
>
<td>{{ trigger.get('name', '') }}</td>
<td>{{ trigger.get('type', '') }}</td>
<td>{{ trigger.get('music_file', '') }}</td>
<td>{{ trigger.get('start_seconds', 0) }}</td>
<td>{{ trigger.get('end_seconds', '') }}</td>
<td>{{ trigger.get('volume', 80) }}</td>
<td>{{ trigger.get('fade_in_seconds', 0.0) }}</td>
<td>{{ trigger.get('fade_out_seconds', 0.0) }}</td>
<td>{{ trigger.get('repeat_count', 0) }}</td>
<td>{{ 'Oui' if trigger.get('normalize_audio', false) else 'Non' }}</td>
<td>
<button
type="button"
class="small js-play-trigger"
data-trigger-id="{{ trigger_id }}"
data-repeat-count="{{ trigger.get('repeat_count', 0) }}"
data-play-url="{{ url_for('ui.play_trigger') }}"
>Lancer</button>
<button
type="button"
class="small danger js-delete-trigger-btn"
data-trigger-id="{{ trigger_id }}"
>Supprimer</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</main>
<div class="modal-backdrop" id="delete-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="delete-audio-title">
<h3 id="delete-audio-title">Confirmer la suppression</h3>
<p id="delete-audio-message">Voulez-vous vraiment supprimer ce fichier ?</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="delete-audio-cancel">Annuler</button>
<button type="button" class="danger" id="delete-audio-confirm">Supprimer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="delete-trigger-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="delete-trigger-title">
<h3 id="delete-trigger-title">Confirmer la suppression</h3>
<p id="delete-trigger-message">Voulez-vous vraiment supprimer ce trigger ?</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="delete-trigger-cancel">Annuler</button>
<button type="button" class="danger" id="delete-trigger-confirm">Supprimer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="launch-trigger-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="launch-trigger-title">
<h3 id="launch-trigger-title">Avertissement audio</h3>
<p id="launch-trigger-message">La musique va être jouée sur la sortie audio. Continuer ?</p>
<label class="normalize-row" for="launch-trigger-dont-show">
<input type="checkbox" id="launch-trigger-dont-show" />
Ne plus réafficher cet avertissement
</label>
<div class="modal-actions">
<button type="button" class="ghost-link" id="launch-trigger-cancel">Annuler</button>
<button type="button" id="launch-trigger-confirm">Lancer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="duplicate-name-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="duplicate-name-title">
<h3 id="duplicate-name-title">Nom déjà utilisé</h3>
<p id="duplicate-name-message">Ce nom existe déjà dans les fichiers enregistrés.</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="duplicate-name-close">Fermer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="browser-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="browser-audio-title">
<h3 id="browser-audio-title">Lecture audio</h3>
<div class="audio-player-card">
<p id="audio-now-playing" class="muted">Aucune lecture en cours.</p>
<audio id="browser-audio-player" controls preload="metadata"></audio>
<p class="audio-time" id="audio-time">Temps de lecture: 00:00 / 00:00</p>
</div>
<div class="modal-actions">
<button type="button" class="ghost-link" id="browser-audio-close">Fermer</button>
</div>
</div>
</div>
<script>
const SKIP_LAUNCH_WARNING_KEY = "pysonnerie_skip_launch_trigger_warning";
function showFlash(message, category) {
let stack = document.querySelector(".flash-stack");
if (!stack) {
stack = document.createElement("div");
stack.className = "flash-stack";
document.body.prepend(stack);
}
const flash = document.createElement("div");
flash.className = "flash flash-" + category;
flash.textContent = message;
stack.appendChild(flash);
window.setTimeout(() => {
flash.classList.add("is-hiding");
window.setTimeout(() => {
flash.remove();
if (!stack.querySelector(".flash")) stack.remove();
}, 360);
}, 3600);
}
function confirmTriggerLaunch(triggerName) {
const skipWarning = localStorage.getItem(SKIP_LAUNCH_WARNING_KEY) === "1";
if (skipWarning) {
return Promise.resolve(true);
}
const modal = document.getElementById("launch-trigger-modal");
const message = document.getElementById("launch-trigger-message");
const dontShow = document.getElementById("launch-trigger-dont-show");
const cancelBtn = document.getElementById("launch-trigger-cancel");
const confirmBtn = document.getElementById("launch-trigger-confirm");
if (!modal || !message || !dontShow || !cancelBtn || !confirmBtn) {
return Promise.resolve(true);
}
message.textContent = "La musique va être jouée sur la sortie audio" +
(triggerName ? " pour \"" + triggerName + "\"" : "") + ". Continuer ?";
dontShow.checked = false;
return new Promise((resolve) => {
let done = false;
const cleanup = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
cancelBtn.removeEventListener("click", onCancel);
confirmBtn.removeEventListener("click", onConfirm);
modal.removeEventListener("click", onBackdropClick);
document.removeEventListener("keydown", onKeydown);
};
const finish = (ok) => {
if (done) return;
done = true;
if (ok && dontShow.checked) {
localStorage.setItem(SKIP_LAUNCH_WARNING_KEY, "1");
}
cleanup();
resolve(ok);
};
const onCancel = () => finish(false);
const onConfirm = () => finish(true);
const onBackdropClick = (event) => {
if (event.target === modal) {
finish(false);
}
};
const onKeydown = (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
finish(false);
}
};
cancelBtn.addEventListener("click", onCancel);
confirmBtn.addEventListener("click", onConfirm);
modal.addEventListener("click", onBackdropClick);
document.addEventListener("keydown", onKeydown);
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
});
}
function upsertTriggerRow(data) {
const tbody = document.getElementById("triggers-tbody");
const tableWrap = document.getElementById("triggers-table-wrap");
const noMsg = document.getElementById("no-triggers-msg");
const table = document.getElementById("triggers-table");
if (!tbody || !table) return;
const triggerId = data.trigger_id;
const originalId = data.original_id || triggerId;
const t = data.trigger || {};
const playUrl = table.dataset.playUrl || "";
const deleteUrl = table.dataset.deleteUrl || "";
// Supprimer l'ancienne ligne si renommage
if (originalId && originalId !== triggerId) {
const oldRow = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(originalId)}"]`);
if (oldRow) oldRow.remove();
}
const endVal = (t.end_seconds != null && t.end_seconds !== "") ? t.end_seconds : "";
const fadeInVal = t.fade_in_seconds != null ? t.fade_in_seconds : 0;
const fadeOutVal = t.fade_out_seconds != null ? t.fade_out_seconds : 0;
const repeatCountVal = t.repeat_count != null ? t.repeat_count : 0;
const normalizeVal = !!t.normalize_audio;
let row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
if (!row) {
row = document.createElement("tr");
row.className = "trigger-row";
row.title = "Cliquer pour charger ce trigger dans le formulaire";
tbody.appendChild(row);
// Attacher le listener de clic (meme logique que pour les lignes existantes)
row.addEventListener("click", (event) => {
if (event.target.closest("form, button, a, input")) return;
const f = document.getElementById("trigger-form");
if (!f) return;
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
set("original_id", row.dataset.triggerId || "");
set("trigger_type", row.dataset.triggerType || "");
set("trigger_name", row.dataset.triggerName || "");
set("music_file", row.dataset.triggerMusic || "");
set("start_seconds", row.dataset.triggerStart || "0");
set("end_seconds", row.dataset.triggerEnd || "");
set("trigger_volume", row.dataset.triggerVolume || "80");
set("fade_in_seconds", row.dataset.triggerFadeIn || "0");
set("fade_out_seconds", row.dataset.triggerFadeOut || "0");
set("repeat_count", row.dataset.triggerRepeatCount || "0");
const normalize = document.getElementById("normalize_audio");
if (normalize) normalize.checked = (row.dataset.triggerNormalize === "1");
document.getElementById("trigger_type")?.focus();
f.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
row.dataset.triggerId = triggerId;
row.dataset.triggerType = t.type || "";
row.dataset.triggerName = t.name || "";
row.dataset.triggerMusic = t.music_file || "";
row.dataset.triggerStart = t.start_seconds != null ? t.start_seconds : "0";
row.dataset.triggerEnd = endVal;
row.dataset.triggerVolume = t.volume != null ? t.volume : 80;
row.dataset.triggerFadeIn = fadeInVal;
row.dataset.triggerFadeOut = fadeOutVal;
row.dataset.triggerRepeatCount = repeatCountVal;
row.dataset.triggerNormalize = normalizeVal ? "1" : "0";
row.innerHTML = `
<td>${t.name || ""}</td>
<td>${t.type || ""}</td>
<td>${t.music_file || ""}</td>
<td>${t.start_seconds != null ? t.start_seconds : 0}</td>
<td>${endVal}</td>
<td>${t.volume != null ? t.volume : 80}</td>
<td>${fadeInVal}</td>
<td>${fadeOutVal}</td>
<td>${repeatCountVal}</td>
<td>${normalizeVal ? "Oui" : "Non"}</td>
<td>
<button type="button" class="small js-play-trigger"
data-trigger-id="${triggerId}"
data-repeat-count="${repeatCountVal}"
data-play-url="${playUrl}">Lancer</button>
<button type="button" class="small danger js-delete-trigger-btn"
data-trigger-id="${triggerId}">Supprimer</button>
</td>`;
// Rebrancher le listener du bouton Lancer
const playBtn = row.querySelector(".js-play-trigger");
if (playBtn) {
playBtn.addEventListener("click", async () => {
const tid = playBtn.dataset.triggerId;
const url = playBtn.dataset.playUrl;
if (!tid || !url) return;
const rowName = row.dataset.triggerName || "";
const ok = await confirmTriggerLaunch(rowName);
if (!ok) return;
playBtn.disabled = true;
const parsedRepeatCount = Number.parseInt(playBtn.dataset.repeatCount || row.dataset.triggerRepeatCount || "0", 10);
const safeRepeatCount = Number.isFinite(parsedRepeatCount) && parsedRepeatCount >= 0 ? String(parsedRepeatCount) : "0";
const body = new URLSearchParams({ trigger_id: tid, repeat_count: safeRepeatCount });
fetch(url, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
.then((res) => res.json())
.then((d) => { showFlash(d.ok ? (d.message || "Trigger démarré.") : (d.error || "Échec."), d.ok ? "success" : "error"); })
.catch(() => { showFlash("Erreur réseau lors du lancement.", "error"); })
.finally(() => { playBtn.disabled = false; });
});
}
// Afficher la table si elle etait cachee
if (tableWrap) tableWrap.hidden = false;
if (noMsg) noMsg.hidden = true;
}
(function () {
const btn = document.getElementById("btn-stop-audio");
if (!btn) return;
btn.addEventListener("click", () => {
btn.disabled = true;
fetch("{{ url_for('ui.stop_audio') }}", {
method: "POST",
headers: { "X-Requested-With": "fetch" },
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
showFlash(data.message || "Audio arrêté.", "info");
} else {
showFlash(data.error || "Échec de l'arrêt audio.", "error");
}
})
.catch(() => {
showFlash("Erreur réseau lors de l'arrêt audio.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
})();
(function () {
document.querySelectorAll(".js-play-trigger").forEach((btn) => {
btn.addEventListener("click", async () => {
const triggerId = btn.dataset.triggerId;
const url = btn.dataset.playUrl;
if (!triggerId || !url) return;
const row = btn.closest("tr");
const triggerName = row?.dataset.triggerName || "";
const ok = await confirmTriggerLaunch(triggerName);
if (!ok) return;
btn.disabled = true;
const parsedRepeatCount = Number.parseInt(btn.dataset.repeatCount || row?.dataset.triggerRepeatCount || "0", 10);
const safeRepeatCount = Number.isFinite(parsedRepeatCount) && parsedRepeatCount >= 0 ? String(parsedRepeatCount) : "0";
const body = new URLSearchParams({ trigger_id: triggerId, repeat_count: safeRepeatCount });
fetch(url, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
showFlash(data.message || "Trigger démarré.", "success");
} else {
showFlash(data.error || "Échec du lancement.", "error");
}
})
.catch(() => {
showFlash("Erreur réseau lors du lancement.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
});
})();
(function () {
const form = document.getElementById("trigger-form");
if (!form) return;
const originalIdInput = document.getElementById("original_id");
const typeInput = document.getElementById("trigger_type");
const nameInput = document.getElementById("trigger_name");
const musicInput = document.getElementById("music_file");
const startInput = document.getElementById("start_seconds");
const endInput = document.getElementById("end_seconds");
const volumeInput = document.getElementById("trigger_volume");
const volumeDisplay = document.getElementById("trigger_volume_display");
const fadeInInput = document.getElementById("fade_in_seconds");
const fadeOutInput = document.getElementById("fade_out_seconds");
const repeatCountInput = document.getElementById("repeat_count");
const normalizeInput = document.getElementById("normalize_audio");
if (volumeInput && volumeDisplay) {
volumeInput.addEventListener("input", () => { volumeDisplay.textContent = volumeInput.value; });
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const submitBtn = form.querySelector("button[type=submit]");
if (submitBtn) submitBtn.disabled = true;
const body = new URLSearchParams(new FormData(form));
fetch(form.action, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
showFlash(data.message || "Trigger enregistré.", "success");
upsertTriggerRow(data);
} else {
showFlash(data.error || "Échec de l'enregistrement.", "error");
}
})
.catch(() => {
showFlash("Erreur réseau lors de l'enregistrement.", "error");
})
.finally(() => {
if (submitBtn) submitBtn.disabled = false;
});
});
const rows = document.querySelectorAll("tr.trigger-row");
rows.forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("form, button, a, input")) {
return;
}
if (originalIdInput) originalIdInput.value = row.dataset.triggerId || "";
if (typeInput) typeInput.value = row.dataset.triggerType || "";
if (nameInput) nameInput.value = row.dataset.triggerName || "";
if (musicInput) musicInput.value = row.dataset.triggerMusic || "";
if (startInput) startInput.value = row.dataset.triggerStart || "0";
if (endInput) endInput.value = row.dataset.triggerEnd || "";
if (volumeInput) {
volumeInput.value = row.dataset.triggerVolume || "80";
if (volumeDisplay) volumeDisplay.textContent = volumeInput.value;
}
if (fadeInInput) fadeInInput.value = row.dataset.triggerFadeIn || "0";
if (fadeOutInput) fadeOutInput.value = row.dataset.triggerFadeOut || "0";
if (repeatCountInput) repeatCountInput.value = row.dataset.triggerRepeatCount || "0";
if (normalizeInput) normalizeInput.checked = (row.dataset.triggerNormalize === "1");
typeInput?.focus();
form.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
})();
(function () {
const player = document.getElementById("browser-audio-player");
const timeLabel = document.getElementById("audio-time");
const nowPlaying = document.getElementById("audio-now-playing");
const modal = document.getElementById("browser-audio-modal");
const closeBtn = document.getElementById("browser-audio-close");
if (!player || !timeLabel || !nowPlaying || !modal || !closeBtn) return;
let previewName = "Fichier audio";
let repeatsRemaining = 0;
let totalPlays = 1;
let currentPlay = 1;
const parseRepeatCount = (rawValue) => {
const parsed = Number.parseInt(String(rawValue ?? ""), 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
};
const updateNowPlaying = () => {
nowPlaying.textContent = "Lecture: " + previewName + " (" + currentPlay + "/" + totalPlays + ")";
};
const openModal = () => {
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
};
const closeModal = () => {
player.pause();
repeatsRemaining = 0;
totalPlays = 1;
currentPlay = 1;
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const remain = total % 60;
return String(minutes).padStart(2, "0") + ":" + String(remain).padStart(2, "0");
};
const updateTimeLabel = () => {
const current = formatTime(player.currentTime || 0);
const total = formatTime(player.duration || 0);
timeLabel.textContent = "Temps de lecture: " + current + " / " + total;
};
player.addEventListener("timeupdate", updateTimeLabel);
player.addEventListener("loadedmetadata", updateTimeLabel);
player.addEventListener("ended", () => {
if (repeatsRemaining <= 0) {
updateTimeLabel();
return;
}
repeatsRemaining -= 1;
currentPlay += 1;
player.currentTime = 0;
player.play().catch(() => {});
updateNowPlaying();
updateTimeLabel();
});
const playButtons = document.querySelectorAll(".js-play-browser");
playButtons.forEach((button) => { button.addEventListener("click", () => {
const src = button.dataset.audioUrl;
const name = button.dataset.audioName || "Fichier audio";
if (!src) return;
const triggerRepeatInput = document.getElementById("repeat_count");
const repeatCount = button.dataset.repeatCount != null
? parseRepeatCount(button.dataset.repeatCount)
: parseRepeatCount(triggerRepeatInput?.value);
previewName = name;
repeatsRemaining = repeatCount;
totalPlays = repeatCount + 1;
currentPlay = 1;
if (player.src !== src) {
player.src = src;
} else {
player.currentTime = 0;
}
player.play().catch(() => {
// Browsers may block autoplay in some contexts; user can press play manually.
});
updateNowPlaying();
updateTimeLabel();
openModal();
});
});
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
closeModal();
}
});
})();
(function () {
const uploadForm = document.getElementById("upload-audio-form");
const fileInput = document.getElementById("audio_file_input");
const duplicateModal = document.getElementById("duplicate-name-modal");
const duplicateClose = document.getElementById("duplicate-name-close");
if (!uploadForm || !fileInput || !duplicateModal || !duplicateClose) return;
const existing = new Set([
{% for filename in audio_files %}
"{{ filename|lower|replace('\\', '\\\\')|replace('"', '\\"') }}",
{% endfor %}
]);
const openDuplicateModal = () => {
duplicateModal.classList.add("is-open");
duplicateModal.setAttribute("aria-hidden", "false");
duplicateClose.focus();
};
const closeDuplicateModal = () => {
duplicateModal.classList.remove("is-open");
duplicateModal.setAttribute("aria-hidden", "true");
};
uploadForm.addEventListener("submit", (event) => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (existing.has(file.name.toLowerCase())) {
event.preventDefault();
openDuplicateModal();
}
});
duplicateClose.addEventListener("click", closeDuplicateModal);
duplicateModal.addEventListener("click", (event) => {
if (event.target === duplicateModal) {
closeDuplicateModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && duplicateModal.classList.contains("is-open")) {
closeDuplicateModal();
}
});
})();
(function () {
const modal = document.getElementById("delete-audio-modal");
const message = document.getElementById("delete-audio-message");
const cancelBtn = document.getElementById("delete-audio-cancel");
const confirmBtn = document.getElementById("delete-audio-confirm");
if (!modal || !message || !cancelBtn || !confirmBtn) return;
let pendingForm = null;
const openModal = (form, filename) => {
pendingForm = form;
message.textContent = "Voulez-vous vraiment supprimer le fichier \"" + filename + "\" ?";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
};
const closeModal = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
pendingForm = null;
};
document.querySelectorAll(".js-delete-audio-form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const filename = form.dataset.filename || "ce fichier";
openModal(form, filename);
});
});
cancelBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal();
}
});
confirmBtn.addEventListener("click", () => {
if (pendingForm) {
pendingForm.submit();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
closeModal();
}
});
})();
(function () {
const modal = document.getElementById("delete-trigger-modal");
const message = document.getElementById("delete-trigger-message");
const cancelBtn = document.getElementById("delete-trigger-cancel");
const confirmBtn = document.getElementById("delete-trigger-confirm");
const tbody = document.getElementById("triggers-tbody");
const tableWrap = document.getElementById("triggers-table-wrap");
const noMsg = document.getElementById("no-triggers-msg");
const deleteUrl = document.getElementById("triggers-table")?.dataset.deleteUrl || "";
if (!modal || !message || !cancelBtn || !confirmBtn || !tbody) return;
let pendingTriggerId = null;
let pendingTriggerName = "ce trigger";
const openModal = (triggerId) => {
pendingTriggerId = triggerId;
const row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
pendingTriggerName = row?.dataset.triggerName || "ce trigger";
message.textContent = `Voulez-vous vraiment supprimer le trigger "${pendingTriggerName}" ?`;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
};
const closeModal = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
pendingTriggerId = null;
pendingTriggerName = "ce trigger";
};
tbody.addEventListener("click", (event) => {
const btn = event.target.closest(".js-delete-trigger-btn");
if (!btn) return;
openModal(btn.dataset.triggerId);
});
cancelBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) closeModal();
});
confirmBtn.addEventListener("click", () => {
if (!pendingTriggerId) return;
const triggerId = pendingTriggerId;
closeModal();
confirmBtn.disabled = true;
const body = new URLSearchParams({ trigger_id: triggerId });
fetch(deleteUrl, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
const row = tbody.querySelector(`tr[data-trigger-id="${CSS.escape(triggerId)}"]`);
if (row) row.remove();
if (!tbody.querySelector("tr")) {
if (tableWrap) tableWrap.hidden = true;
if (noMsg) noMsg.hidden = false;
}
showFlash(data.message || "Trigger supprimé.", "success");
} else {
showFlash(data.error || "Échec de la suppression.", "error");
}
})
.catch(() => {
showFlash("Erreur réseau lors de la suppression.", "error");
})
.finally(() => {
confirmBtn.disabled = false;
});
});
})();
(function () {
const select = document.getElementById("music_file");
const btn = document.getElementById("btn-preview-trigger-audio");
if (!select || !btn) return;
let previewContext = null;
let previewSource = null;
let previewSessionId = 0;
const stopPreview = () => {
previewSessionId += 1;
if (previewSource) {
try {
previewSource.stop();
} catch (_) {}
previewSource = null;
}
};
const asNonNegative = (input, fallback) => {
const value = Number.parseFloat(input?.value ?? "");
if (!Number.isFinite(value) || value < 0) return fallback;
return value;
};
const computeNormalizeGain = (audioBuffer, startOffset, playDuration) => {
const sampleRate = audioBuffer.sampleRate;
const startFrame = Math.max(0, Math.floor(startOffset * sampleRate));
const endFrame = Math.min(audioBuffer.length, Math.ceil((startOffset + playDuration) * sampleRate));
if (endFrame <= startFrame) return 1;
let peak = 0;
for (let c = 0; c < audioBuffer.numberOfChannels; c += 1) {
const channel = audioBuffer.getChannelData(c);
for (let i = startFrame; i < endFrame; i += 1) {
const abs = Math.abs(channel[i]);
if (abs > peak) peak = abs;
}
}
if (peak <= 0) return 1;
return Math.min(1, 0.95 / peak);
};
btn.addEventListener("click", () => {
const filename = select.value;
if (!filename) return;
const streamBase = select.dataset.streamUrl || "";
const url = streamBase.replace("__FILE__", encodeURIComponent(filename));
const player = document.getElementById("browser-audio-player");
const nowPlaying = document.getElementById("audio-now-playing");
const startInput = document.getElementById("start_seconds");
const endInput = document.getElementById("end_seconds");
const volumeInput = document.getElementById("trigger_volume");
const fadeInInput = document.getElementById("fade_in_seconds");
const fadeOutInput = document.getElementById("fade_out_seconds");
const repeatCountInput = document.getElementById("repeat_count");
const normalizeInput = document.getElementById("normalize_audio");
const startSeconds = asNonNegative(startInput, 0);
const endSecondsRaw = Number.parseFloat(endInput?.value ?? "");
const hasEnd = Number.isFinite(endSecondsRaw) && endSecondsRaw > 0;
const volume = asNonNegative(volumeInput, 80);
const fadeInSeconds = asNonNegative(fadeInInput, 0);
const fadeOutSeconds = asNonNegative(fadeOutInput, 0);
const repeatCount = Math.max(0, Math.floor(asNonNegative(repeatCountInput, 0)));
const normalizeAudio = !!normalizeInput?.checked;
btn.disabled = true;
if (player) {
player.pause();
player.removeAttribute("src");
player.load();
}
stopPreview();
if (!previewContext) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
showFlash("Prévisualisation avancée non supportée par ce navigateur.", "error");
btn.disabled = false;
return;
}
previewContext = new Ctx();
}
if (previewContext.state === "suspended") {
previewContext.resume().catch(() => {});
}
fetch(url)
.then((res) => {
if (!res.ok) throw new Error("Téléchargement audio impossible");
return res.arrayBuffer();
})
.then((arr) => previewContext.decodeAudioData(arr))
.then((audioBuffer) => {
const duration = audioBuffer.duration;
if (!(startSeconds < duration)) {
throw new Error("Le début dépasse la durée du fichier.");
}
const requestedEnd = hasEnd ? endSecondsRaw : duration;
const clampedEnd = Math.min(duration, requestedEnd);
if (!(clampedEnd > startSeconds)) {
throw new Error("La fin doit être supérieure au début.");
}
const playDuration = clampedEnd - startSeconds;
const normalizeGain = normalizeAudio ? computeNormalizeGain(audioBuffer, startSeconds, playDuration) : 1;
const baseGain = Math.max(0, Math.min(1, volume / 100)) * normalizeGain;
const fadeIn = Math.min(Math.max(0, fadeInSeconds), playDuration);
const fadeOut = Math.min(Math.max(0, fadeOutSeconds), playDuration);
const currentSession = previewSessionId;
const totalPlays = repeatCount + 1;
const startPreviewPlay = (playIndex) => {
if (currentSession !== previewSessionId) return;
const source = previewContext.createBufferSource();
source.buffer = audioBuffer;
const gainNode = previewContext.createGain();
source.connect(gainNode);
gainNode.connect(previewContext.destination);
const now = previewContext.currentTime;
const startAt = now + 0.03;
const endAt = startAt + playDuration;
const fadeOutStart = Math.max(startAt + fadeIn, endAt - fadeOut);
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(0, now);
if (fadeIn > 0) {
gainNode.gain.linearRampToValueAtTime(baseGain, startAt + fadeIn);
} else {
gainNode.gain.setValueAtTime(baseGain, startAt);
}
if (fadeOut > 0) {
gainNode.gain.setValueAtTime(baseGain, fadeOutStart);
gainNode.gain.linearRampToValueAtTime(0.0001, endAt);
} else {
gainNode.gain.setValueAtTime(baseGain, endAt);
}
source.onended = () => {
if (previewSource === source) {
previewSource = null;
}
if (currentSession !== previewSessionId) return;
if (playIndex < totalPlays) {
startPreviewPlay(playIndex + 1);
}
};
previewSource = source;
source.start(startAt, startSeconds, playDuration);
if (nowPlaying) {
nowPlaying.textContent = "Prévisualisation: " + filename +
" (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)" +
" [" + playIndex + "/" + totalPlays + "]";
}
};
startPreviewPlay(1);
player?.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
})
.catch((err) => {
showFlash(err?.message || "Prévisualisation impossible.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
})();
</script>
{% endblock %}