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.
952 lines
38 KiB
952 lines
38 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 %}>▶</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 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>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-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>{{ '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-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 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"); |
|
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.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>${normalizeVal ? "Oui" : "Non"}</td> |
|
<td> |
|
<button type="button" class="small js-play-trigger" |
|
data-trigger-id="${triggerId}" |
|
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 body = new URLSearchParams({ trigger_id: tid }); |
|
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 body = new URLSearchParams({ trigger_id: triggerId }); |
|
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 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 (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; |
|
|
|
const openModal = () => { |
|
modal.classList.add("is-open"); |
|
modal.setAttribute("aria-hidden", "false"); |
|
}; |
|
|
|
const closeModal = () => { |
|
player.pause(); |
|
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", 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; |
|
|
|
if (player.src !== src) { |
|
player.src = src; |
|
} |
|
player.play().catch(() => { |
|
// Browsers may block autoplay in some contexts; user can press play manually. |
|
}); |
|
nowPlaying.textContent = "Lecture: " + name; |
|
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; |
|
|
|
const stopPreview = () => { |
|
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 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 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 source = previewContext.createBufferSource(); |
|
source.buffer = audioBuffer; |
|
const gainNode = previewContext.createGain(); |
|
source.connect(gainNode); |
|
gainNode.connect(previewContext.destination); |
|
|
|
const normalizeGain = normalizeAudio ? computeNormalizeGain(audioBuffer, startSeconds, playDuration) : 1; |
|
const baseGain = Math.max(0, Math.min(1, volume / 100)) * normalizeGain; |
|
|
|
const now = previewContext.currentTime; |
|
const startAt = now + 0.03; |
|
const endAt = startAt + playDuration; |
|
|
|
const fadeIn = Math.min(Math.max(0, fadeInSeconds), playDuration); |
|
const fadeOut = Math.min(Math.max(0, fadeOutSeconds), 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; |
|
} |
|
}; |
|
|
|
previewSource = source; |
|
source.start(startAt, startSeconds, playDuration); |
|
|
|
if (nowPlaying) { |
|
nowPlaying.textContent = "Prévisualisation: " + filename + |
|
" (" + startSeconds.toFixed(1) + "s -> " + clampedEnd.toFixed(1) + "s)"; |
|
} |
|
player?.closest("section")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
|
}) |
|
.catch((err) => { |
|
showFlash(err?.message || "Prévisualisation impossible.", "error"); |
|
}) |
|
.finally(() => { |
|
btn.disabled = false; |
|
}); |
|
}); |
|
})(); |
|
</script> |
|
{% endblock %}
|
|
|