From 23f2136058c2f550333c2bef4f06b9f7c4b685f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Fri, 27 Mar 2026 14:56:49 +0100 Subject: [PATCH] Sauvegarde vers le serveur --- .gitignore | 1 + public/auth.js | 46 ++- public/callback.html | 20 - public/callback.php | 54 +++ public/index.html | 19 +- public/profile.php | 81 ++++ public/readme.md | 4 +- public/script.js | 822 ++++++++++++++++++++++++++++------------- public/sw.js | 5 +- public/sync_global.php | 85 +++++ 10 files changed, 835 insertions(+), 302 deletions(-) create mode 100644 .gitignore delete mode 100644 public/callback.html create mode 100644 public/callback.php create mode 100644 public/profile.php create mode 100644 public/sync_global.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/public/auth.js b/public/auth.js index beaffca..dfe1a56 100644 --- a/public/auth.js +++ b/public/auth.js @@ -1,20 +1,24 @@ // auth.js - Gestion de la connexion SSO + const authConfig = { - authority: "https://idp.a5l.fr/realms/A5L", - client_id: "soundboard_a5l", // À enregistrer sur votre IdP - redirect_uri: window.location.origin + "/callback.html", - response_type: "code", - scope: "openid profile email" + authority: "https://idp.a5l.fr/realms/A5L", + client_id: "soundboard_a5l", + // MODIFICATION : pointer vers le fichier PHP + redirect_uri: window.location.origin + "/callback.php", + response_type: "code", + scope: "openid profile email" }; function login() { - // Construction de l'URL exacte attendue par Keycloak + const state = generateState(); + localStorage.setItem('auth_state', state); // Optionnel : pour vérification CSRF + const authUrl = `${authConfig.authority}/protocol/openid-connect/auth?` + `client_id=${authConfig.client_id}&` + `redirect_uri=${encodeURIComponent(authConfig.redirect_uri)}&` + `response_type=${authConfig.response_type}&` + `scope=${authConfig.scope}&` + - `state=${generateState()}`; // Sécurité recommandée + `state=${state}`; window.location.href = authUrl; } @@ -30,8 +34,10 @@ function generateState() { } function logout() { - localStorage.removeItem('auth_token'); - window.location.reload(); + localStorage.removeItem('auth_token'); + // On supprime le cookie en le faisant expirer + document.cookie = "auth_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + window.location.href = 'index.html'; } function checkAuth() { @@ -41,4 +47,26 @@ function checkAuth() { return false; } return true; +} + +function updateAuthUI() { + const token = localStorage.getItem('auth_token'); + const userNameDisplay = document.getElementById('userNameDisplay'); + + if (token && token.includes('.')) { + try { + // Décodage sécurisé du Base64Url + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(window.atob(base64)); + + const username = payload.preferred_username || "Utilisateur"; + + if (userNameDisplay) { + userNameDisplay.innerText = username.toUpperCase(); + } + } catch (e) { + console.error("Erreur décodage token", e); + } + } } \ No newline at end of file diff --git a/public/callback.html b/public/callback.html deleted file mode 100644 index c016916..0000000 --- a/public/callback.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/public/callback.php b/public/callback.php new file mode 100644 index 0000000..4de5272 --- /dev/null +++ b/public/callback.php @@ -0,0 +1,54 @@ + 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'client_secret'=> $clientSecret +]; +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData)); +$response = curl_exec($ch); + +if (!$response) { + die("Erreur CURL : " . curl_error($ch)); +} + +$data = json_decode($response, true); +if (isset($data['error'])) { + die("Erreur IDP : " . $data['error_description']); +} + +curl_close($ch); + +if (isset($data['access_token'])) { + $token = $data['access_token']; + // On décode pour récupérer le login pour le JS + $parts = explode('.', $token); + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); + $login = $payload['preferred_username'] ?? 'User'; + + // COOKIE POUR PHP + setcookie("auth_token", $token, time() + 3600, "/", "", true, false); + + // REDIRECTION ET STORAGE POUR JS + echo ""; + exit(); +} else { + header("Location: profile.php?error=failed"); + exit(); +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index bd22e9b..f1fec9c 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,6 @@ Soundboard A5L - @@ -17,18 +16,18 @@
- 1.214 + + 1.230 + - - + +
- - + + 👤 + MON PROFIL +
-
diff --git a/public/profile.php b/public/profile.php new file mode 100644 index 0000000..7d2f657 --- /dev/null +++ b/public/profile.php @@ -0,0 +1,81 @@ + 'Guest', + 'name' => 'Visiteur', + 'email' => 'Non renseigné', + 'logged' => false +]; + +if ($token) { + $parts = explode('.', $token); + if (count($parts) >= 2) { + $payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true); + if ($payload) { + $userData['login'] = preg_replace('/[^a-zA-Z0-9\-\.\@]/', '', $payload['preferred_username'] ?? ($payload['sub'] ?? '')); + $userData['name'] = $payload['name'] ?? 'Utilisateur'; + $userData['email'] = $payload['email'] ?? 'Non renseigné'; + $userData['logged'] = true; + } + } +} +?> + + + + + Profil - Cloud A5L + + + + + + +
+ +
+

