Browse Source

feat: Enhance audio player and trigger management

- Added volume control to the AudioPlayer class, allowing playback volume to be specified.
- Updated trigger configuration to include volume settings in the JSON configuration.
- Modified backend and frontend to handle volume settings during trigger creation and playback.
- Improved frontend UI to include volume slider for triggers and updated dashboard to display volume levels.
- Implemented initialization scripts for backend and frontend to create configuration files with default values.
- Updated README files to reflect new configuration options and usage instructions.
- Enhanced error handling and user feedback for trigger actions in the frontend.
master
scayac 1 month ago
parent
commit
2b9292381c
  1. 1
      .gitignore
  2. 9
      backend/README_BACKEND.md
  3. 4
      backend/app/audio_player.py
  4. 2
      backend/app/main.py
  5. 2
      backend/app/models.py
  6. 16
      backend/data/conf.json
  7. 58
      backend/init.py
  8. 39
      frontend/README_FRONTEND.md
  9. 19
      frontend/app/__init__.py
  10. BIN
      frontend/app/__pycache__/__init__.cpython-312.pyc
  11. BIN
      frontend/app/__pycache__/backend_client.cpython-312.pyc
  12. BIN
      frontend/app/__pycache__/routes.cpython-312.pyc
  13. 2
      frontend/app/backend_client.py
  14. 59
      frontend/app/routes.py
  15. 11
      frontend/app/static/css/style.css
  16. 345
      frontend/app/templates/dashboard.html
  17. 2
      frontend/app/templates/login.html
  18. 32
      frontend/init.py
  19. 1
      frontend/systemd/pysonnerie-frontend.service

1
.gitignore vendored

@ -2,3 +2,4 @@ @@ -2,3 +2,4 @@
__pycache__
*.pyc
certs
conf.json

9
backend/README_BACKEND.md