+ + + + + +
?
+

Accès Restreint

+

Authentification requise via A5L Identity Provider.

+ + + + +
+ + +
+
+ + Accueil +
+ + + \ No newline at end of file diff --git a/public/readme.md b/public/readme.md index 14ad62e..d4e467c 100644 --- a/public/readme.md +++ b/public/readme.md @@ -11,15 +11,17 @@ * Progressif Web App : fonctionnement offline * Utilisation d'IndexedDB pour stocker les son * Empecher le device de se mettre en veille +* Authentification A5L +* Stocker les sons sur le serveur ## Fonctionnalités à venir * Suppression d'une association -* Authentification pour stocker les sons sur le serveur * Authentification pour récuperer les sons depuis le serveur * Partager des sons pour que d'autres puissent les déployer dans leur soundboard * Rechercher des sons dans sa médiathèque ou des sons partagés pour une diffusion One Shot * Ajouter des sons partagés par d'autres utilisateurs +* Ajouter tous les sons d'un dossier Vous pouvez trouver des sons sur https://boardsounds.com/fr diff --git a/public/script.js b/public/script.js index b45a80d..9de15e8 100644 --- a/public/script.js +++ b/public/script.js @@ -6,216 +6,264 @@ let deferredPrompt; const DB_NAME = "SoundboardDB"; const STORE_NAME = "sounds"; +const players = {}; +let draggedIndex = null; + +let btnData = JSON.parse(localStorage.getItem('sb_studio_data')) || Array(64).fill(null).map((_, i) => ({ + id: i, name: "", file: null, loop: false, volume: 1, hasFile: false +})); const installBtn = document.getElementById('installBtn'); const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); -function openDB() { +/* --- BASE DE DONNÉES INDEXEDDB --- */ + +let dbInstance = null; + +/** + * Ouvre ou récupère l'instance de la base de données (Singleton). + */ +async function getDB() { + if (dbInstance) return dbInstance; + return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }; - request.onsuccess = (e) => resolve(e.target.result); - request.onerror = (e) => reject(e.target.error); + + request.onsuccess = (e) => { + dbInstance = e.target.result; + resolve(dbInstance); + }; + + request.onerror = (e) => { + console.error("Erreur IndexedDB:", e.target.error); + reject(e.target.error); + }; }); } +/** + * Enregistre un son dans la base de données. + */ async function saveSound(id, data) { - const db = await openDB(); - const tx = db.transaction(STORE_NAME, "readwrite"); - tx.objectStore(STORE_NAME).put(data, id); - return tx.complete; -} - -async function getSound(id) { - const db = await openDB(); - return new Promise((resolve) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const request = tx.objectStore(STORE_NAME).get(id); - request.onsuccess = () => resolve(request.result); - }); -} - -// Lancement automatique au démarrage -// On attend que la page soit prête -window.addEventListener('load', () => { - if (checkAuth()) { - document.getElementById('loginBtn').style.display = 'none'; - document.getElementById('userProfile').style.display = 'flex'; - // Charger les données utilisateur depuis le serveur ici si besoin - init(); - } else { - // Optionnel : afficher une grille vide ou un message - console.log("Utilisateur non connecté."); - } - - migrateToIndexedDB(); -}); - -window.addEventListener('beforeinstallprompt', (e) => { - // Empêche Chrome d'afficher sa propre mini-barre - e.preventDefault(); - // Stocke l'événement pour l'utiliser plus tard - deferredPrompt = e; - // Affiche votre bouton personnalisé - installBtn.style.display = 'block'; - - installBtn.addEventListener('click', async () => { - if (deferredPrompt) { - // Montre la fenêtre d'installation native - deferredPrompt.prompt(); - // Attend la réponse de l'utilisateur - const { outcome } = await deferredPrompt.userChoice; - console.log(`L'utilisateur a répondu : ${outcome}`); - // On nettoie - deferredPrompt = null; - installBtn.style.display = 'none'; - } - }); -}); - -if (isFirefox && !window.matchMedia('(display-mode: standalone)').matches) { - const infoZone = document.getElementById('app-version'); - if (infoZone) { - // Message plus pro-actif pour l'utilisateur - infoZone.innerHTML += "
(Utilisez 'Installer' dans le menu Firefox)"; - infoZone.style.color = "#ff8800"; - } -} - -// Cache le bouton si l'app est déjà installée -window.addEventListener('appinstalled', () => { -installBtn.style.display = 'none'; -deferredPrompt = null; -console.log('PWA installée avec succès !'); -}); - -// Données pour 64 boutons (Grille 8x8) -let btnData = JSON.parse(localStorage.getItem('sb_studio_data')) || Array(64).fill(null).map((_, i) => ({ - id: i, name: "", file: null, loop: false, volume: 1 -})); - -const players = {}; - -let draggedIndex = null; - -function handleDragStart(e, index) { - draggedIndex = index; - e.target.classList.add('dragging'); - // On définit un transfert de données pour Firefox - e.dataTransfer.setData('text/plain', index); -} - -function handleDragOver(e) { - e.preventDefault(); // Nécessaire pour permettre le drop - return false; -} - -function handleDrop(e, targetIndex) { - e.preventDefault(); - const targetElement = document.getElementById(`btn-${targetIndex}`); - targetElement.classList.remove('drag-over'); - - if (draggedIndex === targetIndex) return; - - // --- LOGIQUE D'INVERSION --- - // On inverse les données dans le tableau btnData - const temp = btnData[draggedIndex]; - btnData[draggedIndex] = btnData[targetIndex]; - btnData[targetIndex] = temp; - - // On s'assure que les IDs restent corrects (optionnel selon ton usage) - btnData[draggedIndex].id = draggedIndex; - btnData[targetIndex].id = targetIndex; - - // Si des sons étaient en cours, on les arrête pour éviter les bugs - stopAll(); - - // On réinitialise les lecteurs audio pour ces deux boutons - delete players[draggedIndex]; - delete players[targetIndex]; - - // Sauvegarde et mise à jour de l'affichage - localStorage.setItem('sb_studio_data', JSON.stringify(btnData)); - init(); -} - -// Fonction pour demander le verrouillage de l'écran -async function requestWakeLock() { try { - if ('wakeLock' in navigator) { - wakeLock = await navigator.wakeLock.request('screen'); - console.log("L'écran est verrouillé (ne s'éteindra pas)"); + const db = await getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); - // Si le verrouillage est libéré (ex: perte de focus), on le réinitialise - wakeLock.addEventListener('release', () => { - console.log("Le verrouillage de l'écran a été libéré"); - }); - } + store.put(data, id); + + tx.oncomplete = () => resolve(); + tx.onerror = (e) => reject(e.target.error); + }); } catch (err) { - console.error(`${err.name}, ${err.message}`); + console.error(`Erreur sauvegarde son ${id}:`, err); } } -// Réactiver le verrouillage quand l'app revient au premier plan -document.addEventListener('visibilitychange', async () => { - if (wakeLock !== null && document.visibilityState === 'visible') { - await requestWakeLock(); - } -}); +/** + * Récupère un son depuis la base de données. + */ +async function getSound(id) { + try { + const db = await getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(id); + request.onsuccess = () => resolve(request.result); + request.onerror = (e) => reject(e.target.error); + }); + } catch (err) { + console.error(`Erreur récupération son ${id}:`, err); + return null; + } +} + +/* --- NAVIGATION ET GRILLE --- */ function init() { const board = document.getElementById('board'); + if (!board) return; board.innerHTML = ""; btnData.forEach((data, i) => { const div = document.createElement('div'); - div.className = `btn ${data.file ? 'active' : ''} ${data.loop ? 'has-loop' : ''}`; + // On utilise data.hasFile pour la classe active + div.className = `btn ${data.hasFile ? 'active' : ''} ${data.loop ? 'has-loop' : ''}`; div.id = `btn-${i}`; - - // Le bouton n'est "draggable" que si le mode édition est actif div.setAttribute('draggable', isEditMode ? 'true' : 'false'); - div.innerHTML = `${i+1} ${data.name || "-"} `; div.onclick = () => handleBtnClick(i); - - // Les événements de Drag & Drop (ils ne se déclencheront que si draggable est true) div.ondragstart = (e) => handleDragStart(e, i); div.ondragover = (e) => handleDragOver(e); - div.ondragenter = (e) => isEditMode && div.classList.add('drag-over'); - div.ondragleave = (e) => div.classList.remove('drag-over'); div.ondrop = (e) => handleDrop(e, i); - div.ondragend = (e) => div.classList.remove('dragging'); board.appendChild(div); }); } -function toggleEditMode() { - isEditMode = !isEditMode; - document.body.classList.toggle('edit-mode', isEditMode); - - // Mise à jour visuelle du bouton - const toggleBtn = document.getElementById('toggleBtn'); - toggleBtn.innerText = isEditMode ? "QUITTER L'ÉDITION" : "MODE ÉDITION"; - toggleBtn.style.background = isEditMode ? "var(--accent)" : "#444"; - toggleBtn.style.color = isEditMode ? "black" : "white"; - - // On réinitialise la grille pour appliquer le changement de 'draggable' - init(); -} - function handleBtnClick(i) { if (isEditMode) openEdit(i); else playSound(i); } +function toggleEditMode() { + isEditMode = !isEditMode; + document.body.classList.toggle('edit-mode', isEditMode); + const toggleBtn = document.getElementById('toggleBtn'); + toggleBtn.innerText = isEditMode ? "QUITTER L'ÉDITION" : "MODE ÉDITION"; + init(); +} + + + + +/* --- LECTURE AUDIO --- */ + +/** + * Gère la lecture, la pause et la progression d'un échantillon sonore. + * @param {number} i - Index du bouton. + */ +async function playSound(i) { + const data = btnData[i]; + if (!data.hasFile) return; + + const btnElement = document.getElementById(`btn-${i}`); + if (!btnElement) return; + + // 1. Si déjà en lecture : on arrête proprement + if (players[i] && !players[i].paused) { + players[i].pause(); + players[i].currentTime = 0; + resetButtonUI(btnElement); + return; + } + + try { + // 2. Initialisation du lecteur si inexistant + if (!players[i]) { + const fileData = await getSound(`sound-${i}`); + if (!fileData) throw new Error("Données audio introuvables"); + + players[i] = new Audio(fileData); + } + + const audio = players[i]; + audio.loop = data.loop || false; + audio.volume = data.volume ?? 1; + + // 3. Événements de mise à jour UI + audio.ontimeupdate = () => { + if (audio.duration) { + const percentage = (audio.currentTime / audio.duration) * 100; + btnElement.style.setProperty('--progress', `${percentage.toFixed(2)}%`); + } + }; + + audio.onended = () => { + if (!audio.loop) { + resetButtonUI(btnElement); + } + }; + + // 4. Lancement de la lecture (gère la promesse pour éviter les erreurs de navigateur) + await audio.play(); + btnElement.classList.add('playing'); + + } catch (err) { + console.error(`Erreur de lecture (Bouton ${i}):`, err); + // En cas d'erreur (ex: format non supporté), on nettoie l'UI + resetButtonUI(btnElement); + } +} + +/** + * Arrête tous les sons en cours et réinitialise l'interface de tous les boutons. + */ +function stopAll() { + Object.keys(players).forEach(key => { + const audio = players[key]; + if (audio) { + audio.pause(); + audio.currentTime = 0; + } + + const btn = document.getElementById(`btn-${key}`); + if (btn) resetButtonUI(btn); + }); +} + +/** + * Utilitaire interne pour remettre un bouton à son état initial. + * @param {HTMLElement} btn + */ +function resetButtonUI(btn) { + btn.classList.remove('playing'); + btn.style.setProperty('--progress', '0%'); +} + +/* --- CYCLE DE VIE (LOAD / INSTALL) --- */ + +/** + * Initialisation principale au chargement de la page. + */ +window.addEventListener('load', async () => { + + // 2. Initialisation de la grille + init(); + + // 3. Traitements asynchrones (Migration & Verrouillage écran) + try { + await migrateToIndexedDB(); + await requestWakeLock(); + } catch (err) { + console.warn("Échec des services secondaires :", err); + } +}); + + +/** + * Gestion de l'installation PWA (Prompt). + */ +window.addEventListener('beforeinstallprompt', (e) => { + // Empêche l'affichage automatique de la bannière système + e.preventDefault(); + deferredPrompt = e; + + // Affiche notre bouton d'installation personnalisé + if (installBtn) { + installBtn.style.display = 'block'; + } +}); + +// Écouteur sur le bouton d'installation +if (installBtn) { + installBtn.addEventListener('click', async () => { + if (!deferredPrompt) return; + + // Déclenche le prompt natif + deferredPrompt.prompt(); + + // Attend la réponse de l'utilisateur + const { outcome } = await deferredPrompt.userChoice; + console.log(`Résultat installation : ${outcome}`); + + // Nettoyage + deferredPrompt = null; + installBtn.style.display = 'none'; + }); +} + async function migrateToIndexedDB() { // 1. Vérifie si des données existent dans le localStorage const rawData = localStorage.getItem('sb_studio_data'); @@ -258,124 +306,270 @@ async function migrateToIndexedDB() { } } +/* --- MODALE D'ÉDITION --- */ - -async function playSound(i) { - const data = btnData[i]; - if (!data.hasFile) return; - - const btnElement = document.getElementById(`btn-${i}`); - - // Si déjà en lecture : on arrête - if (players[i] && !players[i].paused) { - players[i].pause(); - players[i].currentTime = 0; - btnElement.classList.remove('playing'); - btnElement.style.setProperty('--progress', '0%'); - } else { - // Si le player n'existe pas ou a été supprimé, on le crée - if (!players[i]) { - const fileData = await getSound(`sound-${i}`); - if (!fileData) return; - players[i] = new Audio(fileData); - } - - players[i].loop = data.loop; - players[i].volume = data.volume || 1; - - // Gestion de la progression - players[i].ontimeupdate = () => { - const percentage = (players[i].currentTime / players[i].duration) * 100; - btnElement.style.setProperty('--progress', `${percentage}%`); - }; - - // --- CORRECTION ICI : Gestion de la fin de lecture --- - players[i].onended = () => { - btnElement.style.setProperty('--progress', '0%'); - // On ne retire la classe que si on n'est pas en boucle - if (!data.loop) { - btnElement.classList.remove('playing'); - } - }; - - players[i].play(); - btnElement.classList.add('playing'); - } -} - -function stopAll() { - Object.keys(players).forEach(key => { - players[key].pause(); players[key].currentTime = 0; - document.getElementById(`btn-${key}`).classList.remove('playing'); - }); -} - +/** + * Ouvre la modale et pré-remplit les champs avec les données du bouton. + * @param {number} i - Index du bouton dans btnData. + */ function openEdit(i) { editingIndex = i; const data = btnData[i]; - document.getElementById('editTitle').innerText = "Bouton " + (i + 1); - document.getElementById('inputName').value = data.name; - document.getElementById('inputLoop').checked = data.loop; - document.getElementById('inputVolume').value = data.volume || 1; - document.getElementById('fileNameLabel').innerText = data.file ? "Audio présent" : "Audio vide"; - tempFileData = data.file; - document.getElementById('editOverlay').style.display = 'flex'; + + // Mise à jour des éléments texte + document.getElementById('editTitle').textContent = `Configuration Bouton ${i + 1}`; + document.getElementById('fileNameLabel').textContent = data.hasFile ? "✅ Son présent" : "❌ Aucun son"; + + // Remplissage des champs de saisie + document.getElementById('inputName').value = data.name || ""; + document.getElementById('inputLoop').checked = data.loop || false; + document.getElementById('inputVolume').value = data.volume ?? 1; + + // Reset du cache temporaire de fichier + tempFileData = null; + + // Affichage de l'overlay + const overlay = document.getElementById('editOverlay'); + if (overlay) overlay.style.display = 'flex'; } -document.getElementById('fileInput').onchange = (e) => { +/** + * Gère la sélection d'un nouveau fichier audio. + */ +document.getElementById('fileInput').addEventListener('change', (e) => { const file = e.target.files[0]; - if (file) { - // Extraction du nom sans l'extension - // .replace(/\.[^/.]+$/, "") retire tout ce qui suit le dernier point - const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, ""); - - // On met à jour le champ texte du nom dans la modale - document.getElementById('inputName').value = fileNameWithoutExtension; - - const reader = new FileReader(); - reader.onload = (ev) => { - tempFileData = ev.target.result; - document.getElementById('fileNameLabel').innerText = "Fichier : " + file.name; - }; - reader.readAsDataURL(file); - } -}; + if (!file) return; -async function saveSettings() { - const name = document.getElementById('inputName').value; - const loop = document.getElementById('inputLoop').checked; - const volume = parseFloat(document.getElementById('inputVolume').value); - - // Mise à jour des métadonnées - btnData[editingIndex].name = name; - btnData[editingIndex].loop = loop; - btnData[editingIndex].volume = volume; + // Extraction du nom (sans extension) pour pré-remplir le champ nom + const fileNameWithoutExtension = file.name.replace(/\.[^/.]+$/, ""); + document.getElementById('inputName').value = fileNameWithoutExtension; - // Sauvegarde du fichier lourd dans IndexedDB si présent + const label = document.getElementById('fileNameLabel'); + label.textContent = "⌛ Chargement..."; + + const reader = new FileReader(); + reader.onload = (ev) => { + tempFileData = ev.target.result; + label.textContent = `Fichier prêt : ${file.name}`; + }; + reader.onerror = () => { + label.textContent = "❌ Erreur lors de la lecture du fichier"; + }; + reader.readAsDataURL(file); +}); + + +/* --- LOGIQUE DE SAUVEGARDE ET BACKUP --- */ + + +/** + * Enregistre les modifications localement et lance le backup serveur. + */ +/** + * Enregistre les modifications localement et lance le backup serveur immédiat si connecté. + */ +async function saveSettings() { + const nameInput = document.getElementById('inputName'); + const loopInput = document.getElementById('inputLoop'); + const volumeInput = document.getElementById('inputVolume'); + + const updatedData = btnData[editingIndex]; + updatedData.name = nameInput.value.trim(); + updatedData.loop = loopInput.checked; + updatedData.volume = parseFloat(volumeInput.value); + + // 1. Sauvegarde locale (IndexedDB) si nouveau fichier if (tempFileData) { - await saveSound(`sound-${editingIndex}`, tempFileData); - btnData[editingIndex].hasFile = true; // Flag pour savoir qu'un son existe + try { + await saveSound(`sound-${editingIndex}`, tempFileData); + updatedData.hasFile = true; + } catch (err) { + console.error("Erreur IndexedDB :", err); + alert("Erreur lors de la sauvegarde locale."); + return; + } } - // Sauvegarde des réglages légers dans localStorage + // 2. Sauvegarde des métadonnées locales localStorage.setItem('sb_studio_data', JSON.stringify(btnData)); - // Nettoyage du lecteur existant - if (players[editingIndex]) delete players[editingIndex]; + // 3. Réinitialisation du lecteur audio + if (players[editingIndex]) { + players[editingIndex].pause(); + delete players[editingIndex]; + } + // 4. Fermeture UI tempFileData = null; closeEdit(); init(); + + // 5. SYNCHRO AUTOMATIQUE CLOUD (Si loggué) + const token = localStorage.getItem('auth_token'); + if (token) { + console.log(`Cloud: Synchronisation automatique du bouton ${editingIndex}...`); + syncSingleButtonToCloud(editingIndex, updatedData, token); + } } -// Désactive le menu contextuel (clic droit) sur toute la page -window.oncontextmenu = function(event) { - event.preventDefault(); - event.stopPropagation(); - return false; -}; -function closeEdit() { document.getElementById('editOverlay').style.display = 'none'; } +/** + * Synchronise un seul bouton vers le cloud (appelé après modification individuelle) + */ +async function syncSingleButtonToCloud(index, data, token) { + try { + const formData = new FormData(); + formData.append('index', index); + formData.append('name', data.name); + formData.append('loop', data.loop); + formData.append('volume', data.volume); + + // Récupérer l'audio depuis IndexedDB pour l'envoi + const soundData = await getSound(`sound-${index}`); + if (soundData) { + const response = await fetch(soundData); + const blob = await response.blob(); + formData.append('audio', blob, `sound-${index}.mp3`); + } + + const res = await fetch('sync_global.php', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + if (res.ok) { + console.log(`✅ Bouton ${index} synchronisé avec succès.`); + } else { + console.warn(`⚠️ Échec synchro bouton ${index}: ${res.statusText}`); + } + } catch (err) { + console.error(`❌ Erreur réseau synchro bouton ${index}:`, err); + } +} + + +async function syncAllToCloud() { + const btn = document.getElementById('syncBtn'); + if (!btn) return; + + // Récupération du token depuis le localStorage (posé par callback.php) + const token = localStorage.getItem('auth_token'); + + if (!token) { + alert("Action impossible : Vous devez être connecté."); + return; + } + + const originalText = btn.innerText; + btn.disabled = true; + + const buttonsToSync = btnData.filter(d => d.hasFile); + const total = buttonsToSync.length; + + try { + let successCount = 0; + + for (const data of buttonsToSync) { + btn.innerText = `⏳ SYNC : ${successCount + 1} / ${total}`; + + const formData = new FormData(); + formData.append('index', data.id); + formData.append('name', data.name); + formData.append('loop', data.loop); + formData.append('volume', data.volume); + + // Récupération du son (DataURL) depuis IndexedDB + const soundData = await getSound(`sound-${data.id}`); + if (soundData) { + // Conversion DataURL -> Blob pour l'envoi PHP + const response = await fetch(soundData); + const blob = await response.blob(); + formData.append('audio', blob, `sound-${data.id}.mp3`); + } + + // Envoi vers le script PHP + const res = await fetch('sync_global.php', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + if (res.ok) successCount++; + } + } catch (err) { + console.error(err); + alert("❌ Erreur lors de la synchronisation."); + } finally { + btn.innerText = originalText; + btn.disabled = false; + } +} + + + +async function exportGlobalBackup() { + console.log("Préparation du backup global..."); + const backupData = { + version: "1.0", + timestamp: new Date().toISOString(), + settings: btnData, // Le contenu du localStorage + sounds: {} // On va remplir ça avec les fichiers IndexedDB + }; + + // On récupère chaque son stocké + for (let i = 0; i < btnData.length; i++) { + if (btnData[i].hasFile) { + const soundData = await getSound(`sound-${i}`); + if (soundData) { + backupData.sounds[`sound-${i}`] = soundData; + } + } + } + + // Création du fichier de téléchargement + const dataStr = JSON.stringify(backupData); + const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); + + const exportFileDefaultName = `soundboard_backup_${new Date().toLocaleDateString()}.json`; + + const linkElement = document.createElement('a'); + linkElement.setAttribute('href', dataUri); + linkElement.setAttribute('download', exportFileDefaultName); + linkElement.click(); +} + +async function importGlobalBackup(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const backup = JSON.parse(e.target.result); + + if (!confirm("Attention : l'importation va écraser votre soundboard actuelle. Continuer ?")) return; + + // 1. Restaurer les réglages (localStorage) + btnData = backup.settings; + localStorage.setItem('sb_studio_data', JSON.stringify(btnData)); + + // 2. Restaurer les sons (IndexedDB) + for (const [id, data] of Object.entries(backup.sounds)) { + await saveSound(id, data); + } + + alert("Restauration complète réussie ! La page va s'actualiser."); + window.location.reload(); + + } catch (err) { + console.error(err); + alert("Erreur lors de l'importation : fichier invalide."); + } + }; + reader.readAsText(file); +} + init(); if ('serviceWorker' in navigator) { @@ -383,4 +577,112 @@ if ('serviceWorker' in navigator) { .then(() => console.log("Service Worker Enregistré")); } -requestWakeLock(); \ No newline at end of file +requestWakeLock(); + + + +/* --- CLOUD & BACKUPS --- */ + + +/** + * Synchronise l'intégralité de la soundboard vers le serveur. + * Envoie chaque bouton individuellement pour correspondre à la structure du cloud. + */ +async function syncAllToCloud() { // On la renomme pour correspondre à l'onclick du bouton + const btn = document.getElementById('syncBtn'); + if (!btn) return; + + const token = localStorage.getItem('auth_token'); + if (!token) { + alert("Action impossible : Connectez-vous via l'IDP."); + return; + } + + const originalText = btn.innerText; + btn.disabled = true; + + // On ne synchronise que les boutons qui ont un fichier + const buttonsToSync = btnData.filter(d => d.hasFile); + const total = buttonsToSync.length; + + try { + let successCount = 0; + + for (const data of buttonsToSync) { + btn.innerText = `⏳ SYNC : ${successCount + 1} / ${total}`; + + const formData = new FormData(); + formData.append('index', data.id); + formData.append('name', data.name); + formData.append('loop', data.loop); + formData.append('volume', data.volume); + + // Récupération du son depuis IndexedDB + const soundData = await getSound(`sound-${data.id}`); + if (soundData) { + // soundData est une DataURL (base64), on la convertit en Blob pour l'envoi + const response = await fetch(soundData); + const blob = await response.blob(); + formData.append('audio', blob, `sound-${data.id}.mp3`); + } + + // ENVOI VERS TON NOUVEAU SCRIPT + const res = await fetch('sync_global.php', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (res.ok) successCount++; + } + alert(`✅ Cloud mis à jour : ${successCount} boutons sauvegardés.`); + } catch (err) { + console.error(err); + alert("❌ Erreur lors de la synchronisation vers le cloud."); + } finally { + btn.innerText = originalText; + btn.disabled = false; + } +} + +/* --- UTILITAIRES --- */ + +/** + * Demande le maintien de l'allumage de l'écran (Wake Lock). + * Gère la réactivation automatique lors du retour sur l'onglet. + */ +async function requestWakeLock() { + if (!('wakeLock' in navigator)) return; + + try { + wakeLock = await navigator.wakeLock.request('screen'); + + // Log pour le debug (optionnel) + console.log("Wake Lock actif : l'écran ne s'éteindra pas."); + + // Si le verrou est libéré (ex: batterie faible), on nettoie la variable + wakeLock.addEventListener('release', () => { + console.log("Wake Lock libéré."); + wakeLock = null; + }); + } catch (err) { + console.error(`Échec du Wake Lock: ${err.message}`); + } +} + +/** + * Désactive le menu contextuel (clic droit / appui long) sur toute la page. + */ +window.addEventListener('contextmenu', (e) => { + e.preventDefault(); +}, false); + +/** + * Ferme la modale d'édition. + */ +const closeEdit = () => { + const overlay = document.getElementById('editOverlay'); + if (overlay) overlay.style.display = 'none'; +}; \ No newline at end of file diff --git a/public/sw.js b/public/sw.js index 4c0bf75..a7c62e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,13 +1,14 @@ -const CACHE_NAME = 'sb-v1.213'; // Change ce numéro pour forcer une mise à jour globale +const CACHE_NAME = 'sb-v1.230'; // Change ce numéro pour forcer une mise à jour globale const ASSETS = [ './', // Racine './index.html', './style.css', './script.js', + './auth.js', './manifest.json', './icon-192.png', './icon-512.png', - '/favicon.ico' + './favicon.ico' ]; // Installation : Mise en cache des ressources critiques diff --git a/public/sync_global.php b/public/sync_global.php new file mode 100644 index 0000000..feead71 --- /dev/null +++ b/public/sync_global.php @@ -0,0 +1,85 @@ + 'error', 'message' => 'Non authentifié'])); +} + +$token = $matches[1]; + +// 2. Décodage du Payload du JWT +$tokenParts = explode('.', $token); +if (count($tokenParts) < 2) { + die(json_encode(['status' => 'error', 'message' => 'Format Token invalide'])); +} + +$payloadJson = base64_decode(str_replace(['-', '_'], ['+', '/'], $tokenParts[1])); +$payload = json_decode($payloadJson, true); + +// Nettoyage du login pour le nom du dossier +$userLogin = preg_replace('/[^a-zA-Z0-9\-\.]/', '', $payload['preferred_username'] ?? $payload['sub'] ?? ''); + +if (empty($userLogin)) { + die(json_encode(['status' => 'error', 'message' => 'Login non trouvé'])); +} + +// 3. Définition du chemin (vérifie que ce dossier est accessible en écriture) +$userDir = $_SERVER['DOCUMENT_ROOT'] . '/../data/' . $userLogin . '/'; +if (!is_dir($userDir)) { + mkdir($userDir, 0755, true); +} + +// 4. RÉCUPÉRATION DE LA CONFIG (Envoyée en JSON via FormData dans script.js) +$configData = isset($_POST['config']) ? json_decode($_POST['config'], true) : null; + +if (!$configData) { + // Fallback si tu envoies les champs un par un au lieu d'un objet config + $index = $_POST['index'] ?? $_POST['id'] ?? null; +} else { + $index = $configData['id'] ?? $configData['index'] ?? null; +} + +if ($index === null) { + die(json_encode(['status' => 'error', 'message' => 'Index/ID manquant'])); +} + +// 5. Sauvegarde Audio +$extension = 'mp3'; // par défaut +if (isset($_FILES['audio'])) { + // On essaie de deviner l'extension si non fournie dans le nom + $originalName = $_FILES['audio']['name']; + $extension = pathinfo($originalName, PATHINFO_EXTENSION); + + // Si le Blob n'a pas d'extension (souvent le cas avec JS), on force selon le mime ou mp3 + if (empty($extension)) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($_FILES['audio']['tmp_name']); + $map = ['audio/wav' => 'wav', 'audio/opus' => 'opus', 'audio/ogg' => 'ogg', 'audio/mpeg' => 'mp3']; + $extension = $map[$mime] ?? 'mp3'; + } + + move_uploaded_file($_FILES['audio']['tmp_name'], $userDir . "sound-$index." . $extension); +} + +// 6. Sauvegarde Config (on fusionne les infos) +$finalConfig = [ + 'id' => (int)$index, + 'name' => $configData['name'] ?? ($_POST['name'] ?? 'Sans titre'), + 'loop' => isset($configData['loop']) ? (bool)$configData['loop'] : ($_POST['loop'] === 'true'), + 'volume' => isset($configData['volume']) ? (float)$configData['volume'] : (float)($_POST['volume'] ?? 1), + 'extension' => $extension, + 'updated_at' => date('Y-m-d H:i:s') +]; + +file_put_contents($userDir . "config-$index.json", json_encode($finalConfig, JSON_PRETTY_PRINT)); + +echo json_encode([ + 'status' => 'success', + 'user' => $userLogin, + 'index' => $index, + 'file' => "sound-$index.$extension" +]); \ No newline at end of file