@ -41,6 +41,15 @@ Si présence d'un proxy, la dernière commande sera `pip install -r requirements @@ -41,6 +41,15 @@ Si présence d'un proxy, la dernière commande sera `pip install -r requirements
## Configuration
Le script `init.py` crée `data/conf.json` avec des valeurs par défaut et un mot de passe admin aléatoire :
```bash
cd backend
python init.py
```
Le mot de passe généré est affiché une seule fois dans le terminal. Le fichier est créé avec les permissions `600`.
Le fichier `data/conf.json` contient:
- `server.host`, `server.port`, `server.tls_cert`, `server.tls_key`

4
backend/app/audio_player.py

@ -20,7 +20,7 @@ class AudioPlayer: @@ -20,7 +20,7 @@ class AudioPlayer:
raise FileNotFoundError(f"Music file not found: {music_file}")
return candidate
def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None) -> None:
def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None, volume: int = 80) -> None:
with self._lock:
music_path = self._resolve_music_path(music_file)
self.stop()
@ -31,6 +31,8 @@ class AudioPlayer: @@ -31,6 +31,8 @@ class AudioPlayer:
"-autoexit",
"-loglevel",
"error",
"-volume",
str(max(0, min(100, volume))),
"-ss",
str(start_seconds),
]

2
backend/app/main.py

@ -64,6 +64,7 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]: @@ -64,6 +64,7 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]:
music_file=trigger["music_file"],
start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0),
end_seconds=trigger.get("end_seconds"),
volume=int(trigger.get("volume", 80)),
)
return trigger
@ -99,6 +100,7 @@ def _serial_callback(raw_message: str) -> TriggerLogInfo | None: @@ -99,6 +100,7 @@ def _serial_callback(raw_message: str) -> TriggerLogInfo | None:
music_file=trigger["music_file"],
start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0),
end_seconds=trigger.get("end_seconds"),
volume=int(trigger.get("volume", 80)),
)
info: TriggerLogInfo = {"key": trigger_key}

2
backend/app/models.py

@ -11,6 +11,7 @@ class TriggerConfig(BaseModel): @@ -11,6 +11,7 @@ class TriggerConfig(BaseModel):
music_file: str = Field(..., min_length=1, max_length=255)
start_seconds: Optional[float] = Field(default=0.0, ge=0)
end_seconds: Optional[float] = Field(default=None, ge=0)
volume: int = Field(default=80, ge=0, le=100)
@field_validator("end_seconds")
@classmethod
@ -27,6 +28,7 @@ class TriggerPatch(BaseModel): @@ -27,6 +28,7 @@ class TriggerPatch(BaseModel):
music_file: Optional[str] = Field(default=None, min_length=1, max_length=255)
start_seconds: Optional[float] = Field(default=None, ge=0)
end_seconds: Optional[float] = Field(default=None, ge=0)
volume: Optional[int] = Field(default=None, ge=0, le=100)
class ForcePlayRequest(BaseModel):

16
backend/data/conf.json

@ -10,25 +10,27 @@ @@ -10,25 +10,27 @@
"password": "admin"
},
"serial": {
"enabled": true,
"enabled": false,
"port": "/dev/ttyACM0",
"baudrate": 115200,
"timeout": 1
},
"triggers": {
"GPIO3": {
"name": "Test GPIO 23",
"name": "Test",
"type": "GPIO3",
"music_file": "SONNERIE A.mp3",
"start_seconds": 3.0,
"end_seconds": 10.0
"start_seconds": 0.0,
"end_seconds": null,
"volume": 80
},
"GPIO4": {
"name": "Test 2",
"name": "Test",
"type": "GPIO4",
"music_file": "SONNERIE D.mp3",
"music_file": "SONNERIE A.mp3",
"start_seconds": 0.0,
"end_seconds": null
"end_seconds": null,
"volume": 80
}
}
}

58
backend/init.py

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Initialisation du backend pySonnerie.
Cree backend/data/conf.json avec des valeurs par defaut si le fichier
n'existe pas encore. Le mot de passe admin est genere aleatoirement.
"""
from __future__ import annotations
import json
import secrets
import sys
from pathlib import Path
CONF_PATH = Path(__file__).resolve().parent / "data" / "conf.json"
DEFAULT_CONF = {
"server": {
"host": "0.0.0.0",
"port": 8443,
"tls_cert": "certs/cert.pem",
"tls_key": "certs/key.pem",
},
"auth": {
"username": "admin",
"password": None, # remplace par un mot de passe aleatoire
},
"serial": {
"enabled": False,
"port": "/dev/ttyACM0",
"baudrate": 115200,
"timeout": 1,
},
"triggers": {},
}
def main() -> None:
if CONF_PATH.exists():
print(f"[info] {CONF_PATH} existe deja, aucune modification.")
sys.exit(0)
CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
(CONF_PATH.parent / "musiques").mkdir(parents=True, exist_ok=True)
conf = DEFAULT_CONF.copy()
password = secrets.token_urlsafe(16)
conf["auth"] = {"username": "admin", "password": password}
CONF_PATH.write_text(json.dumps(conf, indent=2) + "\n", encoding="utf-8")
CONF_PATH.chmod(0o600)
print(f"[ok] {CONF_PATH} cree (permissions 600).")
print(f"[ok] Identifiants par defaut : admin / {password}")
print("[!] Notez ce mot de passe, il ne sera pas reaffiche.")
if __name__ == "__main__":
main()

39
frontend/README_FRONTEND.md

@ -20,6 +20,37 @@ pip install -r requirements.txt @@ -20,6 +20,37 @@ pip install -r requirements.txt
```
Si présence d'un proxy, la dernière commande sera `pip install -r requirements.txt --proxy http://proxy:port`.
## Configuration
La configuration du frontend se fait via `frontend/data/conf.json`.
Le script `init.py` crée ce fichier automatiquement avec une clé aléatoire :
```bash
cd frontend
python init.py
```
Le fichier généré est :
```json
{
"secret_key": "<clé-aléatoire>"
}
```
Générer une clé sécurisée manuellement (si besoin d'éditer le fichier) :
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Le fichier est créé avec les permissions `600`. Pour les restreindre manuellement :
```bash
chmod 600 frontend/data/conf.json
```
## Execution
```bash
@ -41,13 +72,19 @@ source .venv/bin/activate @@ -41,13 +72,19 @@ source .venv/bin/activate
pip install -r requirements.txt
```
Créer et sécuriser le fichier de configuration :
```bash
sudo -u www-data python /opt/pySonnerie/frontend/init.py
```
Copier le service fourni :
```bash
sudo cp systemd/pysonnerie-frontend.service /etc/systemd/system/
```
Adapter au besoin les variables dans le fichier de service (`FRONTEND_SECRET_KEY`, `FRONTEND_BIND`) puis activer:
Adapter au besoin la variable `FRONTEND_BIND` dans le fichier de service, puis activer :
```bash
sudo systemctl daemon-reload

19
frontend/app/__init__.py

@ -1,18 +1,31 @@ @@ -1,18 +1,31 @@
from __future__ import annotations
import os
import json
from pathlib import Path
from flask import Flask
def _load_secret_key(project_root: Path) -> str:
conf_path = project_root / "frontend" / "data" / "conf.json"
if conf_path.exists():
try:
conf = json.loads(conf_path.read_text(encoding="utf-8"))
key = conf.get("secret_key", "")
if key:
return str(key)
except Exception:
pass
return "pysonnerie-frontend-dev-key"
def create_app() -> Flask:
app = Flask(__name__)
secret = os.getenv("FRONTEND_SECRET_KEY", "pysonnerie-frontend-dev-key")
project_root = Path(__file__).resolve().parents[2]
secret = _load_secret_key(project_root)
app.config["SECRET_KEY"] = secret
project_root = Path(__file__).resolve().parents[2]
app.config["PROJECT_ROOT"] = project_root
app.config["MUSIC_DIR"] = project_root / "backend" / "data" / "musiques"
app.config["MAX_CONTENT_LENGTH"] = 128 * 1024 * 1024

BIN
frontend/app/__pycache__/__init__.cpython-312.pyc

Binary file not shown.

BIN
frontend/app/__pycache__/backend_client.cpython-312.pyc

Binary file not shown.

BIN
frontend/app/__pycache__/routes.cpython-312.pyc

Binary file not shown.

2
frontend/app/backend_client.py

@ -5,7 +5,9 @@ from pathlib import Path @@ -5,7 +5,9 @@ from pathlib import Path
from typing import Any
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class BackendApiError(RuntimeError):
pass

59
frontend/app/routes.py

@ -8,6 +8,7 @@ from flask import ( @@ -8,6 +8,7 @@ from flask import (
Response,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
@ -139,6 +140,8 @@ def dashboard() -> str | Response: @@ -139,6 +140,8 @@ def dashboard() -> str | Response:
def save_trigger() -> Response:
client_or_redirect = _ensure_login()
if isinstance(client_or_redirect, Response):
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Non connecte"}), 401
return client_or_redirect
client = client_or_redirect
@ -148,25 +151,32 @@ def save_trigger() -> Response: @@ -148,25 +151,32 @@ def save_trigger() -> Response:
music_file = request.form.get("music_file", "").strip()
start_raw = request.form.get("start_seconds", "0")
end_raw = request.form.get("end_seconds", "")
volume_raw = request.form.get("volume", "80")
if not GPIO_PATTERN.match(trigger_type):
flash("Le type doit respecter le format GPIO<number>.", "error")
def _err(msg: str) -> Response:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": msg}), 400
flash(msg, "error")
return redirect(url_for("ui.dashboard"))
if not GPIO_PATTERN.match(trigger_type):
return _err("Le type doit respecter le format GPIO<number>.")
if not name or not music_file:
flash("Le nom et le fichier audio sont obligatoires.", "error")
return redirect(url_for("ui.dashboard"))
return _err("Le nom et le fichier audio sont obligatoires.")
try:
start_seconds = float(start_raw)
end_seconds = _parse_optional_float(end_raw)
volume = int(volume_raw)
except ValueError:
flash("Les temps de debut/fin doivent être numériques.", "error")
return redirect(url_for("ui.dashboard"))
return _err("Les temps de debut/fin et le volume doivent être numériques.")
if start_seconds < 0 or (end_seconds is not None and end_seconds <= start_seconds):
flash("Fenêtre temporelle invalide.", "error")
return redirect(url_for("ui.dashboard"))
return _err("Fenêtre temporelle invalide.")
if not (0 <= volume <= 100):
return _err("Le volume doit être compris entre 0 et 100.")
payload = {
"name": name,
@ -174,6 +184,7 @@ def save_trigger() -> Response: @@ -174,6 +184,7 @@ def save_trigger() -> Response:
"music_file": music_file,
"start_seconds": start_seconds,
"end_seconds": end_seconds,
"volume": volume,
}
try:
@ -183,9 +194,19 @@ def save_trigger() -> Response: @@ -183,9 +194,19 @@ def save_trigger() -> Response:
else:
client.upsert_trigger(trigger_type, payload)
except BackendApiError as exc:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": str(exc)}), 502
flash(f"Echec d'enregistrement du trigger: {exc}", "error")
return redirect(url_for("ui.dashboard"))
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({
"ok": True,
"message": f"Trigger {trigger_type} enregistre.",
"trigger_id": trigger_type,
"original_id": original_id or trigger_type,
"trigger": payload,
})
flash(f"Trigger {trigger_type} enregistré.", "success")
return redirect(url_for("ui.dashboard"))
@ -194,18 +215,26 @@ def save_trigger() -> Response: @@ -194,18 +215,26 @@ def save_trigger() -> Response:
def delete_trigger() -> Response:
client_or_redirect = _ensure_login()
if isinstance(client_or_redirect, Response):
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Non connecte"}), 401
return client_or_redirect
client = client_or_redirect
trigger_id = request.form.get("trigger_id", "").strip()
if not trigger_id:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Identifiant du trigger manquant."}), 400
flash("Identifiant du trigger manquant.", "error")
return redirect(url_for("ui.dashboard"))
try:
client.delete_trigger(trigger_id)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True, "message": f"Trigger {trigger_id} supprime.", "trigger_id": trigger_id})
flash(f"Trigger {trigger_id} supprime.", "success")
except BackendApiError as exc:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": f"Echec de suppression: {exc}"}), 500
flash(f"Echec de suppression: {exc}", "error")
return redirect(url_for("ui.dashboard"))
@ -214,18 +243,26 @@ def delete_trigger() -> Response: @@ -214,18 +243,26 @@ def delete_trigger() -> Response:
def play_trigger() -> Response:
client_or_redirect = _ensure_login()
if isinstance(client_or_redirect, Response):
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Non connecte"}), 401
return client_or_redirect
client = client_or_redirect
trigger_id = request.form.get("trigger_id", "").strip()
if not trigger_id:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Identifiant du trigger manquant."}), 400
flash("Identifiant du trigger manquant.", "error")
return redirect(url_for("ui.dashboard"))
try:
client.play_trigger(trigger_id)
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True, "message": f"Trigger {trigger_id} demarre."})
flash(f"Trigger {trigger_id} demarré.", "success")
except BackendApiError as exc:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": str(exc)}), 502
flash(f"Echec du lancement: {exc}", "error")
return redirect(url_for("ui.dashboard"))
@ -234,13 +271,19 @@ def play_trigger() -> Response: @@ -234,13 +271,19 @@ def play_trigger() -> Response:
def stop_audio() -> Response:
client_or_redirect = _ensure_login()
if isinstance(client_or_redirect, Response):
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": "Non connecte"}), 401
return client_or_redirect
client = client_or_redirect
try:
client.stop_audio()
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": True, "message": "Audio arrete."})
flash("Audio arrete.", "info")
except BackendApiError as exc:
if request.headers.get("X-Requested-With") == "fetch":
return jsonify({"ok": False, "error": str(exc)}), 502
flash(f"Echec de l'arrêt audio: {exc}", "error")
return redirect(url_for("ui.dashboard"))

11
frontend/app/static/css/style.css

@ -293,6 +293,17 @@ button.small { @@ -293,6 +293,17 @@ button.small {
min-width: 0;
}
.volume-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.volume-row input[type="range"] {
width: 100%;
accent-color: var(--brand);
}
.table-wrap {
overflow-x: auto;
}

345
frontend/app/templates/dashboard.html

@ -7,9 +7,7 @@ @@ -7,9 +7,7 @@
<p class="muted">Backend: {{ backend_url }} | Utilisateur: {{ username }}</p>
</div>
<div class="topbar-actions">
<form method="post" action="{{ url_for('ui.stop_audio') }}">
<button type="submit" class="danger">Arreter l'audio</button>
</form>
<button type="button" class="danger" id="btn-stop-audio">Arreter l'audio</button>
<a href="{{ url_for('ui.logout') }}" class="ghost-link">Deconnexion</a>
</div>
</header>
@ -49,15 +47,23 @@ @@ -49,15 +47,23 @@
<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>
<button type="submit">Enregistrer le trigger</button>
</form>
</section>
<section class="panel reveal delay-2">
<h2>Liste des triggers</h2>
{% if triggers %}
<div class="table-wrap">
<table>
<p class="muted" id="no-triggers-msg"{% if triggers %} hidden{% endif %}>Aucun trigger configure.</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>ID</th>
@ -66,10 +72,11 @@ @@ -66,10 +72,11 @@
<th>Audio</th>
<th>Debut</th>
<th>Fin</th>
<th>Volume</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tbody id="triggers-tbody">
{% for trigger_id, trigger in triggers.items() %}
<tr
class="trigger-row"
@ -79,6 +86,7 @@ @@ -79,6 +86,7 @@
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) }}"
title="Cliquer pour charger ce trigger dans le formulaire"
>
<td>{{ trigger_id }}</td>
@ -87,24 +95,25 @@ @@ -87,24 +95,25 @@
<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>
<form method="post" action="{{ url_for('ui.play_trigger') }}" class="inline-form">
<input type="hidden" name="trigger_id" value="{{ trigger_id }}" />
<button type="submit" class="small">Lancer</button>
</form>
<form method="post" action="{{ url_for('ui.delete_trigger') }}" class="inline-form" onsubmit="return confirm('Supprimer le trigger {{ trigger_id }} ?')">
<input type="hidden" name="trigger_id" value="{{ trigger_id }}" />
<button type="submit" class="small danger">Supprimer</button>
</form>
<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>
{% else %}
<p class="muted">Aucun trigger configure.</p>
{% endif %}
</section>
<section class="panel reveal delay-3">
@ -160,6 +169,17 @@ @@ -160,6 +169,17 @@
</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="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 deja utilise</h3>
@ -171,6 +191,180 @@ @@ -171,6 +191,180 @@
</div>
<script>
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 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 : "";
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");
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.innerHTML = `
<td>${triggerId}</td>
<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>
<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", () => {
const tid = playBtn.dataset.triggerId;
const url = playBtn.dataset.playUrl;
if (!tid || !url) 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 demarre.") : (d.error || "Echec."), d.ok ? "success" : "error"); })
.catch(() => { showFlash("Erreur reseau 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 arrete.", "info");
} else {
showFlash(data.error || "Echec de l'arret audio.", "error");
}
})
.catch(() => {
showFlash("Erreur reseau lors de l'arret audio.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
})();
(function () {
document.querySelectorAll(".js-play-trigger").forEach((btn) => {
btn.addEventListener("click", () => {
const triggerId = btn.dataset.triggerId;
const url = btn.dataset.playUrl;
if (!triggerId || !url) 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 demarre.", "success");
} else {
showFlash(data.error || "Echec du lancement.", "error");
}
})
.catch(() => {
showFlash("Erreur reseau lors du lancement.", "error");
})
.finally(() => {
btn.disabled = false;
});
});
});
})();
(function () {
const form = document.getElementById("trigger-form");
if (!form) return;
@ -181,6 +375,39 @@ @@ -181,6 +375,39 @@
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");
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 enregistre.", "success");
upsertTriggerRow(data);
} else {
showFlash(data.error || "Echec de l'enregistrement.", "error");
}
})
.catch(() => {
showFlash("Erreur reseau lors de l'enregistrement.", "error");
})
.finally(() => {
if (submitBtn) submitBtn.disabled = false;
});
});
const rows = document.querySelectorAll("tr.trigger-row");
rows.forEach((row) => {
@ -195,6 +422,10 @@ @@ -195,6 +422,10 @@
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;
}
typeInput?.focus();
form.scrollIntoView({ behavior: "smooth", block: "start" });
@ -227,8 +458,7 @@ @@ -227,8 +458,7 @@
player.addEventListener("ended", updateTimeLabel);
const playButtons = document.querySelectorAll(".js-play-browser");
playButtons.forEach((button) => {
button.addEventListener("click", () => {
playButtons.forEach((button) => { button.addEventListener("click", () => {
const src = button.dataset.audioUrl;
const name = button.dataset.audioName || "Fichier audio";
if (!src) return;
@ -343,5 +573,80 @@ @@ -343,5 +573,80 @@
}
});
})();
(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;
const openModal = (triggerId) => {
pendingTriggerId = triggerId;
message.textContent = `Voulez-vous vraiment supprimer le trigger "${triggerId}" ?`;
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;
};
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 supprime.", "success");
} else {
showFlash(data.error || "Echec de la suppression.", "error");
}
})
.catch(() => {
showFlash("Erreur reseau lors de la suppression.", "error");
})
.finally(() => {
confirmBtn.disabled = false;
});
});
})();
</script>
{% endblock %}

2
frontend/app/templates/login.html

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
<form method="post" class="form-grid">
<label>
Nom d'utilisateur
<input type="text" name="username" placeholder="admin" value="{{ attempted_username or '' }}" required />
<input type="text" name="username" value="{{ attempted_username or '' }}" required />
</label>
<label>
Mot de passe

32
frontend/init.py

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Initialisation du frontend pySonnerie.
Cree frontend/data/conf.json avec une secret_key aleatoire si le fichier
n'existe pas encore.
"""
from __future__ import annotations
import json
import secrets
import sys
from pathlib import Path
CONF_PATH = Path(__file__).resolve().parent / "data" / "conf.json"
def main() -> None:
if CONF_PATH.exists():
print(f"[info] {CONF_PATH} existe deja, aucune modification.")
sys.exit(0)
CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
conf = {"secret_key": secrets.token_hex(32)}
CONF_PATH.write_text(json.dumps(conf, indent=2) + "\n", encoding="utf-8")
CONF_PATH.chmod(0o600)
print(f"[ok] {CONF_PATH} cree avec une cle aleatoire (permissions 600).")
if __name__ == "__main__":
main()

1
frontend/systemd/pysonnerie-frontend.service

@ -7,7 +7,6 @@ Type=simple @@ -7,7 +7,6 @@ Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/pySonnerie/frontend
Environment=FRONTEND_SECRET_KEY=change-me
Environment=FRONTEND_BIND=0.0.0.0:5000
ExecStart=/opt/pySonnerie/frontend/.venv/bin/gunicorn --workers 2 --bind ${FRONTEND_BIND} wsgi:app
Restart=on-failure

Loading…
Cancel
Save