1
0
forked from jchomaz/Vulture

100 Commits

Author SHA1 Message Date
b4ca539e7b build(Script): Script de lancement projet complet (PS1)
- Utilise Windows Terminal pour ouvrir multiples onglets

- Lance le frontend Vue (npm run dev)

- Lance automatiquement les services backend (score, quizz, buzzer, session...)
2026-02-08 16:49:29 +01:00
a632ca98b0 feat(Editor): Nouveau composant SessionSelector
- Dropdown de sélection des sessions

- Boutons d'actions rapides (Créer, Supprimer, Sauvegarder)

- Interface simplifiée avec Vuetify (arrondis, icônes)
2026-02-08 16:49:29 +01:00
cdb3cdf642 feat(Editor): Refactor liste des questions (SessionQuestionsList)
- Affichage liste avec previews média (Image/Vidéo/Audio)

- Fix génération miniature vidéo via le hack #t=0.1

- Boutons de ré-ordonnancement (Monter/Descendre)

- Intégration du dialogue d'édition QuestionEditorDialog
2026-02-08 16:49:29 +01:00
1ce14eca13 feat(Editor): Refactor et Renommage avancé des médias
- Algorithme de renommage en 2 passes (TMP -> Final) pour éviter les verrous fichiers

- Gestion propre de la suppression des questions et médias associés

- Délégation de l'affichage de la liste à SessionQuestionsList
2026-02-08 16:49:29 +01:00
46bd3f5917 feat(Editor): Nouveau composant QuestionEditorDialog
- Dialogue modal pour l'ajout/édition de questions

- Support complet de l'upload et preview des médias (Image/Vidéo/Audio)

- Gestion des paramètres de question (points, autoplay, loop...)

- Gestion des notes pour le maître du jeu
2026-02-08 16:49:29 +01:00
eae80c0c39 build(deps): Mise à jour package.json et package-lock.json 2026-02-08 16:49:29 +01:00
c0f5b35398 build(deps): Mise à jour package.json et package-lock.json 2026-02-08 16:49:29 +01:00
f5dbd08565 feat(Config): Centralisation topics MQTT et MAJ IPs
- Mise à jour IP Broker MQTT (192.168.1.201)

- Ajout API URL (192.168.1.178)

- Ajout objet 'topics' pour centraliser les routes MQTT (session, media...)
2026-02-08 16:49:29 +01:00
7586095bd5 feat(SessionManager): Gestion avancée des médias (Rename/Delete)
- Implémentation renameMedia avec retry (fix Windows file locking)

- Implémentation deleteMedia via MQTT

- Sécurisation des chemins d'accès (Directory Traversal prevention)
2026-02-08 16:49:29 +01:00
bcaa97e7e2 feat(MQTTPublisher): Mise à jour liste topics WLED
- Correction format topics (suppression slash initial)

- Ajout topics spécifiques couleur (wled/all/col, wled/panel/col)
2026-02-08 16:49:29 +01:00
b1b7080fbe style(QuizzCollector): Amélioration formatage des logs ([INFO]/[ERREUR]) 2026-02-08 16:49:29 +01:00
ce8d859126 feat(Router): Ajout de la route vers l'éditeur de session (/session-editor) 2026-02-08 16:49:29 +01:00
f800262278 feat(Config): Ajout topics MQTT gestion média
- Ajout mqttSessionDeleteMediaTopic (game/session/media/delete)

- Ajout mqttSessionRenameMediaTopic (game/session/media/rename)
2026-02-08 16:49:29 +01:00
2dbb270e17 feat(BuzzerManager): Alignement couleur déblocage avec WLED
- Changement de la couleur de déblocage vers Magenta (#FF00FF)

- Suppression de l'effet rainbow au déblocage pour éviter les conflits visuels
2026-02-08 16:49:29 +01:00
f3fc94cab3 fix(SessionEditor): Nettoyage config avant sauvegarde et traductions
- Suppression des timestamps (cache-busting) dans les MediaUrl avant l'envoi au serveur

- Filtrage des propriétés internes (commençant par _) pour ne pas polluer le JSON

- Traduction des logs console en français
2026-02-08 16:49:29 +01:00
92daf14a09 (update) ScoreDisplay : Feedback visuel lors des buzzer
- Ajout de l'état 'activeTeam' et flashingTeam' basé sur les messages MQT

- Implémentation du grisement (dimming) des équipes inactives

- Ajout d'une lueur blanche clignotante (flash-glow) de 2s sur l'équipe active
2026-02-08 16:49:29 +01:00
c62f76aeec (update) GameDisplay : Feedback visuel lors des buzzer
- Ajout de l'état 'activeTeam' et 'flashingTeam' basé sur les messages MQTT

- Implémentation du grisement (dimming) des équipes inactives

- Ajout d'une lueur blanche clignotante (flash-glow) de 2s sur l'équipe active

- La box-shadow du panneau des scores prend la couleur de l'équipe active
2026-02-08 16:49:29 +01:00
74c0448dfc (update): Formatage mineur (one-line function) 2026-02-08 16:49:29 +01:00
922b7850ea (update) Changement du nom de l'onglet en Vulture 2026-02-08 16:49:29 +01:00
cdf0952ca1 (delete) Suppression du vieux style de quizz 2026-02-08 16:49:29 +01:00
ab102ed1df feat(light): Sync buzzers vers WLED et maj des defaults
- Modification de la couleur par défaut vers Magenta (#FF00FF)

- Ajout de la synchronisation directe : la couleur du buzzer est envoyée immédiatement au bandeau LED

- Désactivation des effets (rainbow, blink...) pour stabiliser l'affichage

- Abonnement au topic vulture/buzzer/pressed/#
2026-02-08 16:49:28 +01:00
36d07f313b script de lancement du kiosque intégré au dépot 2026-02-06 21:35:16 +01:00
013d629625 Utilisation de compose plutot que de script pour builder, lancer et stopper les containers (maj de la doc) 2026-02-06 21:35:16 +01:00
31649435a6 (update) : gestion des sessions et prévisualisation des médias
- Ajout bouton création de session avec dialog

- Ajout bouton suppression de session avec confirmation

- Ajout preview plein écran pour images, vidéos et audio

- Remplacement v-file-input par v-btn stylé pour upload

- Preview vidéo à 1/3 de la durée pour éviter écran noir

- Améliorations de style: dialogs arrondis, champs alignés
2026-02-05 21:53:34 +01:00
0f0f1ffe33 (update) : implémentation création et suppression de sessions)
- Ajout de createSession() pour créer un dossier session avec config par défaut

- Ajout de deleteSession() pour supprimer un dossier session récursivement

- Abonnement aux topics game/session/create et game/session/delete

- Rafraîchissement automatique de la liste après création/suppression
2026-02-05 21:52:26 +01:00
1c2c8dfcbf (update) : ajout des topics MQTT pour création et suppression de session 2026-02-05 21:51:17 +01:00
ed9a939121 (update) Mise à jour de buzzer-watcher.js pour mettre en forme le debug de console 2026-02-03 21:12:56 +01:00
a15d811092 (delete) Suppression de _a retravailler_test-buzzer-manager.js car remplacé par le nouveau service buzzer-manager.js 2026-02-03 21:07:41 +01:00
bab961ace7 (update) Mise à jour de Score-Manager.js pour mettre en forme le debug de console) 2026-02-03 21:05:55 +01:00
07d76a7669 (update) Ajout d'une animation à la mise à jour des scores 2026-02-03 20:54:01 +01:00
3f63801df9 (update) Suppression de l'import des variables (ne sert plus) 2026-02-03 20:48:45 +01:00
66c9e68eb7 (update) Mise à jour de ScoreDisplay pour récupérer les scores si la page est rechargée. 2026-02-03 20:47:08 +01:00
cc9cf987b1 (update) Mise à jour de Score-Manager.js pour ajouter le mecanisme de request des scores (utiles en cas de reload de page). 2026-02-03 20:42:04 +01:00
212e2f350f (update) Mise à jour du configuration.json pour ajouter le topic de request des scores 2026-02-03 20:41:06 +01:00
5379e0ed53 (update) Mise à jour de MqttButton pour ajouter un console.log 2026-02-03 20:39:40 +01:00
6403b8a299 (update) Mise à jour de CardScore pour récupérer les scores si la page est rechargée. 2026-02-03 20:38:49 +01:00
827427ed28 (update) Mise à jour de CardScore pour récupérer les scores si la page est rechargée. 2026-02-03 20:38:37 +01:00
4efe3b00c4 (update) Mise à jour de GameDisplay pour récupérer les scores si la page est rechargée. 2026-02-03 20:37:53 +01:00
a844c21a1b Merge pull request '(update) mise à jour des commentaires en francais' (#4) from update/improve-uix into main
Reviewed-on: jchomaz/Vulture#4
2026-02-03 19:59:19 +01:00
332098a6fd (update) mise à jour des commentaires en francais 2026-02-03 19:59:02 +01:00
f7e2a7a37e Merge pull request 'update/improve-uix' (#3) from update/improve-uix into main
Reviewed-on: jchomaz/Vulture#3
2026-02-03 19:56:32 +01:00
4c1fac7543 Merge pull request 'update/improve-uix' (#2) from update/improve-uix into main
Reviewed-on: jchomaz/Vulture#2
2026-02-03 19:54:50 +01:00
df2c9d4788 (new) track the new quizstore.js for manage the current Vulture Session 2026-02-01 16:20:49 +01:00
2fe8527c37 (update) track the time in the new real timer for the remaining time and new uix placements 2026-02-01 16:19:39 +01:00
5938e269e1 (update) track the time in the new real timer for the remaining time 2026-02-01 16:18:59 +01:00
ff03299645 (update) track the time in the new real timer for the remaining time 2026-02-01 16:18:18 +01:00
5624336173 (new) add the new media manager in the GameDisplay 2026-02-01 16:17:10 +01:00
7aa5ddb4ec (update) add buzzer blocked action in hiding overlay 2026-02-01 16:16:22 +01:00
be8c18710d (update) add info card about the current question for the game master 2026-02-01 16:15:28 +01:00
f4530e8e50 (new) add info card about the current question for the game master 2026-02-01 16:14:20 +01:00
8db6f16ac8 (update) add real timer from the current question 2026-02-01 16:12:21 +01:00
fb3b7fabd4 Modification du thème de la fenêtre de validation de buzzer 2026-02-01 13:54:15 +01:00
0244854ddb patch MQTT 2026-02-01 13:53:36 +01:00
bcec23a751 Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:52:24 +01:00
70fb7cbcea Mise à jour des couleur et du thème 2026-02-01 13:51:57 +01:00
353541541d Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:50:25 +01:00
ee4c2604db Retrait des anciens fichiers de configuration 2026-02-01 13:49:54 +01:00
ad9b29ca93 Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:49:11 +01:00
7413a2a78f Mise à jour du fichier de configuration et patch MQTT 2026-02-01 13:48:53 +01:00
54bbfa00b3 Ajout de la nouvelle page 2026-02-01 13:48:02 +01:00
de8f8f051f Patch des soucis de passage d'une page à une autre 2026-02-01 13:43:09 +01:00
f855601217 update the configuration file to be in unique file 2026-02-01 13:41:09 +01:00
ddbd00ae3f Update components and remove BrainBlastBar.vue -> VultureBar.vue 2026-02-01 13:39:35 +01:00
98b084724e Documentation de déploiement du mode kiosque pour le tableau des score 2026-01-26 21:54:52 +01:00
184a6ac600 config : corrige ip buzzer 2026-01-26 17:32:29 +01:00
9a4a2cb6ad Change le port exposé de l'app 2026-01-26 17:13:08 +01:00
3a458be33d feat: Refactor VApp container to a development build and update its exposed port mapping in the run script. 2026-01-26 17:13:08 +01:00
4e0f34f75c chore: Update buzzer IP addresses in network configuration. 2026-01-26 17:13:08 +01:00
19fe61f077 feat: Introduce Quadlet units for dev/prod VApp containers and a custom network, update existing container configurations, and enhance documentation. 2026-01-26 17:13:07 +01:00
b86909c744 Créer 2 scripts de lancement un pour le dev en local l'autre pour la prod 2026-01-26 17:13:07 +01:00
4e57c70b3c gestion d'une config pour les ip si prod ou dev 2026-01-26 17:13:07 +01:00
da932bdcb3 Lancement en mode network plutot que pod 2026-01-26 17:13:02 +01:00
6026bfb7ff Quadlet (WiP) 2026-01-26 17:09:46 +01:00
28a05c2104 scripts de gestion 2026-01-26 17:09:46 +01:00
b9a2c53032 Correction des options de lancement 2026-01-26 17:09:46 +01:00
c73322a67a Mise à jour des paquets 2026-01-23 18:52:04 +01:00
5900b1faa1 Ajout des boutons de simulation de buzzer et agrandissement de la fenêtre 2026-01-23 18:50:58 +01:00
af58e9c30d Agrandissement de la fenêtre de debug 2026-01-23 18:49:50 +01:00
bc8846d9eb Nouveau player video du game display 2026-01-23 18:48:41 +01:00
5c16468157 Page d'overlay qui masque le player 2026-01-23 18:48:13 +01:00
911671c653 Nouvelle page qui s'affiche en popup sur la page de controle 2026-01-23 18:47:44 +01:00
cd540698a1 Modification de l'équipe Orange en jaune 2026-01-23 18:47:01 +01:00
2a28526cb9 Modification de l'équipe Orange en jaune 2026-01-23 18:45:55 +01:00
6666874913 Modification du controle manuel des score et suppression des 16 bouttons 2026-01-23 18:44:35 +01:00
814c3d0e68 Ajout du popup quand les équipes buzz 2026-01-23 18:43:56 +01:00
911497ab1d Ajout des scores dans les pastilles d'équipes et de le mise à jour en live des scores 2026-01-23 18:42:42 +01:00
905da933dc Modification du score-manager 2026-01-23 18:40:44 +01:00
0186a0a83e Modification du buzzer-manager 2026-01-23 18:40:23 +01:00
da929ecb89 Merge pull request 'container-integration' (#1) from lol/Vulture:container-integration into main
Reviewed-on: jchomaz/Vulture#1
2025-11-24 17:47:13 +01:00
4ab0cca1b2 (conf) VApp : configuration de la bonne adresse du serveur mqtt 2025-11-24 17:40:00 +01:00
5401f416e7 (chore) VNode: ignore les json dans le répertoire des scores 2025-11-16 19:07:45 +01:00
856b90567d (config) VNode : Utilise localhost comme adresse du serveur mqtt
On est sur le même pod.
Voir si besoin de faire une variable d'env ou autre si besoin de
spécifier pour le dev
2025-11-16 19:05:59 +01:00
bb791ed2f4 (feat) VContainers : build VNode 2025-11-16 19:05:59 +01:00
768f42dff4 (feat) VContainers : build VApp 2025-11-16 19:05:59 +01:00
bc807f9c7b (feat) VContainers : Fichier de configuration pour nanomq 2025-11-16 19:05:54 +01:00
8d980a90c8 (doc) VContainers : document création et lancement de container 2025-11-16 19:05:49 +01:00
e8607c78e9 (fix) Vapp : Fuck***g caseless filesystem 2025-11-16 19:05:49 +01:00
1ff31e0991 (fix) VNode: portable path 2025-11-16 19:05:42 +01:00
3e29bfca18 (chore) VApp : Update dependencies 2025-11-16 19:05:41 +01:00
56c6a744c7 (chore) All: Ajoute .gitignore générique pour js/vue/... 2025-11-16 19:05:28 +01:00
85 changed files with 6771 additions and 2611 deletions

142
.gitignore vendored Normal file
View File

@@ -0,0 +1,142 @@
# Score files
/VNode/services/game/score/*.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

33
Start-FullProject.ps1 Normal file
View File

@@ -0,0 +1,33 @@
# Chemins de base
$ServicesPath = "$($PSScriptRoot)\VNode\services"
$VAppPath = "$($PSScriptRoot)\VApp"
# Liste des services
$JsServices = @(
"score-manager.js",
"quizz-collector.js",
"buzzer-manager.js",
"buzzer-watcher.js",
"session-manager.js"
)
# Utilisation de -d (startingDirectory) pour wt.exe
# Cela évite de faire le Set-Location manuellement
$WtArguments = "nt --title VultureGame -d `"$($VAppPath)`" powershell.exe -NoExit -Command `"npm run dev -- --host`""
# Boucle pour ajouter chaque service
foreach ($ServiceName in $JsServices) {
# Recherche du fichier
$ServiceSearch = Get-ChildItem -Path "$($ServicesPath)" -Filter "$($ServiceName)" -Recurse | Select-Object -First 1
if ($ServiceSearch) {
# On définit le répertoire de travail sur le dossier du script trouvé
$Directory = $ServiceSearch.DirectoryName
$NodeCommand = "node $($ServiceSearch.FullName)"
$WtArguments += " `; nt --title $($ServiceName) -d `"$($Directory)`" powershell.exe -NoExit -Command `"$($NodeCommand)`""
}
}
# Lancement final
Start-Process "wt.exe" -ArgumentList $WtArguments

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <meta charset="UTF-8"> <link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Brain Blast</title> <head> <meta charset="UTF-8"> <link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Vulture</title>
<script src="/config.js"></script>
</head> </head>
<body> <div id="app"></div> <script type="module" src="/src/main.js"></script> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script>
</body> </body>

3104
VApp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@videojs-player/vue": "^1.0.0", "@videojs-player/vue": "^1.0.0",
"axios": "^1.13.4",
"express": "^5.0.0", "express": "^5.0.0",
"mqtt": "^5.3.5", "mqtt": "^5.3.5",
"ping": "^0.4.4", "ping": "^0.4.4",
@@ -21,6 +22,7 @@
"video.js": "^8.22.0", "video.js": "^8.22.0",
"vue": "^3.4.19", "vue": "^3.4.19",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"vuetify": "^3.11.8",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {

19
VApp/public/config.js Normal file
View File

@@ -0,0 +1,19 @@
window.APP_CONFIG = {
mqttBrokerUrl: 'ws://192.168.1.201:9001',
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43',
apiUrl: 'http://192.168.1.178:3001',
topics: {
requestList: 'game/session/list/request',
responseList: 'game/session/list/response',
requestConfig: 'game/session/config/request',
getConfig: 'game/session/config/get',
updateConfig: 'game/session/config/update',
createSession: 'game/session/create',
deleteSession: 'game/session/delete',
deleteMedia: 'game/session/media/delete',
renameMedia: 'game/session/media/rename'
}
};

View File

@@ -1,19 +1,28 @@
<template> <template>
<v-app> <v-app>
<BrainBlastBar v-if="$route.name != 'Game Display (Projection)'" /> <VultureBar v-if="showVultureBar" />
<GameStatus v-if="$route.name === 'Game Control (Présentateur)'"> <GameStatus v-if="showGameStatus" />
</GameStatus>
<v-main> <v-main>
<RouterView /> <RouterView :key="$route.fullPath" />
</v-main> <!-- <v-footer class="footer" :elevation=12 border><v-row justify="center">© 2024 - ASCO section Fablab</v-row></v-footer> --> </v-main> <!-- <v-footer class="footer" :elevation=12 border><v-row justify="center">© 2024 - ASCO section Fablab</v-row></v-footer> -->
</v-app> </v-app>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue';
import BrainBlastBar from '@/components/BrainBlastBar.vue' import { useRoute } from 'vue-router';
import VultureBar from '@/components/VultureBar.vue'
import GameStatus from '@/components/GameStatus.vue' import GameStatus from '@/components/GameStatus.vue'
const route = useRoute();
const showVultureBar = computed(() => {
return route.name !== 'Game Display (Projection)' && route.name !== 'Score Display (Projection)';
});
const showGameStatus = computed(() => {
return route.name === 'Game Control (Présentateur)';
});
</script> </script>
<style> <style>

View File

Before

Width:  |  Height:  |  Size: 766 KiB

After

Width:  |  Height:  |  Size: 766 KiB

View File

@@ -1,10 +0,0 @@
<template>
<v-app-bar :collapse="$route.name === 'Game Display (Projection)'" :elevation="5" height="50">
<RouterMenu />
<v-app-bar-title v-if="$route.name !== 'Accueil'">Brain Blast</v-app-bar-title>
</v-app-bar>
</template>
<script setup>
import RouterMenu from '@/components/RouterMenu.vue'
</script>

View File

@@ -0,0 +1,146 @@
<template>
<v-dialog v-model="dialog" persistent max-width="800" height="500" style="background-color: rgba(0, 0, 0, 0.8);">
<v-card dark rounded="xl">
<v-card-title :style="{ backgroundColor: buzzerColor }" class="headline text-center justify-center">
<v-icon color="background" dark large left size="70">mdi-alarm-light</v-icon>
</v-card-title>
<v-card-text :style="{ color: buzzerColor }" class="text-style">
L'équipe {{ buzzerTeam }} a buzzé !
</v-card-text>
<v-card-actions class="justify-center pa-0 ma-0" style="height: 100px; gap: 0;">
<v-btn
class="refuse-btn ma-0"
tile
rounded="0"
height="100%"
width="50%"
@click="refuse">
<v-icon left size="40">mdi-close-circle</v-icon>
<span style="font-size: 20px; padding-left: 10px;">Refuser</span>
</v-btn>
<v-btn
class="validate-btn ma-0"
tile
rounded="0"
height="100%"
width="50%"
:style="{ backgroundColor: buzzerColor }"
@click="validate">
<v-icon left size="40">mdi-check-circle</v-icon>
<span style="font-size: 20px; padding-left: 10px;">Valider (+1)</span>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import mqtt from 'mqtt';
import config from '@/config.js';
import { useTheme } from 'vuetify';
const theme = useTheme();
const dialog = ref(false);
const buzzerTeam = ref('');
const buzzerColor = ref('');
const client = mqtt.connect(config.mqttBrokerUrl);
// Associe les couleurs hex aux noms d'équipe si besoin, ou utilise directement la couleur
function getTeamNameFromColor(color) {
const c = color.toUpperCase();
const colors = theme.current.value.colors;
console.log('Received Color:', c);
console.log('Comparing against:', colors.RedBuzzer.toUpperCase(), colors.BlueBuzzer.toUpperCase(), colors.YellowBuzzer.toUpperCase(), colors.GreenBuzzer.toUpperCase());
if (c === colors.RedBuzzer.toUpperCase()) return 'rouge';
if (c === colors.BlueBuzzer.toUpperCase()) return 'bleue';
if (c === colors.YellowBuzzer.toUpperCase()) return 'jaune';
if (c === colors.GreenBuzzer.toUpperCase()) return 'verte';
return color; // Valeur par défaut
}
function getTeamKeyFromColor(color) {
const c = color.toUpperCase();
const colors = theme.current.value.colors;
if (c === colors.RedBuzzer.toUpperCase()) return 'Red';
if (c === colors.BlueBuzzer.toUpperCase()) return 'Blue';
if (c === colors.YellowBuzzer.toUpperCase()) return 'Yellow';
if (c === colors.GreenBuzzer.toUpperCase()) return 'Green';
return null;
}
onMounted(() => {
client.on('connect', () => {
console.log('BuzzerValidation: Connected');
client.subscribe('vulture/buzzer/status');
});
client.on('message', (topic, message) => {
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message.toString());
if (data.status === 'blocked') {
buzzerColor.value = data.color;
buzzerTeam.value = getTeamNameFromColor(data.color);
dialog.value = true;
} else if (data.status === 'unblocked') {
// Optionnel : fermer automatiquement si débloqué depuis ailleurs
dialog.value = false;
}
} catch (e) {
console.error('Error parsing buzzer status:', e);
}
}
});
});
function unlockBuzzers() {
client.publish('vulture/buzzer/unlock','0');
}
function validate() {
const teamKey = getTeamKeyFromColor(buzzerColor.value);
if (teamKey) {
const payload = { [teamKey]: "+1" };
client.publish('game/score/update', JSON.stringify(payload));
}
// Petit délai avant le déblocage pour que la mise à jour du score soit traitée
setTimeout(() => {
unlockBuzzers();
}, 100);
dialog.value = false;
}
function refuse() {
unlockBuzzers();
dialog.value = false;
}
</script>
<style scoped>
.headline {
font-weight: bold;
}
.text-style {
font-size: 45px!important;
font-weight: 600;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
height: 100%; /* S'assure que l'élément occupe toute la hauteur du conteneur si possible, ou une hauteur substantielle */
}
.validate-btn {
color: rgb(var(--v-theme-background),1);
}
.refuse-btn {
background-color: rgb(var(--v-theme-inactiveButton),1);
color: rgb(var(--v-theme-background),1);
}
</style>

View File

@@ -4,111 +4,166 @@
<v-icon left class="white--text pr-5 pl-2" size="40">mdi-calculator-variant</v-icon> <v-icon left class="white--text pr-5 pl-2" size="40">mdi-calculator-variant</v-icon>
Gestion des scores Gestion des scores
</v-card-title> </v-card-title>
<v-container class="text-center"> <v-container class="text-center pt-8">
<v-row justify="center"> <!-- Team Lines -->
<v-col cols="4" sm="6" md="3"> <v-row v-for="(team, color) in scores" :key="color" align="center" justify="center" class="mb-2">
<mqtt-button width="120" height="60" class="btn red xs12 sm6 md3" topic="game/score/update" message='{"Red": "-2"}'> <!-- Icon/Label -->
<v-icon left size="40">mdi-minus-box-multiple</v-icon> <v-col cols="2" class="d-flex justify-center">
</mqtt-button> <v-icon :color="getTeamColor(color)" size="40">mdi-circle</v-icon>
</v-col> </v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3" topic="game/score/update" message='{"Red": "-1"}'> <!-- Total Score Input -->
<v-icon left size="40">mdi-minus-box</v-icon> <v-col cols="5">
</mqtt-button> <v-text-field
</v-col> v-model.number="team.Total"
<v-col cols="4" sm="6" md="3"> label="Total"
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3" topic="game/score/update" message='{"Red": "+1"}'> type="number"
<v-icon left size="40">mdi-plus-box</v-icon> variant="outlined"
</mqtt-button> density="compact"
</v-col> hide-details
<v-col cols="4" sm="6" md="3"> prepend-inner-icon="mdi-minus"
<mqtt-button width="120" height="60" class="btn red card xs12 sm6 md3 " topic="game/score/update" message='{"Red": "+2"}'> append-inner-icon="mdi-plus"
<v-icon left size="40">mdi-plus-box-multiple</v-icon> :color="getTeamColor(color)"
</mqtt-button> :base-color="getTeamColor(color)"
</v-col> @click:prepend-inner="changeScore(color, 'Total', -1)"
<v-col cols="4" sm="6" md="3"> @click:append-inner="changeScore(color, 'Total', 1)"
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "-2"}'> @update:model-value="updateScore(color)"
<v-icon left size="40">mdi-minus-box-multiple</v-icon> class="centered-input"
</mqtt-button> readonly
</v-col> ></v-text-field>
<v-col cols="12" sm="6" md="3"> </v-col>
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "-1"}'>
<v-icon left size="40">mdi-minus-box</v-icon> <!-- Round Score Input -->
</mqtt-button> <v-col cols="5">
</v-col> <v-text-field
<v-col cols="4" sm="6" md="3"> v-model.number="team.Round"
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "+1"}'> label="Manche"
<v-icon left size="40">mdi-plus-box</v-icon> type="number"
</mqtt-button> variant="outlined"
</v-col> density="compact"
<v-col cols="12" sm="6" md="3"> hide-details
<mqtt-button width="120" height="60" class="btn blue card xs12 sm6 md3 " topic="game/score/update" message='{"Blue": "+2"}'> prepend-inner-icon="mdi-minus"
<v-icon left size="40">mdi-plus-box-multiple</v-icon> append-inner-icon="mdi-plus"
</mqtt-button> :color="getTeamColor(color)"
</v-col> :base-color="getTeamColor(color)"
<v-col cols="4" sm="6" md="3"> @click:prepend-inner="changeScore(color, 'Round', -1)"
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "-2"}'> @click:append-inner="changeScore(color, 'Round', 1)"
<v-icon left size="40">mdi-minus-box-multiple</v-icon> @update:model-value="updateScore(color)"
</mqtt-button> class="centered-input"
</v-col> readonly
<v-col cols="12" sm="6" md="3"> ></v-text-field>
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "-1"}'> </v-col>
<v-icon left size="40">mdi-minus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "+1"}'>
<v-icon left size="40">mdi-plus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn yellow card xs12 sm6 md3 " topic="game/score/update" message='{"Yellow": "+2"}'>
<v-icon left size="40">mdi-plus-box-multiple</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "-2"}'>
<v-icon left size="40">mdi-minus-box-multiple</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "-1"}'>
<v-icon left size="40">mdi-minus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="4" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "+1"}'>
<v-icon left size="40">mdi-plus-box</v-icon>
</mqtt-button>
</v-col>
<v-col cols="12" sm="6" md="3">
<mqtt-button width="120" height="60" class="btn green card xs12 sm6 md3 " topic="game/score/update" message='{"Green": "2"}'>
<v-icon left size="40">mdi-plus-box-multiple</v-icon>
</mqtt-button>
</v-col>
</v-row> </v-row>
</v-container> </v-container>
</v-card> </v-card>
</template> </template>
<script setup> <script setup>
import MqttButton from './MqttButton.vue'; import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { ref } from 'vue'; import mqtt from 'mqtt';
import config from '@/config.js'; // Ensure correct path
// Variable pour contrôler l'état de la carte
const isCardReduced = ref(false); const isCardReduced = ref(false);
// Méthode pour basculer l'état de la carte const scores = reactive({
Red: { Total: 0, Round: 0 },
Blue: { Total: 0, Round: 0 },
Yellow: { Total: 0, Round: 0 },
Green: { Total: 0, Round: 0 },
});
// const client = mqtt.connect(config.mqttBrokerUrl);
let client = null;
onMounted(() => {
client = mqtt.connect(config.mqttBrokerUrl);
client.on('connect', () => {
console.log('CardButtonScore: Connected to MQTT broker at', config.mqttBrokerUrl);
client.subscribe('game/score');
console.log("CardButtonScore: Requesting scores.");
client.publish('game/score/request', '{}');
});
client.on('error', (err) => {
console.error('CardButtonScore: MQTT Error:', err);
});
client.on('message', (topic, message) => {
if (topic === 'game/score') {
try {
const data = JSON.parse(message.toString());
console.log('CardButtonScore: Received score update:', data);
if (data && data.TEAM) {
Object.keys(scores).forEach(color => {
if (data.TEAM[color]) {
scores[color].Total = data.TEAM[color].TotalScore;
scores[color].Round = data.TEAM[color].RoundScore;
}
});
}
} catch (e) {
console.error("Error parsing score update:", e);
}
}
});
});
onUnmounted(() => {
if (client) {
client.end();
}
});
function toggleCardSize() { function toggleCardSize() {
isCardReduced.value = !isCardReduced.value; isCardReduced.value = !isCardReduced.value;
} }
function getTeamColor(color) {
if (color === 'Yellow') return '#D4D100'; // Custom yellow
if (color === 'Red') return '#d42828';
if (color === 'Blue') return '#2867d4';
if (color === 'Green') return '#28d42e';
return color.toLowerCase();
}
function changeScore(teamColor, field, delta) {
scores[teamColor][field] += delta;
updateScore(teamColor);
}
function updateScore(teamColor) {
const payload = {
[teamColor]: {
Total: scores[teamColor].Total,
Round: scores[teamColor].Round
}
};
console.log('CardButtonScore: Publishing update:', payload);
client.publish('game/score/update', JSON.stringify(payload));
}
</script> </script>
<style> <style>
.card--reduced { .card--reduced {
height: 56px; /* Réglez la hauteur réduite selon vos besoins */ height: 56px;
width: 170px; width: 60%; /* Adjusted width for layout */
overflow: hidden; overflow: hidden;
transition: height 0.3s ease-in-out; transition: height 0.3s ease-in-out;
} }
.centered-input input {
text-align: center;
}
.centered-input .v-field__label {
text-align: center;
}
/* Remove number spin buttons */
.centered-input input[type=number]::-webkit-inner-spin-button,
.centered-input input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style> </style>

View File

@@ -26,7 +26,7 @@
<v-icon left size="60">mdi-play</v-icon> <v-icon left size="60">mdi-play</v-icon>
</mqtt-button> </mqtt-button>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
</v-card> </v-card>
</template> </template>
@@ -39,8 +39,7 @@
const isCardReduced = ref(false); const isCardReduced = ref(false);
// Méthode pour basculer l'état de la carte // Méthode pour basculer l'état de la carte
function toggleCardSize() { isCardReduced.value = !isCardReduced.value; function toggleCardSize() { isCardReduced.value = !isCardReduced.value; }
}
</script> </script>
<style> <style>

View File

@@ -28,7 +28,12 @@ const quizzList = ref([]);
// Fonction pour mettre à jour la liste // Fonction pour mettre à jour la liste
const handleMessage = (topic, message) => { const handleMessage = (topic, message) => {
try { try {
quizzList.value = JSON.parse(message.toString()); const parsed = JSON.parse(message.toString());
if (Array.isArray(parsed)) {
quizzList.value = parsed;
} else {
console.warn('CardCurrentQuizz: Received non-array data', parsed);
}
} catch (error) { } catch (error) {
console.error('Erreur de parsing JSON:', error); console.error('Erreur de parsing JSON:', error);
} }

View File

@@ -53,14 +53,14 @@
<div> <div>
<v-label class="labelRoundScore-style pt-3">Manche</v-label> <v-label class="labelRoundScore-style pt-3">Manche</v-label>
<div> <div>
<v-label class="labelRoundScore-style">{{ scores.OrangeRoundScore }}</v-label> <v-label class="labelRoundScore-style">{{ scores.YellowRoundScore }}</v-label>
</div> </div>
</div> </div>
<v-divider color="background"/> <v-divider color="background"/>
<div> <div>
<v-label class="labelTotalScore-style pt-3">Total</v-label> <v-label class="labelTotalScore-style pt-3">Total</v-label>
<div> <div>
<v-label class="labelTotalScore-style pb-3">{{ scores.OrangeTotalScore }}</v-label> <v-label class="labelTotalScore-style pb-3">{{ scores.YellowTotalScore }}</v-label>
</div> </div>
</div> </div>
</v-col> </v-col>
@@ -91,7 +91,6 @@
<script setup> <script setup>
import { onMounted, ref, reactive } from 'vue'; import { onMounted, ref, reactive } from 'vue';
import variables from '@/variables.js';
import mqtt from 'mqtt' import mqtt from 'mqtt'
import config from '@/config.js' import config from '@/config.js'
@@ -103,11 +102,11 @@ const client = mqtt.connect(mqttBrokerUrl)
const scores = reactive({ const scores = reactive({
RedTotalScore: 0, // Propriétés réactives RedTotalScore: 0, // Propriétés réactives
BlueTotalScore: 0, // Propriétés réactives BlueTotalScore: 0, // Propriétés réactives
OrangeTotalScore: 0, // Propriétés réactives YellowTotalScore: 0, // Propriétés réactives
GreenTotalScore: 0, // Propriétés réactives GreenTotalScore: 0, // Propriétés réactives
RedRoundScore: 0, // Propriétés réactives RedRoundScore: 0, // Propriétés réactives
BlueRoundScore: 0, // Propriétés réactives BlueRoundScore: 0, // Propriétés réactives
OrangeRoundScore: 0, // Propriétés réactives YellowRoundScore: 0, // Propriétés réactives
GreenRoundScore: 0, // Propriétés réactives GreenRoundScore: 0, // Propriétés réactives
}); });
// Fonction pour traiter chaque message reçu et réinitialiser le timeout // Fonction pour traiter chaque message reçu et réinitialiser le timeout
@@ -131,24 +130,6 @@ function handleMessage(topic, message) {
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
// Mettre à jour l'état des buzzers en fonction des messages
/*
switch (buzzer) {
case 'redBuzzerIP':
redBuzzerState.value = status === "online" ? 1 : 0;
break;
case 'blueBuzzerIP':
blueBuzzerState.value = status === "online" ? 1 : 0;
break;
case 'yellowBuzzerIP':
yellowBuzzerState.value = status === "online" ? 1 : 0;
break;
case 'greenBuzzerIP':
greenBuzzerState.value = status === "online" ? 1 : 0;
break;
}
*/
} }
@@ -162,7 +143,9 @@ onMounted(() => {
subscribeToTopic('game/score', (topic, message) => { subscribeToTopic('game/score', (topic, message) => {
handleMessage(topic, message); handleMessage(topic, message);
}); });
// Request score refresh
client.publish('game/score/request', '{}');
}); });
</script> </script>

View File

@@ -3,14 +3,36 @@
<v-card-title class="card__title primary" @click="toggleCardSize"> <v-card-title class="card__title primary" @click="toggleCardSize">
<v-icon left class="white--text pr-5 pl-2" size="40">mdi-play-network-outline</v-icon> <v-icon left class="white--text pr-5 pl-2" size="40">mdi-play-network-outline</v-icon>
Solution </v-card-title> Solution </v-card-title>
<v-container class="text-center"> <v-container class="text-center" v-if="currentQuestion">
<v-row justify="center"> <div class="text-h6 mb-2">Question {{ currentQuestionIndex + 1 }}</div>
<v-container class="text-center"> <!-- Utilisation de styles CSS personnalisés pour centrer l'image --> <div class="text-body-1 font-weight-bold mb-4">{{ currentQuestion.QuestionText }}</div>
<v-img width="450" src="@/assets/copilot-solution-FULL-HD.jpg" style="margin: 0 auto;">
</v-img> <v-divider class="mb-4"></v-divider>
</v-container>
</v-row> <v-btn
:color="showSolution ? 'red' : 'green'"
class="mb-4"
@click="showSolution = !showSolution"
>
{{ showSolution ? 'Masquer Solution' : 'Voir Solution' }}
</v-btn>
<v-slide-y-transition>
<div v-if="showSolution" class="solution-block">
<div class="text-h5 success--text mb-2">{{ currentQuestion.MasterData.CorrectAnswer }}</div>
<div class="text-body-2 grey--text text--lighten-1 mb-2">
<v-icon small>mdi-information</v-icon> {{ currentQuestion.MasterData.MasterNotes }}
</div>
<div class="text-body-2 info--text">
<v-icon small>mdi-help-circle</v-icon> {{ currentQuestion.MasterData.Help }}
</div>
</div>
</v-slide-y-transition>
</v-container> </v-container>
<v-container v-else class="text-center">
<div class="text-caption">Aucun quiz chargé ou fin du quiz.</div>
</v-container>
</v-card> </v-card>
</template> </template>
<style> <style>
@@ -34,13 +56,18 @@
</style> </style>
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import quizStore from '@/store/quizStore';
// Variable pour contrôler l'état de la carte // Variable pour contrôler l'état de la carte
const isCardReduced = ref(false); const isCardReduced = ref(false);
const showSolution = ref(false);
// Méthode pour basculer l'état de la carte // Méthode pour basculer l'état de la carte
function toggleCardSize() { function toggleCardSize() {
isCardReduced.value = !isCardReduced.value; isCardReduced.value = !isCardReduced.value;
} }
const currentQuestion = quizStore.getters.currentQuestion;
const currentQuestionIndex = quizStore.getters.currentQuestionIndex;
</script> </script>

View File

@@ -1,85 +1,39 @@
<template> <template>
<div class="container"> <div class="container">
<div class="timer"> <div class="timer">
<v-label color="primary" class="labelTime-style" >{{ formatTime }}</v-label> <v-label class="labelTime-style" >{{ formatTime }}</v-label>
</div> </div>
<v-row no-gutters justify="space-around" >
<v-btn class="buttons" color="primary" icon="mdi-play" @click="startTimer"></v-btn>
<v-btn color="primary" icon="mdi-pause" @click="pauseTimer"></v-btn>
<v-btn color="primary" icon="mdi-restart" @click="resetTimer"></v-btn>
</v-row>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onBeforeUnmount } from 'vue'; import { computed, watch } from 'vue';
import quizStore from '@/store/quizStore';
const timerActive = ref(false); const timer = quizStore.getters.timer;
const startTime = ref(null);
const currentTime = ref(null); watch(timer, (val) => {
const elapsedTime = ref(0); console.log('CardTimer: timer value changed', val);
}, { immediate: true });
const formatTime = computed(() => { const formatTime = computed(() => {
let seconds = Math.floor(elapsedTime.value / 1000); const seconds = timer.value % 60;
let minutes = Math.floor(seconds / 60); const minutes = Math.floor(timer.value / 60);
let hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
seconds = seconds % 60; minutes = minutes % 60; return `${pad(minutes % 60)}:${pad(seconds)}`;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; });
});
const pad = (number) => { const pad = (number) => {
return (number < 10 ? "0" : "") + number; return (number < 10 ? "0" : "") + number;
}; };
const startTimer = () => {
if (!timerActive.value) {
timerActive.value = true;
startTime.value = Date.now() - elapsedTime.value;
updateTimer(); }
};
const pauseTimer = () => {
if (timerActive.value) {
timerActive.value = false;
clearInterval(currentTime.value); }
};
const resetTimer = () => {
elapsedTime.value = 0;
timerActive.value = false;
clearInterval(currentTime.value);
};
const updateTimer = () => {
currentTime.value = setInterval(() => {elapsedTime.value = Date.now() - startTime.value; }, 1000);
};
onBeforeUnmount(() => { clearInterval(currentTime.value);
});
</script>
<script>
const startTimer = () => {
if (!timerActive.value) {
timerActive.value = true;
startTime.value = Date.now() - elapsedTime.value;
updateTimer(); } };
const pauseTimer = () => {
if (timerActive.value) {
timerActive.value = false;
clearInterval(currentTime.value); } };
const resetTimer = () => {
elapsedTime.value = 0;
timerActive.value = false;
clearInterval(currentTime.value); };
export { startTimer, pauseTimer, resetTimer };
</script> </script>
<style> <style>
.container { .container {
text-align: center; text-align: center;
margin-top: auto; /* Place le container en bas de son parent */ margin-top: auto;
margin-bottom: 1px; /* Marge en bas pour un espacement */ margin-bottom: 1px;
position: fixed; /* Le positionne de manière fixe */ position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@@ -89,12 +43,9 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
.labelTime-style { .labelTime-style {
font-size: 30px !important; font-size: 40px !important;
font-weight: 500; font-weight: 500;
color: #d42828 !important; color: #d42828 !important;
opacity: 90% !important; opacity: 90% !important;
} }
.buttons{
background-color: rgb(255, 255, 255);
}
</style> </style>

View File

@@ -0,0 +1,265 @@
<template>
<v-container v-show="gamehiding === false" class="player_video_div">
<div v-if="currentQuestion" style="width: 100%; height: 100%;">
<!-- LECTEUR VIDÉO -->
<div v-show="currentQuestion.Type === 'video'" style="width: 100%; height: 100%;">
<video ref="videoPlayer" class="video-js player_video" controls preload="auto">
</video>
</div>
<!-- AFFICHAGE IMAGE -->
<div v-if="currentQuestion.Type === 'picture'" style="width: 100%; height: 100%;">
<v-img
:src="getMediaUrl(currentQuestion.MediaUrl)"
:key="currentQuestion.QuestionId"
class="player_video"
></v-img>
</div>
<!-- LECTEUR AUDIO -->
<div v-if="currentQuestion.Type === 'audio'" class="audio-container player_video">
<div class="audio-visualizer">
<v-icon size="150" color="white" class="mb-4">mdi-music-circle</v-icon>
<span class="text-h2 white--text">ÉCOUTEZ</span>
</div>
<audio ref="audioPlayer" :src="getMediaUrl(currentQuestion.MediaUrl)" class="custom-audio"></audio>
</div>
</div>
</v-container>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import quizStore from '@/store/quizStore';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import { subscribeToTopic, publishMessage } from '@/services/mqttService';
// Accès au store
const currentQuestion = quizStore.getters.currentQuestion;
// Références du lecteur vidéo
const videoPlayer = ref(null);
let vjsPlayer = null;
// Références du lecteur audio
const audioPlayer = ref(null);
let gamehiding = ref(true);
// Méthodes
function getMediaUrl(relativePath) {
if (!relativePath) return '';
const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
const url = new URL(`../quizz/vulture-session-2026-01/${cleanPath}`, import.meta.url).href;
console.log('GameMedia: Resolved URL:', { relativePath, url });
return url;
}
function initVideoPlayer() {
if (vjsPlayer) return; // Déjà initialisé
if (!videoPlayer.value) return; // DOM pas prêt
console.log('GameMedia: Initializing VideoJS');
vjsPlayer = videojs(videoPlayer.value, {
autoplay: false,
controls: false,
preload: 'auto',
fluid: true,
loop: false,
volume: 0,
}, () => {
console.log('GameMedia: VideoJS Ready');
// Si la question courante est une vidéo, la charger
if (currentQuestion.value && currentQuestion.value.Type === 'video') {
updateVideoSource();
}
// Masquer automatiquement à la fin de la vidéo
vjsPlayer.on('ended', () => {
console.log('GameMedia: Video ended, hiding');
gamehiding.value = true;
publishMessage('/display/control', 'hide');
});
});
}
function updateVideoSource() {
if (!vjsPlayer || !currentQuestion.value) return;
const url = getMediaUrl(currentQuestion.value.MediaUrl);
console.log('GameMedia: Loading Video Source', url);
vjsPlayer.src({ type: 'video/mp4', src: url });
// L'autoplay est géré par la commande MQTT 'play' maintenant
// if (currentQuestion.value.Settings?.AutoPlay) {
// vjsPlayer.play().catch(e => console.log('Autoplay blocked', e));
// }
}
// Observateurs
watch(currentQuestion, async (newVal, oldVal) => {
console.log('GameMedia: Question Changed', newVal);
// Arrêter d'abord tous les médias
if (vjsPlayer) {
vjsPlayer.pause();
}
if (audioPlayer.value) {
audioPlayer.value.pause();
}
// Rester masqué au changement de question jusqu'à la lecture
gamehiding.value = true;
publishMessage('/display/control', 'hide');
if (!newVal) return;
await nextTick(); // Attendre la mise à jour du DOM (v-if)
if (newVal.Type === 'video') {
if (!vjsPlayer) {
initVideoPlayer();
} else {
updateVideoSource();
}
} else if (newVal.Type === 'audio') {
// Chargement audio (pas d'autoplay)
setTimeout(() => {
if(audioPlayer.value){
console.log('GameMedia: Loading Audio');
audioPlayer.value.load();
// Masquer automatiquement à la fin de l'audio
audioPlayer.value.onended = () => {
console.log('GameMedia: Audio ended, hiding');
gamehiding.value = true;
publishMessage('/display/control', 'hide');
};
}
}, 100);
}
// Pour le type 'picture', rien à faire, vidéo/audio déjà en pause
}, { immediate: true });
// Cycle de vie
onMounted(async () => {
await nextTick();
if (currentQuestion.value?.Type === 'video') {
initVideoPlayer();
}
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
});
});
const handleMessage = (topic, message) => {
console.log('GameMedia: Received', topic, message);
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
console.log("▶️ GameMedia: Play");
// Only play the media relevant to this question type
if (currentQuestion.value?.Type === 'video' && vjsPlayer) {
vjsPlayer.play().catch(e => console.error("Error playing video:", e));
}
if (currentQuestion.value?.Type === 'audio' && audioPlayer.value) {
audioPlayer.value.play().catch(e => console.error("Error playing audio:", e));
}
if (currentQuestion.value?.Type === 'picture') {
// Démarrer le timer si PlayTime est configuré
const playTime = currentQuestion.value.Settings?.PlayTime;
if (playTime && playTime > 0) {
quizStore.actions.startTimer(playTime);
}
}
break;
case "pause":
gamehiding.value = true;
console.log("⏸️ GameMedia: Pause");
quizStore.actions.stopTimer();
if (vjsPlayer) {
vjsPlayer.pause();
}
if (audioPlayer.value) {
audioPlayer.value.pause();
}
break;
case "hide":
console.log("🛑 GameMedia: Hide");
gamehiding.value = true;
quizStore.actions.stopTimer();
break;
}
}
// Vérifier le statut du buzzer pour masquer automatiquement au buzz (comme HidingOverlay)
// Réplication de VideoPlayer qui n'avait que /display/control dans l'extrait fourni.
// Comportement optionnel selon les événements du système.
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
console.log("GameMedia: Buzzer Blocked -> Hiding");
gamehiding.value = true;
if (vjsPlayer) vjsPlayer.pause();
if (audioPlayer.value) audioPlayer.value.pause();
}
} catch (e) { console.error('JSON Error', e); }
}
};
onBeforeUnmount(() => {
if (vjsPlayer) {
vjsPlayer.dispose();
}
});
</script>
<style>
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
}
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
}
.vjs-tech{
border-radius: 25px;
}
/* Styles additionnels pour les éléments Audio/Custom pour s'adapter au thème */
.audio-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(45deg, #1a1a1a, #2c3e50);
}
.audio-visualizer {
display: flex;
flex-direction: column;
align-items: center;
animation: pulse 2s infinite;
}
.text-h2 {
font-family: 'Bahnschrift', sans-serif !important;
}
.custom-audio {
margin-top: 30px;
width: 80%;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
100% { transform: scale(1); opacity: 0.8; }
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<v-container v-show="gamehiding === true" class="v-container-game-hided">
<v-img src="@\assets\v-hide.png" class="v-img-hidding"/>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { subscribeToTopic } from '@/services/mqttService';
let gamehiding = ref(true);
const handleMessage = (topic, message) => {
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
break;
case "pause":
gamehiding.value = true;
break;
case "hide":
gamehiding.value = true;
break;
default:
console.warn("Commande non reconnue :", message);
gamehiding.value = true;
}
}
};
// --- Lifecycle
onMounted(() => {
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
if (topic === 'vulture/buzzer/status') {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
gamehiding.value = true;
}
} catch (e) {
console.error('HidingOverlay JSON error', e);
}
}
});
});
</script>
<style>
.v-img-hidding{
border-radius: 25px;
}
.v-container-game-hided{
margin-top: 40px;
width: calc(100vw - 20%) !important;
height: calc(100vh - 20%) !important;
border-radius: 25px;
}
</style>

View File

@@ -53,12 +53,12 @@ import { publishMessage } from '@/services/mqttService';
const selectedTopic = ref('Selectionnez un topic'); const selectedTopic = ref('Selectionnez un topic');
const selectedColor = ref('#FF0000'); const selectedColor = ref('#FF0000');
const topics = ref([ const topics = ref([
'/wled/all', 'wled/all/col',
'/wled/1', 'wled/panel/col',
'/wled/2', 'wled/2',
'/wled/3', 'wled/3',
'/wled/4', 'wled/4',
'/wled/5', 'wled/5',
]); ]);
const publishCustomColor = () => { const publishCustomColor = () => {

View File

@@ -1,6 +1,6 @@
<template> <template>
<v-container class="v-container-style-console"> <v-container class="v-container-style-console">
<v-card tile outlined width="500"> <v-card tile outlined width="900">
<v-card-title class="card__title primary centered-title"> <v-card-title class="card__title primary centered-title">
<v-icon left class="pr-5 pl-2" size="40">mdi-console-line</v-icon> <v-icon left class="pr-5 pl-2" size="40">mdi-console-line</v-icon>
Console MQTT Console MQTT
@@ -27,7 +27,7 @@
</div> </div>
</div> </div>
<v-container class="text-center"> <v-container>
<div v-for="(log, index) in filteredLogs" :key="index"> <div v-for="(log, index) in filteredLogs" :key="index">
<v-label class="v-label-timestamp">{{ log.timestamp }} -&nbsp;</v-label> <v-label class="v-label-timestamp">{{ log.timestamp }} -&nbsp;</v-label>
<v-label class="v-label-topic-message-title">Topic :&nbsp;</v-label> <v-label class="v-label-topic-message-title">Topic :&nbsp;</v-label>

View File

@@ -1,18 +1,18 @@
<template> <template>
<v-container class="v-container-style"> <v-container class="v-container-style">
<v-card tile outlined width="500"> <v-card tile outlined width="600">
<v-card-title class="card__title primary centered-title"> <v-card-title class="card__title primary centered-title">
<v-icon left class="pr-5 pl-2" size="30">mdi-send</v-icon> <v-icon left class="pr-5 pl-2" size="30">mdi-send</v-icon>
Publier un message Publier un message
</v-card-title> </v-card-title>
<div class="input-style"> <div class="input-style">
<v-select <v-text-field
label="Topic" label="Topic"
v-model="selectedTopic" v-model="selectedTopic"
:items="topics" :items="topics"
prepend-icon="mdi-target" prepend-icon="mdi-target"
></v-select> ></v-text-field>
<v-text-field <v-text-field
label="Message" label="Message"
@@ -30,6 +30,42 @@
Déblocage<br>Buzzer Déblocage<br>Buzzer
</v-btn> </v-btn>
<v-btn
rounded
color="RedBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#d42828')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="BlueBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#2867d4')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="YellowBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#D4D100')"
>
Buzzer
</v-btn>
<v-btn
rounded
color="GreenBuzzer"
class="v-btn-style-standalone"
height="40"
@click="publishBuzzer('#28d42e')"
>
Buzzer
</v-btn>
<v-btn <v-btn
class="v-btn-style-validate" class="v-btn-style-validate"
height="50" height="50"
@@ -53,7 +89,8 @@ const topics = [
'display/control', 'display/control',
'sound/playsound', 'sound/playsound',
'game/score/update', 'game/score/update',
'game/score' 'game/score',
'/display/media'
]; ];
// Methods // Methods
@@ -62,7 +99,18 @@ const publisCustomMessage = () => {
}; };
const publishBuzzerUnblock = () => { const publishBuzzerUnblock = () => {
publishMessage('brainblast/buzzer/unlock', "0"); publishMessage('vulture/buzzer/unlock', "0");
};
const publishBuzzer = (inputColor) => {
publishMessage('vulture/buzzer/pressed/2',JSON.stringify({
buzzer_id: 1,
color: inputColor
}));
// Add a small delay before unlocking to ensure the score update is processed
setTimeout(() => {
publishMessage('/display/control', "pause");
}, 100);
}; };
</script> </script>

View File

@@ -16,6 +16,7 @@
const disabled = ref(false) const disabled = ref(false)
const _publishMessage = () => { const _publishMessage = () => {
console.log('MqttButton: Publishing', props.topic, props.message)
publishMessage(props.topic, props.message) publishMessage(props.topic, props.message)
disabled.value = true disabled.value = true
} }

View File

@@ -0,0 +1,352 @@
<template>
<v-dialog v-model="visible" persistent max-width="800px">
<v-card rounded="xl" class="pa-3">
<v-card-title class="text-title-style">
{{ isNew ? 'Nouvelle question' : 'Éditer la question' }}
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12" md="4">
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.QuestionId" label="ID Question" hint="Unique ID, ex: Q-005"></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.Type" :items="['video', 'audio', 'picture']" label="Type"></v-select>
</v-col>
<v-col cols="12" md="4">
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model.number="editedItem.Points" type="number" label="Points"></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.QuestionText" label="Texte de la question" rows="2"></v-textarea>
</v-col>
<!-- Media Section -->
<v-col cols="12">
<div class="d-flex align-center">
<!-- Preview Section -->
<div v-if="editedItem.MediaUrl" class="preview-box mr-4 text-center">
<img v-if="editedItem.Type === 'picture'" :src="getPreviewUrl(editedItem.MediaUrl)" class="preview-content" @click="showFullPreview = true">
<video
v-else-if="editedItem.Type === 'video'"
:src="getPreviewUrl(editedItem.MediaUrl)"
class="preview-content"
@click="showVideoPreview = true"
@loadedmetadata="(e) => e.target.currentTime = e.target.duration / 3"
muted
></video>
<div v-else-if="editedItem.Type === 'audio'" class="text-caption" style="cursor: pointer;" @click="showAudioPreview = true">
<v-icon size="64" color="primary">mdi-music</v-icon>
</div>
</div>
<v-text-field color="primary" density="compact" variant="outlined" rounded="xl" v-model="editedItem.MediaUrl" label="URL du Média" class="flex-grow-2 mr-2" hide-details></v-text-field>
<input
type="file"
ref="fileInputRef"
color="primary"
style="display: none;"
@change="onFileSelected"
accept="image/*,video/*,audio/*"
/>
<v-btn
rounded="xl"
variant="tonal"
color="primary"
:loading="uploading"
:disabled="!editedItem.Type"
@click="$refs.fileInputRef.click()"
>
<v-icon start>mdi-upload</v-icon>
Upload
</v-btn>
<v-btn
v-if="editedItem.MediaUrl"
rounded="xl"
text="Supprimer"
variant="tonal"
color="red"
class="ml-2"
@click="deleteMedia"
title="Supprimer le média"
></v-btn>
</div>
<div v-if="uploadError" class="text-caption text-red">{{ uploadError }}</div>
</v-col>
<v-col cols="12"><v-divider></v-divider></v-col>
<v-col cols="12" class="text-h6">Paramètres</v-col>
<v-col cols="6" md="4">
<v-switch inset v-model="editedItem.Settings.AutoPlay" label="AutoPlay" density="compact" color="primary"></v-switch>
</v-col>
<v-col cols="6" md="4">
<v-switch inset v-model="editedItem.Settings.Loop" label="Loop" density="compact" color="primary"></v-switch>
</v-col>
<v-col cols="6" md="4">
<v-text-field color="primary" variant="outlined" rounded="xl" v-model.number="editedItem.Settings.PlayTime" type="number" label="Durée (sec)" density="compact"></v-text-field>
</v-col>
<v-col cols="12"><v-divider></v-divider></v-col>
<v-label class="text-title-style">
Réponse (Maître du jeu)
</v-label>
<v-col cols="12" class="text-h6"></v-col>
<v-col cols="12">
<v-text-field color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.CorrectAnswer" label="Réponse Correcte"></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.MasterNotes" label="Notes pour le MJ" rows="2"></v-textarea>
</v-col>
<v-col cols="12">
<v-textarea color="primary" variant="outlined" rounded="xl" v-model="editedItem.MasterData.Help" label="Indice / Aide" rows="2"></v-textarea>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" rounded="xl" variant="outlined" @click="close">Annuler</v-btn>
<v-btn color="success" rounded="xl" variant="outlined" @click="save">Ok</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Preview Image -->
<v-dialog v-model="showFullPreview" max-width="90vw">
<v-card rounded="xl" class="pa-2">
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<v-img
:src="getPreviewUrl(editedItem.MediaUrl)"
max-height="80vh"
rounded="lg"
@click="showFullPreview = false"
style="cursor: pointer;"
></v-img>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showFullPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Preview Video -->
<v-dialog v-model="showVideoPreview" max-width="90vw">
<v-card rounded="xl" class="pa-2">
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<video
:src="getPreviewUrl(editedItem.MediaUrl)"
controls
autoplay
disablepictureinpicture
disableremoteplayback
controlslist="nodownload"
style="max-width: 100%; max-height: 80vh; border-radius: 12px;"
></video>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showVideoPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Preview Audio -->
<v-dialog v-model="showAudioPreview" max-width="500px">
<v-card rounded="xl" class="pa-4">
<v-card-title class="text-center">
<v-icon size="64" color="primary">mdi-music-circle</v-icon>
</v-card-title>
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<audio
:src="getPreviewUrl(editedItem.MediaUrl)"
controls
controlslist="nodownload"
disablepictureinpicture
disableremoteplayback
autoplay
style="width: 100%;"
></audio>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showAudioPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue';
import axios from 'axios';
const props = defineProps({
modelValue: Boolean,
question: {
type: Object,
default: null
},
sessionId: {
type: String,
required: true
},
apiUrl: {
type: String,
required: true
},
nextIndex: {
type: Number,
default: 1
}
});
const emit = defineEmits(['update:modelValue', 'save', 'delete-media']);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const defaultQuestion = {
QuestionId: '',
Type: '',
Points: 1,
QuestionText: '',
MediaUrl: '',
Settings: {
StartAt: null,
StopAt: null,
AutoPlay: false,
Loop: false,
PlayTime: null,
DisplayMode: 'Cover',
BlurEffect: false
},
MasterData: {
CorrectAnswer: '',
MasterNotes: '',
Help: ''
}
};
const editedItem = reactive(JSON.parse(JSON.stringify(defaultQuestion)));
// Copy question data when opening
watch(() => props.modelValue, (val) => {
if (val) {
if (props.question) {
Object.assign(editedItem, JSON.parse(JSON.stringify(props.question)));
// Ensure nested objects exist
if(!editedItem.Settings) editedItem.Settings = {...defaultQuestion.Settings};
if(!editedItem.MasterData) editedItem.MasterData = {...defaultQuestion.MasterData};
} else {
Object.assign(editedItem, JSON.parse(JSON.stringify(defaultQuestion)));
// Auto-generate ID: Q-005 for 5th question
const idNumber = props.nextIndex.toString().padStart(3, '0');
editedItem.QuestionId = `Q-${idNumber}`;
}
uploadError.value = '';
}
});
const isNew = computed(() => !props.question);
const showFullPreview = ref(false);
const showVideoPreview = ref(false);
const showAudioPreview = ref(false);
const uploading = ref(false);
const uploadError = ref('');
function close() {
visible.value = false;
}
function save() {
emit('save', JSON.parse(JSON.stringify(editedItem)));
close();
}
function getPreviewUrl(relativePath) {
if (!relativePath || relativePath.startsWith('http')) return relativePath;
return `${props.apiUrl}/quizz/${props.sessionId}${relativePath}`;
}
// Upload Handling
const fileInputRef = ref(null);
function onFileSelected(event) {
const file = event.target.files[0];
if (file) handleFileUpload(file);
event.target.value = '';
}
async function handleFileUpload(file) {
if (!file) return;
uploading.value = true;
uploadError.value = '';
if (!editedItem.Type) {
uploadError.value = "Veuillez sélectionner un Type avant d'uploader un fichier.";
uploading.value = false;
return;
}
try {
const formData = new FormData();
formData.append('questionId', editedItem.QuestionId);
formData.append('type', editedItem.Type);
formData.append('file', file);
const response = await axios.post(`${props.apiUrl}/upload/${props.sessionId}`, formData);
if (response.data && response.data.path) {
editedItem.MediaUrl = response.data.path;
}
} catch (e) {
console.error("Upload error:", e);
uploadError.value = "Erreur lors de l'upload du fichier.";
} finally {
uploading.value = false;
}
}
function deleteMedia() {
if (!editedItem.MediaUrl) return;
if (confirm('Voulez-vous vraiment supprimer ce média ? Cette action est irréversible.')) {
emit('delete-media', editedItem.MediaUrl);
editedItem.MediaUrl = '';
}
}
</script>
<style scoped>
.text-title-style {
color: rgb(var(--v-theme-primary), 1) !important;
opacity: 1;
font-size: 20px;
font-weight: bold;
}
.preview-box {
width: 120px;
height: 120px;
background: #333;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 8px;
position: relative;
flex-shrink: 0;
}
.preview-content {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div>
<v-alert v-if="error" type="error" closable class="mb-4">{{ error }}</v-alert>
<v-alert rounded="xl" v-if="success" type="success" closable class="ma-15">{{ success }}</v-alert>
<v-card class="ma-15 pa-5" rounded="xl">
<v-card-title class="pb-10">Configuration Générale</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field color="primary" v-model="config.PackId" label="ID du Pack" readonly variant="outlined" rounded="xl"></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field color="primary" v-model="config.PackTitle" label="Titre du Pack" variant="outlined" rounded="xl"></v-text-field>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card rounded="xl" class="pa-5 ma-15">
<!-- List container without v-list wrapper for clean transition -->
<SessionQuestionsList
:questions="config.Questions"
:session-id="sessionId"
:api-url="apiUrl"
@move="(index, dir) => moveQuestion(index, dir)"
@update="(q, i) => updateQuestion(q, i)"
@delete="(i) => deleteQuestion(i)"
@delete-media="(path) => $emit('delete-media', path)"
/>
</v-card>
</div>
</template>
<script setup>
import SessionQuestionsList from '@/components/SessionQuestionsList.vue';
const props = defineProps({
config: {
type: Object,
required: true
},
sessionId: {
type: String,
required: true
},
apiUrl: {
type: String,
required: true
},
success: String,
error: String
});
const emit = defineEmits(['delete-media', 'rename-media']);
const cleanUrl = (url) => url ? url.split('?')[0] : url;
function deleteQuestion(index) {
if (confirm('Êtes-vous sûr de vouloir supprimer cette question ? (Le média sera aussi supprimé)')) {
const question = props.config.Questions[index];
if (question.MediaUrl) {
emit('delete-media', cleanUrl(question.MediaUrl));
}
props.config.Questions.splice(index, 1);
reindexQuestions();
}
}
function reindexQuestions() {
// Step 1: Rename conflict candidates to temporary names
console.log("reindexQuestions: Starting Step 1 (Rename to TMP)");
props.config.Questions.forEach((q, i) => {
const idNumber = (i + 1).toString().padStart(3, '0');
const newId = `Q-${idNumber}`;
if (q.QuestionId !== newId && q.MediaUrl) {
// It will be renamed. Rename to TEMP first.
const lastDotIndex = q.MediaUrl.lastIndexOf('.');
if (lastDotIndex !== -1) {
const ext = q.MediaUrl.substring(lastDotIndex);
const lastSlashIndex = q.MediaUrl.lastIndexOf('/');
const folderPath = q.MediaUrl.substring(0, lastSlashIndex + 1);
const tempId = `TMP-${q.QuestionId}`;
const tempPath = folderPath + tempId + ext;
emit('rename-media', {
oldPath: cleanUrl(q.MediaUrl),
newName: tempId
});
console.log(`Step 1: Renaming ${q.MediaUrl} to ${tempId}`);
// Store temp path but DO NOT update MediaUrl yet to avoid browser locking the file
q._temp_path = tempPath;
// Mark for second pass
q._temp_renamed = true;
}
}
});
// Step 2: Rename all to final names (Delayed to allow FS to settle)
setTimeout(() => {
console.log("reindexQuestions: Starting Step 2 (Rename to Final)");
props.config.Questions.forEach((q, i) => {
const idNumber = (i + 1).toString().padStart(3, '0');
const newId = `Q-${idNumber}`;
if (q._temp_renamed && q._temp_path) {
// Rename TMP -> Final
const lastDotIndex = q.MediaUrl.lastIndexOf('.');
const ext = q.MediaUrl.substring(lastDotIndex);
const lastSlashIndex = q.MediaUrl.lastIndexOf('/');
const folderPath = q.MediaUrl.substring(0, lastSlashIndex + 1);
console.log(`Step 2: Renaming ${q._temp_path} to ${newId}`);
emit('rename-media', {
oldPath: cleanUrl(q._temp_path), // Use the stored temp path
newName: newId
});
// Update with timestamp to force refresh
q.MediaUrl = folderPath + newId + ext + `?t=${Date.now()}`;
delete q._temp_renamed;
delete q._temp_path;
}
// Note: QuestionId update is done here inside the timeout
// This might cause a slight reactive delay but ensures alignment
q.QuestionId = newId;
});
}, 200); // 200ms delay
}
function updateQuestion(questionData, index) {
if (index > -1) {
Object.assign(props.config.Questions[index], questionData);
} else {
// Add new
if (!questionData._ui_key) {
Object.defineProperty(questionData, '_ui_key', {
value: `q-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
writable: true,
enumerable: false,
configurable: true
});
}
props.config.Questions.push(questionData);
reindexQuestions();
}
}
function moveQuestion(index, direction) {
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < props.config.Questions.length) {
const item = props.config.Questions.splice(index, 1)[0];
props.config.Questions.splice(newIndex, 0, item);
reindexQuestions();
}
}
</script>

View File

@@ -0,0 +1,256 @@
<template>
<div class="session-questions-list">
<div class="d-flex justify-space-between align-center mb-5 px-4">
<span class="text-h6">Questions ({{ questions.length }})</span>
<v-btn rounded="xl" color="secondary" size="small" prepend-icon="mdi-plus" @click="openDialog()">Ajouter</v-btn>
</div>
<div v-if="questions.length === 0" class="text-h5 font-weight-bold text-center text-primary pb-5">
Aucune question définie.
</div>
<!-- List container without v-list wrapper for clean transition -->
<transition-group name="flip-list" tag="div" class="question-list-container">
<v-card v-for="(question, index) in questions" :key="question._ui_key" rounded="xl" variant="outlined" class="mb-2 question-item">
<v-card-text class="d-flex align-center py-2">
<div class="mr-4 font-weight-bold text-h6 text-primary">{{ index + 1 }}</div>
<div class="flex-grow-1">
<div class="text-subtitle-1 font-weight-bold">
<span class="text-primary opacity-100 pb-2">{{ question.QuestionId }}</span>
</div>
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Question :</span> {{ question.QuestionText }}</div>
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Réponse :</span> {{ question.MasterData.CorrectAnswer }}</div>
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Points :</span> {{ question.Points }}</div>
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Temps :</span> {{ question.Settings.PlayTime }} secondes</div>
<div class="text-body-2 font-weight-bold"><span class="text-primary opacity-100">Type :</span> {{ question.Type }}</div>
</div>
<div v-if="question.MediaUrl" class="mr-10">
<div class="list-preview-box" @click="openPreview(question.Type, question.MediaUrl)" style="cursor: pointer;">
<img v-if="question.Type === 'picture'" :src="getPreviewUrl(question.MediaUrl)" class="list-preview-content">
<video
v-else-if="question.Type === 'video'"
:src="getPreviewUrl(question.MediaUrl) + '#t=0.1'"
class="list-preview-content"
preload="metadata"
muted
></video>
<div v-else-if="question.Type === 'audio'">
<v-icon size="32" color="primary">mdi-music</v-icon>
</div>
</div>
</div>
<div class="d-flex flex-column gap-1">
<v-btn icon="mdi-arrow-up" size="x-large" variant="text" :disabled="index === 0" @click="$emit('move', index, -1)"></v-btn>
<v-btn icon="mdi-arrow-down" size="x-large" variant="text" :disabled="index === questions.length - 1" @click="$emit('move', index, 1)"></v-btn>
</div>
<div class="d-flex ml-2">
<v-btn icon="mdi-pencil" size="x-large" variant="text" color="blue" @click="openDialog(question, index)"></v-btn>
<v-btn icon="mdi-delete" size="x-large" variant="text" color="red" @click="$emit('delete', index)"></v-btn>
</div>
</v-card-text>
</v-card>
</transition-group>
<!-- Use persistent prop if needed, though hidden by v-model -->
<QuestionEditorDialog
v-model="dialogVisible"
:question="editingQuestion"
:session-id="sessionId"
:api-url="apiUrl"
:next-index="nextQuestionIndex"
@save="onSave"
@delete-media="onDeleteMedia"
/>
<!-- Dialog Preview Image -->
<v-dialog v-model="showFullPreview" max-width="90vw">
<v-card rounded="xl" class="pa-2">
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<v-img
:src="getPreviewUrl(previewMediaUrl)"
max-height="80vh"
rounded="lg"
@click="showFullPreview = false"
style="cursor: pointer;"
></v-img>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showFullPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Preview Video -->
<v-dialog v-model="showVideoPreview" max-width="90vw">
<v-card rounded="xl" class="pa-2">
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<video
:src="getPreviewUrl(previewMediaUrl)"
controls
autoplay
disablepictureinpicture
disableremoteplayback
controlslist="nodownload"
style="max-width: 100%; max-height: 80vh; border-radius: 12px;"
></video>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showVideoPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Preview Audio -->
<v-dialog v-model="showAudioPreview" max-width="500px">
<v-card rounded="xl" class="pa-4">
<v-card-title class="text-center">
<v-icon size="64" color="primary">mdi-music-circle</v-icon>
</v-card-title>
<v-card-text class="d-flex justify-center" style="padding: 16px;">
<audio
:src="getPreviewUrl(previewMediaUrl)"
controls
controlslist="nodownload"
disablepictureinpicture
disableremoteplayback
autoplay
style="width: 100%;"
></audio>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showAudioPreview = false">Fermer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import QuestionEditorDialog from './QuestionEditorDialog.vue';
const props = defineProps({
questions: {
type: Array,
required: true,
default: () => []
},
sessionId: {
type: String,
required: true
},
apiUrl: {
type: String,
required: true
}
});
const emit = defineEmits(['move', 'delete', 'update', 'delete-media']);
// Dialog state
const dialogVisible = ref(false);
const editingQuestion = ref(null);
const editingIndex = ref(-1);
const nextQuestionIndex = ref(1);
function openDialog(question = null, index = -1) {
editingQuestion.value = question; // if null, dialog treats as new
editingIndex.value = index;
// Calculate next index: if editing, use current index + 1, else use length + 1
if (index >= 0) {
nextQuestionIndex.value = index + 1;
} else {
nextQuestionIndex.value = props.questions.length + 1;
}
dialogVisible.value = true;
}
function onSave(questionData) {
emit('update', questionData, editingIndex.value);
}
function onDeleteMedia(mediaUrl) {
emit('delete-media', mediaUrl);
}
function getPreviewUrl(relativePath) {
if (!relativePath || relativePath.startsWith('http')) return relativePath;
return `${props.apiUrl}/quizz/${props.sessionId}${relativePath}`;
}
const showFullPreview = ref(false);
const showVideoPreview = ref(false);
const showAudioPreview = ref(false);
const previewMediaUrl = ref('');
function openPreview(type, url) {
previewMediaUrl.value = url;
if (type === 'picture') showFullPreview.value = true;
else if (type === 'video') showVideoPreview.value = true;
else if (type === 'audio') showAudioPreview.value = true;
}
</script>
<style scoped>
.gap-1 {
gap: 4px;
}
.question-list-container {
position: relative;
}
.question-item {
transition: all 0.5s ease;
/* Ensure z-index is handled during move for better visual stack */
z-index: 1;
}
/* Transition d'animation de liste */
.flip-list-move {
transition: transform 0.5s;
}
/* Entering items */
.flip-list-enter-active,
.flip-list-leave-active {
transition: all 0.5s ease;
}
.flip-list-enter-from,
.flip-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* Ensure removed items are taken out of flow so others can move up smoothly */
.flip-list-leave-active {
position: absolute;
width: 100%; /* Important to maintain width when absolute */
}
.list-preview-box {
width: 80px;
height: 80px;
background: #333;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 8px;
flex-shrink: 0;
}
.list-preview-content {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<v-row class="ml-15 mr-15 mt-5 mb-10 align-center">
<v-col cols="12" md="4" class="d-flex align-center gap-2">
<v-select
v-model="internalValue"
:items="sessions"
item-title="title"
item-value="id"
label="Choisir une session"
variant="outlined"
hide-details
rounded="xl"
class="flex-grow-1"
></v-select>
<v-btn icon="mdi-plus" color="green" variant="tonal" @click="$emit('create')" title="Nouvelle Session"></v-btn>
<v-btn icon="mdi-delete" color="red" variant="tonal" @click="$emit('delete')" :disabled="!internalValue" title="Supprimer Session"></v-btn>
</v-col>
<v-col class="text-right">
<v-btn rounded="xl" color="primary" @click="$emit('save')" :loading="saving" :disabled="!internalValue">
<v-icon start>mdi-content-save</v-icon> Sauvegarder
</v-btn>
</v-col>
</v-row>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: null
},
sessions: {
type: Array,
default: () => []
},
saving: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'create', 'delete', 'save']);
const internalValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
</script>
<style scoped>
.gap-2 {
gap: 8px;
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<v-container v-show="gamehiding === false" class="player_video_div">
<video
ref="videoJsPlayer"
class="video-js player_video"
controls
></video>
</v-container>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import Mysteryland_h264 from '@/quizz/Quizz-1/festival/Mysteryland_h264.mp4';
import { subscribeToTopic } from '@/services/mqttService';
let gamehiding = ref(true);
let player = ref(null);
const videoOptions = {
autoplay: false,
controls: false,
preload: 'auto',
fluid: true,
loop: false,
volume: 0,
sources: [{ src: Mysteryland_h264, type: 'video/mp4' }],
};
const handleMessage = (topic, message) => {
console.log(topic, message)
if (topic === "/display/media") {
switch (message) {
case "BOX_BEAT.mp4":
changeVideoSource("BOX_BEAT.mp4")
break;
case "DARK_VALLEY.mp4":
changeVideoSource("DARK_VALLEY.mp4")
break;
}
}
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
console.log("▶️ Lecture de la vidéo !");
player.value.play().catch((error) => {
console.error("Erreur de lecture :", error);
});
break;
case "pause":
gamehiding.value = true;
console.log("⏸️ Pause de la vidéo !");
player.value.pause();
break;
case "hide":
console.log("🛑 Cacher la vidéo (implémentation à venir)");
break;
}
}
};
const changeVideoSource = (relativePath) => {
try {
const fullPath = new URL(`../quizz/Quizz-1/festival/${relativePath}`, import.meta.url).href;
if (player.value) {
if (relativePath.includes("mp4")){
player.value.src({ src: fullPath, type: 'video/mp4' });
}
if (relativePath.includes(".jpg")){
player.value.src({ src: fullPath, type: 'image/jpeg' });
}
player.value.load();
player.value.play().catch((err) => console.error('Erreur lecture :', err));
}
} catch (error) {
console.error('❌ Erreur lors du chargement de la vidéo :', error);
}
};
// --- Lifecycle
onMounted(() => {
player.value = videojs(
document.querySelector('.video-js'),
videoOptions,
() => {
console.log('🎥 Video player ready');
}
);
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
});
});
onBeforeUnmount(() => {
if (player) {
player.dispose();
}
});
</script>
<style>
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
}
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
}
.vjs-tech{
border-radius: 25px;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<v-app-bar :elevation="5" height="50">
<RouterMenu />
<v-app-bar-title v-if="$route.name !== 'Accueil'">Vulture</v-app-bar-title>
</v-app-bar>
</template>
<script setup>
import RouterMenu from '@/components/RouterMenu.vue'
</script>

View File

@@ -1,16 +1,27 @@
// Fichier vide, regarde config.js.example pour personaliser ce fichier.
// Note de dev : Normalement ce fichier ne devrait plus avoir de
// modifications
// config.js // config.js
export default { // Reads configuration from window.APP_CONFIG (loaded via public/config.js)
mqttBrokerUrl: 'ws://192.168.1.30:9001', // This allows runtime configuration changes without rebuilding the app.
// Buzzer const defaults = {
mqttBrokerUrl: 'ws://192.168.1.201:9001',
redBuzzerIP: '192.168.73.40', redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41', blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42', orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43' greenBuzzerIP: '192.168.73.43',
apiUrl: 'http://192.168.1.178:3001',
// Light topics: {
requestList: 'game/session/list/request',
responseList: 'game/session/list/response',
requestConfig: 'game/session/config/request',
getConfig: 'game/session/config/get',
updateConfig: 'game/session/config/update',
createSession: 'game/session/create',
deleteSession: 'game/session/delete',
deleteMedia: 'game/session/media/delete',
renameMedia: 'game/session/media/rename'
}
}; };
const config = window.APP_CONFIG || defaults;
export default config;

View File

@@ -3,30 +3,41 @@ import HomeView from '../views/HomeView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ { routes: [{
path: '/', path: '/',
name: 'Accueil', name: 'Accueil',
component: HomeView component: HomeView
}, },
{ {
path: '/game/control', path: '/game/control',
name: 'Game Control (Présentateur)', name: 'Game Control (Présentateur)',
component: () => import('@/views/GameControl.vue') component: () => import('@/views/GameControl.vue')
}, },
{ {
path: '/game/display', path: '/game/display',
name: 'Game Display (Projection)', name: 'Game Display (Projection)',
component: () => import('@/views/GameDisplay.vue') component: () => import('@/views/GameDisplay.vue')
}, },
{ {
path: '/mqtt-debugger', path: '/score/display',
name: 'Debugger MQTT', name: 'Score Display (Projection)',
component: () => import('@/views/MQTTDebugView.vue') component: () => import('@/views/ScoreDisplay.vue')
}, },
{ {
path: '/settings', path: '/mqtt-debugger',
name: 'Paramètres', name: 'Debugger MQTT',
component: () => import('@/views/SettingsView.vue') } component: () => import('@/views/MQTTDebugView.vue')
},
{
path: '/settings',
name: 'Paramètres',
component: () => import('@/views/SettingsView.vue')
},
{
path: '/session-editor',
name: 'Éditeur de Session',
component: () => import('@/views/SessionEditor.vue')
}
] ]
}) })

View File

@@ -13,36 +13,38 @@ import { createVuetify } from 'vuetify'
const CustomThemeDark = { const CustomThemeDark = {
dark: true, dark: true,
colors: { colors: {
background: '#121212', background: '#121212',
primary: '#d42828', primary: '#d42828',
secondary: '#F44336', secondary: '#F44336',
accent: '#FFC107', accent: '#FFC107',
error: '#e91e1e', error: '#e91e1e',
warning: '#FFC107', warning: '#FFC107',
info: '#607D8B', info: '#607D8B',
success: '#15B01B', inactiveButton: '#707070ff',
BlueBuzzer: '#2867d4', success: '#15B01B',
YellowBuzzer: '#D4D100', BlueBuzzer: '#2867d4',
RedBuzzer: '#d42828', YellowBuzzer: '#D4D100',
RedBuzzer: '#d42828',
GreenBuzzer: '#28d42e', GreenBuzzer: '#28d42e',
DisconnectedBuzzer: '#595959', DisconnectedBuzzer: '#595959',
} }
} }
const CustomThemeLight = { const CustomThemeLight = {
dark: false, dark: false,
colors: { colors: {
background: '#ffffff', background: '#ffffff',
primary: '#d42828', primary: '#d42828',
secondary: '#F44336', secondary: '#F44336',
accent: '#FFC107', accent: '#FFC107',
error: '#e91e1e', error: '#e91e1e',
warning: '#FFC107', warning: '#FFC107',
info: '#607D8B', info: '#607D8B',
success: '#4CAF50', inactiveButton: '#707070ff',
BlueBuzzer: '#2867d4', success: '#4CAF50',
YellowBuzzer: '#D4D100', BlueBuzzer: '#2867d4',
RedBuzzer: '#d42828', YellowBuzzer: '#D4D100',
RedBuzzer: '#d42828',
GreenBuzzer: '#28d42e', GreenBuzzer: '#28d42e',
DisconnectedBuzzer: '#595959', DisconnectedBuzzer: '#595959',
} }
@@ -50,10 +52,11 @@ const CustomThemeLight = {
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({ export default createVuetify({
theme: { theme: {
defaultTheme: 'CustomThemeDark', defaultTheme: 'CustomThemeDark',
themes: { themes: {
CustomThemeDark, CustomThemeDark,
CustomThemeLight, }, CustomThemeLight,
},
}, },
}) })

166
VApp/src/store/quizStore.js Normal file
View File

@@ -0,0 +1,166 @@
import { reactive, computed } from 'vue';
import mqtt from 'mqtt';
import config from '@/config.js';
import sessionConfig from '@/quizz/vulture-session-2026-01/session-configuration.json';
// Reactive state
const state = reactive({
currentQuestionIndex: 0,
questions: [],
isMediaHidden: true,
packTitle: '',
timer: 0 // Timer in seconds
});
// MQTT Client
let client = null;
let timerInterval = null;
let initialized = false;
// Initialize Store
function init() {
if (initialized) {
console.log('QuizStore: Already initialized');
return;
}
initialized = true;
// Load local config immediately
state.questions = sessionConfig.Questions || [];
state.packTitle = sessionConfig.PackTitle || '';
// Connect MQTT
client = mqtt.connect(config.mqttBrokerUrl);
client.on('connect', () => {
console.log('QuizStore: MQTT Connected');
client.subscribe('game/quiz/control');
client.subscribe('/display/control');
});
client.on('message', (topic, message) => {
const msgStr = message.toString();
console.log('QuizStore: MQTT Message Received', topic, msgStr);
if (topic === 'game/quiz/control') {
try {
const payload = JSON.parse(msgStr);
handleRemoteCommand(payload);
} catch (e) {
console.error('QuizStore: JSON Parse Error', e);
}
} else if (topic === '/display/control') {
// Handle raw string commands from MqttButtons
if (msgStr === 'next') {
_nextQuestion(true);
} else if (msgStr === 'previous') {
_prevQuestion(true);
} else if (msgStr === 'play') {
// Start timer for picture questions
const currentQ = state.questions[state.currentQuestionIndex];
if (currentQ?.Type === 'picture') {
const playTime = currentQ.Settings?.PlayTime;
if (playTime && playTime > 0) {
console.log('QuizStore: Starting timer for picture', playTime);
actions.startTimer(playTime);
}
}
} else if (msgStr === 'pause') {
stopTimer();
}
}
});
}
function handleRemoteCommand(cmd) {
if (cmd.action === 'next') {
_nextQuestion(false);
} else if (cmd.action === 'prev') {
_prevQuestion(false);
} else if (cmd.action === 'setIndex') {
state.currentQuestionIndex = cmd.index;
}
}
// Internal actions (boolean publish determines if we send MQTT)
function _nextQuestion(publish = true) {
if (state.currentQuestionIndex < state.questions.length - 1) {
state.currentQuestionIndex++;
if (publish && client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: state.currentQuestionIndex }));
}
}
}
function _prevQuestion(publish = true) {
if (state.currentQuestionIndex > 0) {
state.currentQuestionIndex--;
if (publish && client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: state.currentQuestionIndex }));
}
}
}
// Public Actions
const actions = {
init,
nextQuestion: () => _nextQuestion(true),
prevQuestion: () => _prevQuestion(true),
setQuestion: (index) => {
if (index >= 0 && index < state.questions.length) {
state.currentQuestionIndex = index;
if (client) {
client.publish('game/quiz/control', JSON.stringify({ action: 'setIndex', index: index }));
}
}
},
startTimer: (seconds) => {
stopTimer();
state.timer = seconds;
publishTimer();
timerInterval = setInterval(() => {
if (state.timer > 0) {
state.timer--;
publishTimer();
} else {
stopTimer();
// Auto-hide by publishing pause
if (client) {
client.publish('/display/control', 'pause');
}
}
}, 1000);
},
stopTimer
};
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
state.timer = 0;
publishTimer();
}
function publishTimer() {
if (client) {
client.publish('game/timer', JSON.stringify({ time: state.timer }));
}
}
// Getters
const getters = {
currentQuestion: computed(() => state.questions[state.currentQuestionIndex]),
isFirstQuestion: computed(() => state.currentQuestionIndex === 0),
isLastQuestion: computed(() => state.currentQuestionIndex === state.questions.length - 1),
totalQuestions: computed(() => state.questions.length),
packTitle: computed(() => state.packTitle),
currentQuestionIndex: computed(() => state.currentQuestionIndex),
timer: computed(() => state.timer)
};
export default {
state,
actions,
getters
};

View File

@@ -5,18 +5,18 @@ export default {
RedTotalScore: 11, RedTotalScore: 11,
BlueTotalScore: 22, BlueTotalScore: 22,
GreenTotalScore: 33, GreenTotalScore: 33,
OrangeTotalScore: 44, YellowTotalScore: 44,
// Score de la manche courante // Score de la manche courante
RedRoundScore: 1, RedRoundScore: 1,
BlueRoundScore: 2, BlueRoundScore: 2,
OrangeRoundScore: 3, YellowRoundScore: 3,
GreenRoundScore: 4, GreenRoundScore: 4,
//Etat des buzzer //Etat des buzzer
BuzzerRed: false, BuzzerRed: false,
BuzzerBlue: false, BuzzerBlue: false,
BuzzerOrange: false, BuzzerYellow: false,
BuzzerGreen: false, BuzzerGreen: false,
// Ajoutez d'autres variables globales ici // Ajoutez d'autres variables globales ici
@@ -24,4 +24,4 @@ export default {
// Variables localStorage // Variables localStorage
export const localStorageVars = { // Exemple de variable localStorage RedScorelocal: localStorage.getItem('RedScore') || '', BlueScorelocal: localStorage.getItem('BlueScore') || '', OrangeScorelocal: localStorage.getItem('OrangeScore') || '', GreenScorelocal: localStorage.getItem('GreenScore') || '', export const localStorageVars = { // Exemple de variable localStorage RedScorelocal: localStorage.getItem('RedScore') || '', BlueScorelocal: localStorage.getItem('BlueScore') || '', OrangeScorelocal: localStorage.getItem('OrangeScore') || '', GreenScorelocal: localStorage.getItem('GreenScore') || '',
}; };

View File

@@ -5,71 +5,78 @@
<card-control /> <card-control />
</v-col> </v-col>
<v-col class="pl-3"> <v-col class="pl-3">
<card-soundboard /> <CardButtonScore />
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
<v-row no-gutters class="pr-4 pl-4"> <v-row no-gutters class="pr-4 pl-4">
<v-row no-gutters> <v-row no-gutters>
<v-col class="align-start"> <v-col class="align-start">
<CardButtonScore /> <card-solution />
</v-col> </v-col>
<v-col class="pl-3"> <v-col class="pl-3">
<card-solution />
</v-col> </v-col>
</v-row> </v-row>
</v-row> </v-row>
<BuzzerValidationDialog />
</template> </template>
<script setup> <script setup>
import CardSolution from '@/components/CardSolution.vue' import CardSolution from '@/components/CardSolution.vue'
import CardControl from '@/components/CardControl.vue' import CardControl from '@/components/CardControl.vue'
import CardSoundboard from '@/components/CardSoundboard.vue';
import CardButtonScore from '@/components/CardButtonScore.vue' import CardButtonScore from '@/components/CardButtonScore.vue'
import BuzzerValidationDialog from '@/components/BuzzerValidationDialog.vue';
import { onMounted } from 'vue';
import quizStore from '@/store/quizStore';
onMounted(() => {
quizStore.actions.init();
});
</script> </script>
<style> <style>
@media (min-width: 1024px) { @media (min-width: 1024px) {
.card__title.primary { .card__title.primary {
background-color: #d42828; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-primary)); /* Changez la couleur en fonction de votre thème */
} }
.card__title.feedback { .card__title.feedback {
background-color: #2E7D32; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-success)); /* Changez la couleur en fonction de votre thème */
} }
.btn{ .btn{
border-radius:20px!important; border-radius:20px!important;
} }
.btn.red { .btn.red {
background-color: #d42828; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-RedBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.blue { .btn.blue {
background-color: #2867d4; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-BlueBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.yellow { .btn.yellow {
background-color: #d4d100; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-YellowBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.btn.green { .btn.green {
background-color: #28d42e; /* Changez la couleur en fonction de votre thème */ background-color: rgb(var(--v-theme-GreenBuzzer)); /* Changez la couleur en fonction de votre thème */
} }
.scorediv-style-red { .scorediv-style-red {
background-color: #d42828 !important; background-color: rgb(var(--v-theme-RedBuzzer)) !important;
padding: 15px; padding: 15px;
border-top-left-radius: 10%; border-top-left-radius: 10%;
} }
.scorediv-style-yellow { .scorediv-style-yellow {
background-color: #d4d100!important; background-color: rgb(var(--v-theme-YellowBuzzer)) !important;
padding: 15px; padding: 15px;
border-bottom-left-radius: 10%; border-bottom-left-radius: 10%;
} }
.scorediv-style-blue { .scorediv-style-blue {
background-color: #2867d4 !important; background-color: rgb(var(--v-theme-BlueBuzzer)) !important;
padding: 15px; padding: 15px;
border-top-right-radius: 10%; border-top-right-radius: 10%;
} }
.scorediv-style-green { .scorediv-style-green {
background-color: #28d42e !important; background-color: rgb(var(--v-theme-GreenBuzzer)) !important;
padding: 15px; padding: 15px;
border-bottom-right-radius: 10%; border-bottom-right-radius: 10%;
} }

View File

@@ -1,114 +1,219 @@
<template> <template>
<div class="main_div"> <div class="main_div">
<div> <div>
<v-container class="score_div_main"> <v-container class="score_div_main" :style="getMainShadow()">
<v-container class="score_div color-blue"></v-container> <v-container class="score_div color-blue" :class="[getDimClass('Blue'), getFlashClass('Blue')]">
<v-container class="score_div color-red"></v-container> <div class="d-flex flex-column align-center">
<v-container class="score_div color-white d-flex align-center justify-center"> <Transition name="score-fade" mode="out-in">
<span class="v-label-time">00:00</span> <span :key="scores.BlueTotalScore" class="v-label-score">{{ scores.BlueRoundScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-red" :class="[getDimClass('Red'), getFlashClass('Red')]">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.RedTotalScore" class="v-label-score">{{ scores.RedRoundScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-white d-flex align-center justify-center">
<span class="v-label-time">{{ timerDisplay }}</span>
</v-container>
<v-container class="score_div color-green" :class="[getDimClass('Green'), getFlashClass('Green')]">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.GreenTotalScore" class="v-label-score">{{ scores.GreenRoundScore }}</span>
</Transition>
</div>
</v-container>
<v-container class="score_div color-yellow" :class="[getDimClass('Yellow'), getFlashClass('Yellow')]">
<div class="d-flex flex-column align-center">
<Transition name="score-fade" mode="out-in">
<span :key="scores.YellowTotalScore" class="v-label-score">{{ scores.YellowRoundScore }}</span>
</Transition>
</div>
</v-container> </v-container>
<v-container class="score_div color-green"></v-container>
<v-container class="score_div color-yellow"></v-container>
</v-container> </v-container>
</div> </div>
<div> <div>
<v-container v-show="gamehiding === true" class="v-container-game-hided"> <HidingOverlay/>
<v-img src="@\assets\v-hide.png" class="v-img-hidding"></v-img> <GameMedia/>
</v-container>
<v-container v-show="gamehiding === false" class="player_video_div">
<video
ref="videoJsPlayer"
class="video-js player_video"
controls
></video>
</v-container>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'; import GameMedia from "@/components/GameMedia.vue"
import videojs from 'video.js'; import HidingOverlay from "@/components/HidingOverlay.vue"
import 'video.js/dist/video-js.css'; import { onMounted, reactive } from 'vue';
import Mysteryland_h264 from '../quizz/Quizz-1/festival/Mysteryland_h264.mp4'; import mqtt from 'mqtt'
import { subscribeToTopic } from '@/services/mqttService'; import config from '@/config.js'
import quizStore from '@/store/quizStore';
// --- Déclarations // Configuration MQTT
const player = ref(null); const mqttBrokerUrl = config.mqttBrokerUrl
let gamehiding = ref(true) const client = mqtt.connect(mqttBrokerUrl)
const videoOptions = { // Objet réactif pour stocker les scores des équipes
autoplay: false, const scores = reactive({
controls: false, RedTotalScore: 0,
preload: 'auto', BlueTotalScore: 0,
fluid: true, YellowTotalScore: 0,
loop: true, GreenTotalScore: 0,
volume: 0, RedRoundScore: 0,
sources: [{ src: Mysteryland_h264, type: 'video/mp4' }], BlueRoundScore: 0,
}; YellowRoundScore: 0,
GreenRoundScore: 0,
// --- Fonctions
const playVideo = () => {
if (player.value) {
console.log("▶️ Lecture de la vidéo !");
player.value.play().catch((error) => {
console.error("Erreur de lecture :", error);
});
} else {
console.warn("⚠️ Player non encore initialisé !");
}
};
const pauseVideo = () => {
if (player.value) {
console.log("⏸️ Pause de la vidéo !");
player.value.pause();
} else {
console.warn("⚠️ Player non encore initialisé !");
}
};
const handleMessage = (topic, message) => {
if (topic === "/display/control") {
switch (message) {
case "play":
gamehiding.value = false;
playVideo();
break;
case "pause":
gamehiding.value = true;
pauseVideo();
break;
case "hide":
console.log("🛑 Cacher la vidéo (implémentation à venir)");
break;
default:
console.warn("Commande non reconnue :", message);
}
}
};
// --- Lifecycle
onMounted(() => {
player.value = videojs(
document.querySelector('.video-js'),
videoOptions,
() => {
console.log('🎥 Video player ready');
}
);
subscribeToTopic('#', (topic, message) => {
handleMessage(topic, message);
}); });
});
onBeforeUnmount(() => { import { ref } from 'vue';
if (player.value) { // Variable réactive pour l'affichage du timer
player.value.dispose(); const timerDisplay = ref('00:00');
// Fonction pour formater le temps en mm:ss
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// Fonction de gestion des messages MQTT reçus
function handleMessage(topic, message) {
let parsedMessage;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
console.error("Erreur d'analyse JSON:", e);
return;
}
// Mise à jour des scores si le message vient du topic 'game/score'
if (topic === 'game/score' && parsedMessage.TEAM) {
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
scores.YellowTotalScore = parsedMessage.TEAM.Yellow.TotalScore
scores.GreenTotalScore = parsedMessage.TEAM.Green.TotalScore
scores.RedRoundScore = parsedMessage.TEAM.Red.RoundScore
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
}
// Mise à jour du timer si le message vient du topic 'game/timer'
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
timerDisplay.value = formatTime(parsedMessage.time);
}
}
// Fonction utilitaire pour s'abonner à un topic MQTT
function subscribeToTopic(topic, callback) {
client.subscribe(topic)
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
})
}
onMounted(() => {
// Initialisation du store du quiz
quizStore.actions.init();
// Abonnement aux topics MQTT pour les scores et le timer
subscribeToTopic('game/score', (topic, message) => {
handleMessage(topic, message);
});
subscribeToTopic('game/timer', (topic, message) => {
handleMessage(topic, message);
});
// Demande de rafraîchissement des scores au chargement
// Cela permet de récupérer les scores actuels même après un rechargement de page
client.publish('game/score/request', '{}');
// Abonnement au statut des buzzers
subscribeToTopic('vulture/buzzer/status', (topic, message) => {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
const color = data.color || '';
const team = identifyTeamByColor(color);
activeTeam.value = team;
// Trigger flash effect
if (team) {
flashingTeam.value = team;
setTimeout(() => {
if (flashingTeam.value === team) {
flashingTeam.value = null;
}
}, 2000);
}
console.log(`Buzzer Blocked: Color=${color}, Identified Team=${activeTeam.value}`);
} else if (data.status === 'unblocked') {
activeTeam.value = null;
flashingTeam.value = null;
console.log('Buzzer Unblocked');
}
} catch (e) {
console.error('Error parsing buzzer status', e);
}
});
});
const activeTeam = ref(null);
const flashingTeam = ref(null);
function identifyTeamByColor(hexColor) {
if (!hexColor) return null;
// Normalisation (retirer le # et mettre en majuscule)
const color = hexColor.replace('#', '').toUpperCase();
// Liste des couleurs connues (Theme + Standard)
// RED
if (['FF0000', 'D42828'].includes(color)) return 'Red';
// BLUE
if (['0000FF', '2867D4'].includes(color)) return 'Blue';
// GREEN
if (['00FF00', '28D42E'].includes(color)) return 'Green';
// YELLOW
if (['FFFF00', 'D4D100'].includes(color)) return 'Yellow';
// Fallback: Détection approximative par composante dominante
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
if (r > 200 && g > 200 && b < 100) return 'Yellow';
if (g > r && g > b) return 'Green';
if (b > r && b > g) return 'Blue';
if (r > g && r > b) return 'Red';
return null;
}
function getDimClass(team) {
if (!activeTeam.value) return '';
return activeTeam.value !== team ? 'dimmed-score' : '';
}
function getFlashClass(team) {
return flashingTeam.value === team ? 'flashing-glow' : '';
}
function getMainShadow() {
if (!activeTeam.value) return {};
let shadowColor = '';
switch (activeTeam.value) {
case 'Blue': shadowColor = 'rgb(40, 103, 212)'; break;
case 'Red': shadowColor = 'rgb(212, 40, 40)'; break;
case 'Green': shadowColor = 'rgb(40, 212, 46)'; break;
case 'Yellow': shadowColor = 'rgb(212, 209, 0)'; break;
default: return {};
}
return { boxShadow: `0px 3px 45px ${shadowColor}` };
} }
});
</script> </script>
<style scoped> <style scoped>
@@ -122,67 +227,93 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 5px; gap: 5px;
background-color: rgb(80, 80, 80); background-color: rgb(40, 40, 40);
padding: 25px 30px; padding: 25px 30px;
border-radius: 0px 0px 30px 30px; border-radius: 0px 0px 30px 30px;
box-shadow: 0px 1px 5px rgb(255, 0, 0); box-shadow: 0px 3px 45px rgb(141, 141, 141);
} }
.score_div { .score_div {
height: 100px; height: 100px;
width: 170px; width: 170px;
text-align: center; text-align: center;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: transform 0.2s;
} }
.color-blue { .color-blue {
background-color: rgb(var(--v-theme-BlueBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-BlueBuzzer)), #1a3a5a);
border-radius: 40px 5px 40px 5px; border-radius: 40px 5px 40px 5px;
} }
.color-red { .color-red {
background-color: rgb(var(--v-theme-RedBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-RedBuzzer)), #5a1a1a);
border-radius: 40px 5px 40px 5px; border-radius: 40px 5px 40px 5px;
} }
.color-green { .color-green {
background-color: rgb(var(--v-theme-GreenBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-GreenBuzzer)), #1a5a2a);
border-radius: 5px 40px 5px 40px; border-radius: 5px 40px 5px 40px;
} }
.color-yellow { .color-yellow {
background-color: rgb(var(--v-theme-YellowBuzzer), 1); background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
border-radius: 5px 40px 5px 40px; border-radius: 5px 40px 5px 40px;
} }
.color-white { .color-white {
background-color: white; background-color: #1a1a1a;
border: 3px solid #333;
border-radius: 40px; border-radius: 40px;
box-shadow: 0 0 30px rgba(0,0,0,0.6);
} }
.v-label-time { .v-label-time {
padding-top: 5px; padding-top: 5px;
color: black; color: white;
font-size: 49px; font-size: 49px;
font-family: 'Bahnschrift'; font-family: 'Bahnschrift';
text-shadow: 0 0 15px rgba(255, 255, 255, 0.2);
}
.v-label-score {
color: white;
font-size: 40px;
font-family: 'Bahnschrift';
font-weight: bold;
line-height: 1;
text-shadow: 4px 4px 8px rgba(0,0,0,0.4);
}
.v-label-round-score {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
font-family: 'Bahnschrift';
font-weight: 500;
margin-bottom: 2px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
/* Transition styles */
.score-fade-enter-active,
.score-fade-leave-active {
transition: opacity 0.3s ease;
} }
.player_video_div {
margin-top: 40px;
width: calc(100vw - 20%);
height: calc(100vh - 20%);
border-radius: 20px !important;
.score-fade-enter-from,
.score-fade-leave-to {
opacity: 0;
} }
.player_video {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
border-radius: 25px !important;
.dimmed-score {
opacity: 0.3;
transform: scale(0.95);
filter: grayscale(100%);
transition: all 0.3s ease;
} }
.vjs-tech{
border-radius: 25px; @keyframes flash-glow {
0%, 100% { box-shadow: 0 0 0 rgba(255, 255, 255, 0); }
10% { box-shadow: 0 0 20px 10px rgba(255, 255, 255, 0.9); }
} }
.v-container-game-hided{
margin-top: 40px; .flashing-glow {
width: calc(100vw - 20%) !important; animation: flash-glow 0.5s ease-in-out infinite;
height: calc(100vh - 20%) !important; z-index: 10;
border-radius: 25px; position: relative;
}
.v-img-hidding{
border-radius: 25px;
} }
</style> </style>

View File

@@ -0,0 +1,425 @@
<template>
<div class="score-grid">
<div class="score-cell cell-blue color-blue" :class="[getDimClass('Blue'), getFlashClass('Blue')]">
<div class="score-content">
<div class="score-info info-left">
<div class="team-name">Bleue</div>
<div class="sub-score-container sub-left">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.BlueTotalScore }}</span>
</div>
</div>
<div class="score-main">
<Transition name="score-pop" mode="out-in">
<div :key="scores.BlueRoundScore" class="team-score main-score">{{ scores.BlueRoundScore }}</div>
</Transition>
</div>
</div>
</div>
<div class="score-cell cell-red color-red" :class="[getDimClass('Red'), getFlashClass('Red')]">
<div class="score-content">
<div class="score-main">
<Transition name="score-pop" mode="out-in">
<div :key="scores.RedRoundScore" class="team-score main-score">{{ scores.RedRoundScore }}</div>
</Transition>
</div>
<div class="score-info info-right">
<div class="team-name">Rouge</div>
<div class="sub-score-container sub-right">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.RedTotalScore }}</span>
</div>
</div>
</div>
</div>
<div class="score-cell cell-green color-green" :class="[getDimClass('Green'), getFlashClass('Green')]">
<div class="score-content">
<div class="score-info info-left">
<div class="team-name">Verte</div>
<div class="sub-score-container sub-left">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.GreenTotalScore }}</span>
</div>
</div>
<div class="score-main">
<Transition name="score-pop" mode="out-in">
<div :key="scores.GreenRoundScore" class="team-score main-score">{{ scores.GreenRoundScore }}</div>
</Transition>
</div>
</div>
</div>
<div class="score-cell cell-yellow color-yellow" :class="[getDimClass('Yellow'), getFlashClass('Yellow')]">
<div class="score-content">
<div class="score-main">
<Transition name="score-pop" mode="out-in">
<div :key="scores.YellowRoundScore" class="team-score main-score">{{ scores.YellowRoundScore }}</div>
</Transition>
</div>
<div class="score-info info-right">
<div class="team-name">Jaune</div>
<div class="sub-score-container sub-right">
<span class="sub-label">Total</span>
<span class="team-score sub-score">{{ scores.YellowTotalScore }}</span>
</div>
</div>
</div>
</div>
<div class="timer-container">
<div class="timer-display">{{ timerDisplay }}</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import mqtt from 'mqtt'
import config from '@/config.js'
const mqttBrokerUrl = config.mqttBrokerUrl
let client = null
const scores = reactive({
RedTotalScore: 0,
BlueTotalScore: 0,
YellowTotalScore: 0,
GreenTotalScore: 0,
RedRoundScore: 0,
BlueRoundScore: 0,
YellowRoundScore: 0,
GreenRoundScore: 0,
});
const timerDisplay = ref('00:00');
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
function handleMessage(topic, message) {
let parsedMessage;
try {
parsedMessage = JSON.parse(message);
} catch (e) {
console.error("Erreur d'analyse JSON:", e);
return;
}
if (topic === 'game/score' && parsedMessage.TEAM) {
scores.RedTotalScore = parsedMessage.TEAM.Red.TotalScore
scores.BlueTotalScore = parsedMessage.TEAM.Blue.TotalScore
scores.YellowTotalScore = parsedMessage.TEAM.Yellow.TotalScore
scores.GreenTotalScore = parsedMessage.TEAM.Green.TotalScore
scores.RedRoundScore = parsedMessage.TEAM.Red.RoundScore
scores.BlueRoundScore = parsedMessage.TEAM.Blue.RoundScore
scores.YellowRoundScore = parsedMessage.TEAM.Yellow.RoundScore
scores.GreenRoundScore = parsedMessage.TEAM.Green.RoundScore
}
if (topic === 'game/timer' && parsedMessage.time !== undefined) {
timerDisplay.value = formatTime(parsedMessage.time);
}
}
function subscribeToTopic(topic, callback) {
if(client) {
client.subscribe(topic)
client.on('message', (receivedTopic, message) => { callback(receivedTopic.toString(), message.toString())
})
}
}
onMounted(() => {
client = mqtt.connect(mqttBrokerUrl)
subscribeToTopic('game/score', (topic, message) => {
handleMessage(topic, message);
});
subscribeToTopic('game/timer', (topic, message) => {
handleMessage(topic, message);
});
// Request score refresh
client.publish('game/score/request', '{}');
subscribeToTopic('vulture/buzzer/status', (topic, message) => {
try {
const data = JSON.parse(message);
if (data.status === 'blocked') {
const color = data.color || '';
const team = identifyTeamByColor(color);
activeTeam.value = team;
// Trigger flash effect
if (team) {
flashingTeam.value = team;
setTimeout(() => {
if (flashingTeam.value === team) {
flashingTeam.value = null;
}
}, 2000);
}
} else if (data.status === 'unblocked') {
activeTeam.value = null;
flashingTeam.value = null;
}
} catch (e) {
console.error('Error parsing buzzer status', e);
}
});
});
const activeTeam = ref(null);
const flashingTeam = ref(null);
function identifyTeamByColor(hexColor) {
if (!hexColor) return null;
const color = hexColor.replace('#', '').toUpperCase();
// RED
if (['FF0000', 'D42828'].includes(color)) return 'Red';
// BLUE
if (['0000FF', '2867D4'].includes(color)) return 'Blue';
// GREEN
if (['00FF00', '28D42E'].includes(color)) return 'Green';
// YELLOW
if (['FFFF00', 'D4D100'].includes(color)) return 'Yellow';
// Fallback
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
if (r > 200 && g > 200 && b < 100) return 'Yellow';
if (g > r && g > b) return 'Green';
if (b > r && b > g) return 'Blue';
if (r > g && r > b) return 'Red';
return null;
}
function getDimClass(team) {
if (!activeTeam.value) return '';
return activeTeam.value !== team ? 'dimmed-score' : '';
}
function getFlashClass(team) {
return flashingTeam.value === team ? 'flashing-glow' : '';
}
onUnmounted(() => {
if (client) {
client.end()
}
})
</script>
<style scoped>
.score-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 100vw;
height: 100vh;
padding: 40px;
gap: 60px;
position: relative;
font-family: 'Bahnschrift', sans-serif;
}
.score-cell {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: transform 0.2s;
overflow: hidden;
}
.cell-blue {
border-radius: 65px 225px 75px 225px !important;
}
.cell-red {
border-radius: 225px 65px 225px 75px !important;
}
.cell-green {
border-radius: 225px 65px 225px 75px !important;
}
.cell-yellow {
border-radius: 75px 225px 65px 225px !important;
}
.score-content {
display: flex;
flex-direction: row; /* Horizontal layout */
align-items: center;
justify-content: space-around; /* Spread out */
width: 100%;
padding: 0 40px;
}
.score-main {
flex: 2; /* Takes more space */
display: flex;
justify-content: center;
align-items: center;
}
.score-info {
flex: 1; /* Takes less space */
display: flex;
flex-direction: column;
justify-content: center;
}
.info-right {
align-items: flex-end;
text-align: right;
}
.info-left {
align-items: flex-start;
text-align: left;
}
.team-name {
font-size: 2.2rem;
font-weight: 900;
letter-spacing: 2px;
opacity: 0.9;
color: rgba(255, 255, 255, 0.95);
text-transform: uppercase;
margin-bottom: 15px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.team-score {
font-weight: bold;
color: white;
line-height: 1;
text-shadow: 4px 4px 8px rgba(0,0,0,0.4);
}
.main-score {
font-size: 10rem; /* Even larger */
margin: 0;
}
.sub-score-container {
background-color: rgba(0, 0, 0, 0.2);
padding: 10px 20px;
border-radius: 20px;
display: flex;
flex-direction: column;
min-width: 120px;
}
.sub-right {
align-items: flex-end;
}
.sub-left {
align-items: flex-start;
}
.sub-label {
font-size: 1rem;
text-transform: uppercase;
opacity: 0.8;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2px;
font-weight: 600;
}
.sub-score {
font-size: 2.5rem;
}
.timer-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
background-color: #1a1a1a;
padding: 20px 40px;
border-radius: 80px;
border: 6px solid #333;
box-shadow: 0 0 50px rgba(0,0,0,0.8);
}
.timer-display {
font-size: 8rem;
font-weight: bold;
color: white;
font-family: monospace;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.2);
}
.color-blue {
background: linear-gradient(135deg, rgb(var(--v-theme-BlueBuzzer)), #1a3a5a);
}
.color-red {
background: linear-gradient(135deg, rgb(var(--v-theme-RedBuzzer)), #5a1a1a);
}
.color-green {
background: linear-gradient(135deg, rgb(var(--v-theme-GreenBuzzer)), #1a5a2a);
}
.color-yellow {
background: linear-gradient(135deg, rgb(var(--v-theme-YellowBuzzer)), #5a5a1a);
}
/* Score Pop Animation */
.score-pop-enter-active {
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.score-pop-leave-active {
animation: pop-out 0.2s ease-in;
}
@keyframes pop-in {
0% {
transform: scale(0.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes pop-out {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.dimmed-score {
opacity: 0.3;
transform: scale(0.95);
filter: grayscale(100%);
transition: all 0.3s ease;
}
@keyframes flash-glow {
0%, 100% { box-shadow: 0 0 0 rgba(255, 255, 255, 0); }
10% { box-shadow: 0 0 60px 20px rgba(255, 255, 255, 0.9); }
}
.flashing-glow {
animation: flash-glow 0.5s ease-in-out infinite;
z-index: 10;
position: relative;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<v-container>
<SessionSelector
v-model="selectedSessionId"
:sessions="availableSessions"
:saving="saving"
@update:model-value="loadSession"
@create="openCreateDialog"
@delete="deleteDialogVisible = true"
@save="saveSession"
/>
<div v-if="!selectedSessionId" class="text-center mt-10 text-h5 grey--text">
Veuillez sélectionner une session pour commencer.
</div>
<div v-else>
<SessionDetails
:config="sessionConfig"
:session-id="selectedSessionId"
:api-url="API_URL"
:success="success"
:error="error"
@delete-media="deleteMediaFile"
@rename-media="renameMediaFile"
/>
</div>
<!-- Dialog Création de session -->
<v-dialog v-model="createDialogVisible" persistent max-width="500px">
<v-card rounded="xl">
<v-card-title class="text-h5">Nouvelle Session</v-card-title>
<v-card-text>
<v-text-field
v-model="newSessionName"
label="Nom de la session"
variant="outlined"
autofocus
@keyup.enter="createSession"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="createDialogVisible = false">Annuler</v-btn>
<v-btn color="green" variant="elevated" @click="createSession" :disabled="!newSessionName">Créer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog Suppression de session -->
<v-dialog v-model="deleteDialogVisible" max-width="400px">
<v-card rounded="xl">
<v-card-title class="text-h5 delete-dialog-title-style">Supprimer la session ?</v-card-title>
<v-card-text>
Cette action est irréversible ! Tous les fichiers de cette session seront supprimés.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn rounded="xl" class="mb-3" color="grey" variant="text" @click="deleteDialogVisible = false">Annuler</v-btn>
<v-btn rounded="xl" class="mb-3 mr-3" color="red" variant="elevated" @click="confirmDeleteSession">Supprimer</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import SessionSelector from '@/components/SessionSelector.vue';
import SessionDetails from '@/components/SessionDetails.vue';
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import mqtt from 'mqtt';
import config from '@/config.js';
// --- État de l'Application ---
// Liste des sessions disponibles récupérées via MQTT
const availableSessions = ref([]);
// ID de la session actuellement sélectionnée
const selectedSessionId = ref(null);
// Configuration de la session active (Lié au formulaire)
const sessionConfig = reactive({
PackId: '',
PackTitle: '',
Questions: []
});
// --- Gestion des Dialogues et États de Chargement ---
// Création de session
const createDialogVisible = ref(false);
const newSessionName = ref('');
// Suppression de session
const deleteDialogVisible = ref(false);
// États UI
const saving = ref(false);
const error = ref('');
const success = ref('');
// --- Configuration MQTT & API ---
const mqttBrokerUrl = config.mqttBrokerUrl;
// URL API (Backend Express) définie dans config.js
const API_URL = config.apiUrl;
let client = null;
// Topics MQTT utilisés pour la communication avec le backend
const topics = config.topics;
// --- Cycle de Vie du Composant ---
onMounted(() => {
// Connexion au broker MQTT
client = mqtt.connect(mqttBrokerUrl);
client.on('connect', () => {
console.log('SessionEditor: Connecté à MQTT');
client.subscribe(topics.responseList);
client.subscribe(topics.getConfig);
// Demande initiale de la liste des sessions
client.publish(topics.requestList, '{}');
});
client.on('message', (topic, message) => {
if (topic === topics.responseList) {
try {
// Mise à jour de la liste des sessions
availableSessions.value = JSON.parse(message.toString());
console.log("Sessions reçues:", availableSessions.value);
} catch (e) { console.error(e); }
}
else if (topic === topics.getConfig) {
try {
// Chargement de la configuration de la session reçue
const data = JSON.parse(message.toString());
// Réinitialisation et peuplement de sessionConfig
sessionConfig.Questions = [];
// Ajout d'une clé UI unique pour chaque question (pour les animations de liste)
if (Array.isArray(data.Questions)) {
data.Questions.forEach(q => {
if (!q._ui_key) {
Object.defineProperty(q, '_ui_key', {
value: `q-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
writable: true,
enumerable: false,
configurable: true
});
}
});
}
// Fusion des données reçues dans l'objet réactif
Object.assign(sessionConfig, data);
console.log('Configuration de session chargée');
} catch (e) {
console.error('Erreur parsing config session', e);
error.value = "Erreur lors du chargement de la configuration.";
}
}
});
});
onUnmounted(() => {
if (client) client.end();
});
// --- Méthodes Métier ---
// Charge la configuration de la session sélectionnée
function loadSession() {
if (!selectedSessionId.value) return;
console.log("Chargement session:", selectedSessionId.value);
client.publish(topics.requestConfig, JSON.stringify({ SessionId: selectedSessionId.value }));
}
// Envoie une requête pour supprimer un fichier média spécifique
function deleteMediaFile(mediaPath) {
try {
const payload = JSON.stringify({
SessionId: selectedSessionId.value,
MediaPath: mediaPath
});
client.publish(topics.deleteMedia, payload);
console.log("Requête suppression média envoyée:", payload);
} catch (e) {
console.error("Erreur suppression média", e);
}
}
// Envoie une requête pour renommer un fichier média
function renameMediaFile({ oldPath, newName }) {
try {
const payload = JSON.stringify({
SessionId: selectedSessionId.value,
OldPath: oldPath,
NewName: newName
});
client.publish(topics.renameMedia, payload);
console.log("Requête renommage média envoyée:", payload);
} catch (e) {
console.error("Erreur renommage média", e);
}
}
// Ouvre le dialogue de création
function openCreateDialog() {
newSessionName.value = '';
createDialogVisible.value = true;
}
// Crée une nouvelle session via MQTT
function createSession() {
if (!newSessionName.value) return;
console.log("Création session:", newSessionName.value);
try {
const payload = JSON.stringify({ SessionName: newSessionName.value });
client.publish(topics.createSession, payload);
success.value = "Demande de création envoyée...";
setTimeout(() => success.value = '', 3000);
createDialogVisible.value = false;
} catch (e) {
console.error("Erreur création session", e);
error.value = "Erreur lors de la création.";
}
}
// Supprime la session actuellement sélectionnée
function confirmDeleteSession() {
if (!selectedSessionId.value) return;
console.log("Suppression session:", selectedSessionId.value);
try {
const payload = JSON.stringify({ SessionId: selectedSessionId.value });
client.publish(topics.deleteSession, payload);
success.value = "Demande de suppression envoyée...";
setTimeout(() => success.value = '', 3000);
deleteDialogVisible.value = false;
selectedSessionId.value = null;
sessionConfig.Questions = [];
sessionConfig.PackId = '';
sessionConfig.PackTitle = '';
} catch (e) {
console.error("Erreur suppression session", e);
error.value = "Erreur lors de la suppression.";
}
}
// Sauvegarde la configuration actuelle via MQTT
function saveSession() {
if (!selectedSessionId.value) return;
saving.value = true;
success.value = '';
error.value = '';
try {
const payload = JSON.stringify({
SessionId: selectedSessionId.value,
Config: JSON.parse(JSON.stringify(sessionConfig, (key, value) => {
// Filter out keys starting with underscore (internal UI state)
if (key.startsWith('_')) return undefined;
// Filter out cache busting querystring
if (key === 'MediaUrl' && typeof value === 'string') {
return value.split('?')[0];
}
return value;
}))
});
client.publish(topics.updateConfig, payload);
success.value = "Sauvegarde envoyée.";
setTimeout(() => success.value = '', 3000);
} catch (e) {
error.value = "Erreur lors de la sauvegarde : " + e.message;
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
.delete-dialog-title-style {
color: rgb(var(--v-theme-primary)) !important;
padding-left: 6%;
padding-top: 3%;
}
.text-title-style {
color: rgb(var(--v-theme-primary), 1) !important;
opacity: 1;
font-size: 20px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,60 @@
# NanoMQ Configuration 0.18.0
# #============================================================
# # NanoMQ Broker
# #============================================================
mqtt {
property_size = 32
max_packet_size = 260MB
max_mqueue_len = 2048
retry_interval = 10s
keepalive_multiplier = 1.25
# Three of below, unsupported now
max_inflight_window = 2048
max_awaiting_rel = 10s
await_rel_timeout = 10s
}
listeners.tcp {
bind = "0.0.0.0:1883"
}
listeners.ws {
bind = "0.0.0.0:9001"
}
http_server {
port = 8081
limit_conn = 2
username = admin
password = public
auth_type = basic
jwt {
public.keyfile = "/etc/certs/jwt/jwtRS256.key.pub"
}
}
log {
# to = [file, console]
to = [console]
level = warn
dir = "/tmp"
file = "nanomq.log"
rotation {
size = 10MB
count = 5
}
}
auth {
allow_anonymous = true
no_match = allow
deny_action = ignore
cache = {
max_size = 32
ttl = 1m
}
}

107
VContainers/README.md Normal file
View File

@@ -0,0 +1,107 @@
# VContainer - Vulture build script
Construction et lancements des containers.
Toutes les commandes sont à taper depuis la racine du dépôt.
## Upgrade :
```bash
git pull
podmane-compose build
systemctl --user restart vulture-stack.service
pkill -u vulture cage
```
## Build
```bash
podman-compose build
```
## Run
`podman-compose up -d`
## Stop
`podman-compose down`
## Installation
### Automatisation au boot (User Mode)
#### Étape A : Activer la persistance de l'utilisateur
Par défaut, Fedora tue les processus utilisateurs à la déconnexion. On active le "lingering" pour que vos containers tournent dès le boot :
```bash
sudo loginctl enable-linger $USER
```
#### Étape B : Créer l'unité Systemd
Créez le dossier pour les services utilisateurs : `mkdir -p ~/.config/systemd/user/`
Créez le fichier ~/.config/systemd/user/vulture-stack.service :
```TOML
[Unit]
Description=Vulture Project Stack (Podman Compose)
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/Vulture
# Lancement au boot
ExecStart=/usr/bin/podman-compose up
# Arrêt propre
ExecStop=/usr/bin/podman-compose down
Restart=always
[Install]
WantedBy=default.target
```
#### Étape C : Activer le service
```bash
systemctl --user daemon-reload
systemctl --user enable vulture-stack.service
systemctl --user start vulture-stack.service
```
## Surveillance des Containers (Backend)
Puisque la stack tourne en mode utilisateur via Systemd, les commandes standard doivent être préfixées par `--user`.
* **Vérifier l'état de la stack :**
```bash
systemctl --user status vulture-stack.service
```
* **Consulter les logs en temps réel (équivalent `tail -f`) :**
```bash
journalctl --user -u vulture-stack.service -f
```
* **Redémarrer proprement toute la stack :**
```bash
systemctl --user restart vulture-stack.service
```
* **Lister les containers actifs :**
```bash
podman ps
```
## Tip
Pour permettre à Podman d'utiliser les ports privilégiés (<1024) :
```bash
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
```

View File

@@ -0,0 +1,16 @@
# Development Container for VApp
FROM docker.io/node:lts-alpine
WORKDIR /app
# Copy VApp source code
COPY VApp ./
# Install dependencies
RUN npm install
# Expose Vite default port
EXPOSE 5173
# Start in development mode with host exposure
CMD ["npm", "run", "dev", "--", "--host"]

View File

@@ -0,0 +1,11 @@
window.APP_CONFIG = {
// URL du broker MQTT (WebSockets)
// Configuration DEV : localhost
mqttBrokerUrl: 'ws://localhost:9001',
// IPs des buzzers
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43'
};

View File

@@ -0,0 +1,11 @@
window.APP_CONFIG = {
// URL du broker MQTT (WebSockets)
// Configuration PROD : IP du serveur
mqttBrokerUrl: 'ws://192.168.73.252:9001',
// IPs des buzzers
redBuzzerIP: '192.168.73.40',
blueBuzzerIP: '192.168.73.41',
orangeBuzzerIP: '192.168.73.42',
greenBuzzerIP: '192.168.73.43'
};

View File

@@ -0,0 +1,24 @@
server {
listen 80;
root /usr/share/nginx/html; # Chemin où les fichiers statiques sont copiés
index index.html index.htm; # Fichier par défaut à servir
location / {
try_files $uri $uri/ /index.html; # Pour les applications SPA (Single Page Applications)
# cela redirige toutes les requêtes non trouvées vers index.html
}
# Cache Control pour les fichiers statiques (optionnel mais bonne pratique)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Gzip compression (optionnel, améliore la performance)
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

View File

@@ -0,0 +1,12 @@
FROM docker.io/keymetrics/pm2:latest-alpine
## Bundle APP files
COPY VNode src
#COPY package.json .
COPY VContainers/VNode/pm2.json .
#
## Install app dependencies
RUN cd /src && npm install
CMD [ "pm2-runtime", "start", "pm2.json" ]
#CMD [ "sh"]

View File

@@ -0,0 +1,44 @@
[
{
"name": "buzzer-manager",
"script": "services/buzzer/buzzer-manager.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
}
},
{
"name": "buzzer-watcher",
"script": "services/buzzer/buzzer-watcher.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} },
{
"name": "quizz-collector",
"script": "services/game/quizz-collector.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} },
{
"name": "score-manager",
"script": "services/game/score-manager.js",
"cwd": "src",
"env": {
"NODE_ENV": "production"
},
"watch": false
},
{
"name": "light-manager",
"script": "services/light/light-manager.js",
"cwd": "/src",
"watch": false,
"env": {
"NODE_ENV": "production"
} }
]

13
VContainers/build.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
# Move to repository root
cd "$(dirname "$0")/.."
echo "Building VNode..."
podman build . -f ./VContainers/VNode/Containerfile -t vnode
echo "Building VApp..."
podman build . -f ./VContainers/VApp/Containerfile -t vapp
echo "Build complete."

9
VContainers/upgrade.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
# Move to repository root
cd "$(dirname "$0")/.."
git pull
./VContainers/build.sh
systemctl --user restart vulture-stack

View File

@@ -0,0 +1,56 @@
# Documentation Déploiement Kiosque - Tableau de Score
Ce document décrit la configuration du serveur Fedora pour lancer automatiquement Google Chrome en mode plein écran au démarrage via un compositeur Wayland minimaliste (Cage).
## 1. Installation des dépendances
```bash
sudo dnf install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
sudo dnf install -y cage
```
## 2. Configuration de l'Autologin (Systemd)
Créer le fichier d'override pour que le serveur se connecte seul sur le TTY1 :
`sudo systemctl edit getty@tty1.service`
Coller le contenu suivant :
```ini
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin vulture --noclear %I $TERM
```
## 3. Configuration Zsh (`~/.zlogin`)
Ajouter ces lignes à la fin de votre fichier `~/.zlogin` pour déclencher l'affichage uniquement sur le port HDMI physique (TTY1) :
```zsh
# Empêcher la mise en veille de l'écran
setterm --blank 0 --powersave off --powerdown 0
if [[ -z "$DISPLAY" && "$XDG_VTNR" -eq 1 ]]; then
export MOZ_ENABLE_WAYLAND=1
export XDG_SESSION_TYPE=wayland
# Lancement du script de monitoring
exec ~/Vulture/VHard/vulturesrv/kiosk-waiter.sh
fi
```
## 4. Debug et Commandes utiles
* **Relancer le navigateur à distance (SSH) :**
`pkill -u $USER cage` (Le script de boucle le relancera instantanément).
* **Vérifier les logs :**
`journalctl -u getty@tty1.service`
* **Forcer l'arrêt :**
Supprimer temporairement l'appel dans `~/.zlogin` ou tuer le script `kiosk-waiter.sh`.
---
*Note : Si vous utilisez Podman pour le reste du projet (Vulture), ce setup "Bare Metal" pour l'affichage garantit une latence minimale pour les animations du tableau de score.*

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# kiosk-waiter.sh
URL="http://localhost:5173/" # URL locale de vapp
SERVICE_NAME="vulture-stack.service"
echo "Attente du démarrage de la stack Vulture..."
# 1. Attente que le service Systemd soit considéré comme actif
while [[ $(systemctl --user is-active $SERVICE_NAME) != "active" ]]; do
sleep 1
done
# 2. Attente que le serveur HTTP réponde (Healthy)
# On boucle tant que le code de retour HTTP n'est pas 200
until $(curl --output /dev/null --silent --head --fail $URL); do
echo "Le quizz n'est pas encore prêt... attente (2s)"
sleep 2
done
echo "Stack Vulture détectée et saine. Lancement du kiosque."
# 3. Boucle de lancement de Chrome
while true; do
cage -- google-chrome-stable \
--kiosk \
--no-first-run \
--password-store=basic \
--ozone-platform=wayland \
--autoplay-policy=no-user-gesture-required \
--disable-component-update \
"$URL"
echo "Chrome s'est arrêté. Relancement dans 2 secondes..."
sleep 2
done

1175
VNode/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
{ {
"dependencies": { "dependencies": {
"mqtt": "^5.10.1", "cors": "^2.8.6",
"express": "^5.2.1",
"mqtt": "^5.14.1",
"multer": "^2.0.2",
"ping": "^0.4.4", "ping": "^0.4.4",
"ws": "^8.18.0" "ws": "^8.18.0"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,27 +0,0 @@
name: "Histoire & Géographie"
questions:
1:
Q: "Quelle bataille célèbre s'est déroulée en 1815, marquant la défaite de Napoléon Bonaparte ?"
T: "Elle a eu lieu en Belgique"
R: "La bataille de Waterloo."
P: "Q-1.jpeg"
2:
Q: "Quelle est la capitale de l'Australie ?"
T: "Le nom de cette ville commence par la lettre 'C'"
R: "Canberra."
P: "Q-2.jpeg"
3:
Q: "En quelle année la Seconde Guerre mondiale a-t-elle pris fin ?"
T: "C'est au milieu des années 40."
R: "En 1945."
P: "Q-3.jpeg"
4:
Q: "Quel fleuve traverse la ville du Caire en Égypte ?"
T: "C'est l'un des plus longs fleuves du monde"
R: "Le Nil."
P: "Q-4.jpeg"
5:
Q: "Quel pays a été divisé par un mur de 1961 à 1989 ?"
T: "Sa chute a marqué la fin de la guerre froide."
R: "L'Allemagne (le mur de Berlin)."
P: "Q-5.jpeg"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

View File

@@ -1,27 +0,0 @@
name: "Jeux vidéos"
questions:
1:
Q: "Quel personnage de jeu vidéo est un plombier moustachu qui saute sur des ennemis pour sauver une princesse ?"
T: "Cest le personnage le plus célèbre de Nintendo, son nom commence par 'M'."
R: "Mario."
P: "Q-1.jpeg"
2:
Q: "Quel jeu vidéo multijoueur de football avec des voitures est très populaire ?"
T: "Il s'agit d'un mélange de sport et de voitures rapides."
R: "Rocket League."
P: "Q-2.jpeg"
3:
Q: "Quel jeu vidéo mobile consiste à faire exploser des bonbons en alignant trois pièces identiques ?"
T: "Son nom fait référence aux bonbons."
R: "Candy Crush Saga."
P: "Q-3.jpeg"
4:
Q: "Quel est le nom du célèbre personnage bleu de SEGA qui court à une vitesse incroyable ?"
T: "Son nom commence par la lettre 'S' et c'est un hérisson."
R: "Sonic"
P: "Q-4.jpeg"
5:
Q: "Quel jeu permet de construire et explorer un monde fait de blocs, tout en survivant face à des monstres ?"
T: "Le monde est entièrement fait de blocs carrés."
R: "Minecraft."
P: "Q-5.jpeg"

View File

@@ -1,161 +0,0 @@
// Import necessary modules
const mqtt = require('mqtt');
// MQTT broker configuration
const brokerUrl = 'mqtt://localhost'; // Broker URL (change if needed)
const options = {
clientId: 'test_buzzer_manager',
clean: true
};
// Set up MQTT client
const client = mqtt.connect(brokerUrl, options);
// Variables for tracking test results
let testResults = {
buzzerActivity: false,
confirmationReceived: false,
statusBlocked: false,
statusUnblocked: false,
tiltAddConfirmed: false,
tiltRemoveConfirmed: false,
tiltIgnored: false,
unlockConfirmation: false,
tiltUpdateAdd: false,
tiltUpdateRemove: false
};
// Subscribe to topics to capture the responses from the buzzer manager
client.on('connect', () => {
console.log('[INFO] Connected to MQTT broker for testing');
// Subscribe to all topics related to the buzzer manager
client.subscribe('vulture/buzzer/#', (err) => {
if (err) console.error('[ERROR] Failed to subscribe to topics for testing');
else console.log('[INFO] Subscribed to topics successfully');
});
// Run the test sequence after a short delay
setTimeout(runTestSequence, 500);
});
// Capture and process incoming MQTT messages
client.on('message', (topic, message) => {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Message received on ${topic}: ${message.toString()}`);
// Track the test results based on the topics and payloads
if (topic.startsWith('vulture/buzzer/activity') && payload.buzzer_id === 1) {
testResults.buzzerActivity = true;
}
if (topic.startsWith(`vulture/buzzer/confirmation/1`) && payload.status === "received") {
testResults.confirmationReceived = true;
}
if (topic === 'vulture/buzzer/status' && payload.status === "blocked") {
testResults.statusBlocked = true;
}
if (topic === 'vulture/buzzer/status' && payload.status === "unblocked") {
testResults.statusUnblocked = true;
}
if (topic.startsWith(`vulture/buzzer/tilt/confirmation/2`) && payload.status === "received" && payload.action === "add") {
testResults.tiltAddConfirmed = true;
}
if (topic.startsWith(`vulture/buzzer/tilt/confirmation/2`) && payload.status === "received" && payload.action === "remove") {
testResults.tiltRemoveConfirmed = true;
}
if (topic === `vulture/buzzer/tilt/ignored/2` && payload.status === "tilt_ignored") {
testResults.tiltIgnored = true;
}
if (topic === 'vulture/buzzer/status' && payload.status === "tilt_update") {
// Check for tilt update with added buzzer
if (payload.tilt_buzzers.includes(2) && payload.message.includes("added")) {
testResults.tiltUpdateAdd = true;
}
// Check for tilt update with removed buzzer
if (!payload.tilt_buzzers.includes(2) && payload.message.includes("removed")) {
testResults.tiltUpdateRemove = true;
}
}
if (topic === 'vulture/buzzer/unlock/confirmation' && payload.status === "received") {
testResults.unlockConfirmation = true;
}
});
// Function to run the complete test sequence
function runTestSequence() {
console.log('[INFO] Starting test sequence...');
// 1. Simulate a buzzer press (buzzer 1, color red)
console.log('[TEST] Simulating buzzer press (ID 1, color #FF0000)...');
client.publish('vulture/buzzer/pressed/1', JSON.stringify({
buzzer_id: 1,
color: "#FF0000"
}));
// 2. Simulate a second buzzer press (buzzer 2, color blue) to check blocking
setTimeout(() => {
console.log('[TEST] Simulating second buzzer press (ID 2, color #0000FF)...');
client.publish('vulture/buzzer/pressed/2', JSON.stringify({
buzzer_id: 2,
color: "#0000FF"
}));
}, 1000);
// 3. Simulate adding a buzzer to tilt mode (buzzer 2)
setTimeout(() => {
console.log('[TEST] Adding buzzer ID 2 to tilt mode...');
client.publish('vulture/buzzer/tilt', JSON.stringify({
buzzer_id: 2,
status: "add"
}));
}, 1500);
// 4. Simulate pressing a buzzer in tilt mode (should be ignored)
setTimeout(() => {
console.log('[TEST] Simulating tilt buzzer press (ID 2, color #0000FF)...');
client.publish('vulture/buzzer/pressed/2', JSON.stringify({
buzzer_id: 2,
color: "#0000FF"
}));
}, 2000);
// 5. Remove tilt mode from buzzer 2
setTimeout(() => {
console.log('[TEST] Removing tilt mode for buzzer ID 2...');
client.publish('vulture/buzzer/tilt', JSON.stringify({
buzzer_id: 2,
status: "remove"
}));
}, 2500);
// 6. Unlock buzzers to reset state
setTimeout(() => {
console.log('[TEST] Unlocking buzzers...');
client.publish('vulture/buzzer/unlock', '{}');
}, 3000);
// 7. Display results
setTimeout(() => {
console.log('[INFO] Test sequence complete. Results:');
console.log(`1. Buzzer activity detected for buzzer 1: ${testResults.buzzerActivity ? 'PASSED' : 'FAILED'}`);
console.log(`2. Confirmation received for buzzer 1: ${testResults.confirmationReceived ? 'PASSED' : 'FAILED'}`);
console.log(`3. Buzzer 1 status set to "blocked": ${testResults.statusBlocked ? 'PASSED' : 'FAILED'}`);
console.log(`4. Buzzer status set to "unblocked": ${testResults.statusUnblocked ? 'PASSED' : 'FAILED'}`);
console.log(`5. Tilt mode add confirmed for buzzer 2: ${testResults.tiltAddConfirmed ? 'PASSED' : 'FAILED'}`);
console.log(`6. Tilted buzzer press ignored: ${testResults.tiltIgnored ? 'PASSED' : 'FAILED'}`);
console.log(`7. Tilt status update sent (add): ${testResults.tiltUpdateAdd ? 'PASSED' : 'FAILED'}`);
console.log(`8. Tilt mode remove confirmed for buzzer 2: ${testResults.tiltRemoveConfirmed ? 'PASSED' : 'FAILED'}`);
console.log(`9. Tilt status update sent (remove): ${testResults.tiltUpdateRemove ? 'PASSED' : 'FAILED'}`);
console.log(`10. Unlock confirmation received: ${testResults.unlockConfirmation ? 'PASSED' : 'FAILED'}`);
client.end(); // End the MQTT connection
}, 4000);
}

View File

@@ -1,8 +1,14 @@
// Import necessary modules // Import necessary modules
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const fs = require('fs');
const path = require('path');
// Load configuration
const configPath = path.join(__dirname, '../config/configuration.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// MQTT broker configuration // MQTT broker configuration
const brokerUrl = 'mqtt://localhost'; // Broker URL (change if needed) const brokerUrl = config.mqttHost;
const clientId = 'buzzer_manager'; const clientId = 'buzzer_manager';
const options = { const options = {
clientId, clientId,
@@ -64,8 +70,7 @@ function sendTiltStatus(action, buzzerId) {
client.publish('vulture/buzzer/status', JSON.stringify({ client.publish('vulture/buzzer/status', JSON.stringify({
status: "tilt_update", status: "tilt_update",
tilt_buzzers: tiltList, tilt_buzzers: tiltList,
message: `Buzzer ID ${buzzerId} ${action} to tilt mode`, message: `Buzzer ID ${buzzerId} ${action} to tilt mode`
timestamp: new Date().toISOString()
})); }));
console.log(`[INFO] Tilt status updated: ${tiltList.length} buzzers in tilt mode`); console.log(`[INFO] Tilt status updated: ${tiltList.length} buzzers in tilt mode`);
@@ -106,8 +111,7 @@ client.on('message', (topic, message) => {
status: "received", status: "received",
action: status, action: status,
buzzer_id: buzzer_id, buzzer_id: buzzer_id,
message: `Tilt command '${status}' received for buzzer ID ${buzzer_id}`, message: `Tilt command '${status}' received for buzzer ID ${buzzer_id}`
timestamp: new Date().toISOString()
})); }));
// Send the updated tilt status to all components // Send the updated tilt status to all components
@@ -131,8 +135,7 @@ client.on('message', (topic, message) => {
client.publish(`vulture/buzzer/confirmation/${buzzerId}`, JSON.stringify({ client.publish(`vulture/buzzer/confirmation/${buzzerId}`, JSON.stringify({
status: "received", status: "received",
buzzer_id: buzzerId, buzzer_id: buzzerId,
message: `Buzzer ID ${buzzerId} received (Color: ${color})`, message: `Buzzer ID ${buzzerId} received (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
// Ignore if the buzzer is in tilt mode, but notify this event // Ignore if the buzzer is in tilt mode, but notify this event
@@ -143,8 +146,7 @@ client.on('message', (topic, message) => {
client.publish(`vulture/buzzer/tilt/ignored/${buzzerId}`, JSON.stringify({ client.publish(`vulture/buzzer/tilt/ignored/${buzzerId}`, JSON.stringify({
status: "tilt_ignored", status: "tilt_ignored",
buzzer_id: buzzerId, buzzer_id: buzzerId,
message: `Buzzer ID ${buzzerId} is in tilt mode and ignored.`, message: `Buzzer ID ${buzzerId} is in tilt`
timestamp: new Date().toISOString()
})); }));
return; return;
} }
@@ -154,8 +156,7 @@ client.on('message', (topic, message) => {
buzzer_id: buzzerId, buzzer_id: buzzerId,
color: color, color: color,
status: buzzerActive ? "blocked" : "free", status: buzzerActive ? "blocked" : "free",
message: `Activity detected on buzzer ID ${buzzerId} (Color: ${color})`, message: `Activity detected on buzzer ID ${buzzerId} (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
if (!buzzerActive) { if (!buzzerActive) {
@@ -176,8 +177,7 @@ client.on('message', (topic, message) => {
status: "blocked", status: "blocked",
buzzer_id: buzzerId, buzzer_id: buzzerId,
color: color, color: color,
message: `Buzzer activated by ID ${buzzerId} (Color: ${color})`, message: `Buzzer activated by ID ${buzzerId} (Color: ${color})`
timestamp: new Date().toISOString()
})); }));
console.log(`[INFO] Buzzers blocked and notification sent`); console.log(`[INFO] Buzzers blocked and notification sent`);
@@ -188,19 +188,12 @@ client.on('message', (topic, message) => {
if (topic === 'vulture/buzzer/unlock') { if (topic === 'vulture/buzzer/unlock') {
console.log('[INFO] Buzzer unlock requested'); console.log('[INFO] Buzzer unlock requested');
// Confirm receipt of unlock command // Notify the light manager to change to the default color
client.publish('vulture/buzzer/unlock/confirmation', JSON.stringify({
status: "received",
message: "Buzzer unlock command received.",
timestamp: new Date().toISOString()
}));
// Notify the light manager to change to the team's color
client.publish('vulture/light/change', JSON.stringify({ client.publish('vulture/light/change', JSON.stringify({
color: "#FFFFFF", color: "#FF00FF",
effect: 'rainbow' effect: 'none'
})); }));
// Reset buzzer manager state // Reset buzzer manager state
buzzerActive = false; buzzerActive = false;
buzzerThatPressed = null; buzzerThatPressed = null;
@@ -208,8 +201,7 @@ client.on('message', (topic, message) => {
// Notify all components of buzzer unlock // Notify all components of buzzer unlock
client.publish('vulture/buzzer/status', JSON.stringify({ client.publish('vulture/buzzer/status', JSON.stringify({
status: "unblocked", status: "unblocked",
message: "Buzzers unblocked and ready for activation.", message: "Buzzers unblocked and ready for activation."
timestamp: new Date().toISOString()
})); }));
console.log('[INFO] Buzzers unblocked and notification sent'); console.log('[INFO] Buzzers unblocked and notification sent');

View File

@@ -1,18 +1,19 @@
const path = require('path');
const ping = require('ping'); const ping = require('ping');
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const fs = require('fs'); const fs = require('fs');
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_network.json', 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/configuration.json'), 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { hosts: { buzzers: { IP: buzzerIPs, MQTTconfig: { mqttHost, mqttTopic } } } } = config; const { mqttHost, hosts: { buzzers: { IP: buzzerIPs, MQTTconfig: { mqttTopic } } } } = config;
// Connexion au broker MQTT // Connexion au broker MQTT
const client = mqtt.connect(mqttHost); const client = mqtt.connect(mqttHost);
client.on('connect', () => { client.on('connect', () => {
console.log(`Connecté au broker MQTT à ${mqttHost}`); console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
// Fonction pour pinger les buzzers et publier l'état // Fonction pour pinger les buzzers et publier l'état
const pingAndPublish = async () => { const pingAndPublish = async () => {
@@ -23,9 +24,8 @@ client.on('connect', () => {
// Publication du statut dans le topic MQTT // Publication du statut dans le topic MQTT
client.publish(`${mqttTopic}`, JSON.stringify({ buzzer: buzzerName, ip, status })); client.publish(`${mqttTopic}`, JSON.stringify({ buzzer: buzzerName, ip, status }));
console.log(`Ping ${buzzerName} (${ip}) - Status: ${status}`);
} catch (error) { } catch (error) {
console.error(`Erreur avec le buzzer ${buzzerName} (${ip}):`, error.message); console.error(`[ERREUR] Erreur avec le buzzer ${buzzerName} (${ip}):`, error.message);
} }
} }
}; };
@@ -35,5 +35,5 @@ client.on('connect', () => {
}); });
client.on('error', (error) => { client.on('error', (error) => {
console.error('Erreur de connexion au broker MQTT:', error.message); console.error('[ERREUR] Erreur de connexion au broker MQTT:', error.message);
}); });

View File

@@ -1,18 +0,0 @@
{
"services": {
"mqttHost": "mqtt://192.168.1.30",
"score":{
"MQTTconfig":{
"mqttScoreTopic": "game/score",
"mqttScoreChangeTopic": "game/score/update"
}
},
"quizzcollector":{
"MQTTconfig":{
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
}
}
}
}

View File

@@ -1,17 +0,0 @@
{
"hosts": {
"buzzers":{
"IP":{
"redBuzzerIP": "8.8.8.6",
"blueBuzzerIP": "8.8.8.8",
"greenBuzzerIP": "8.8.8.8",
"yellowBuzzerIP": "8.8.8.8"
},
"MQTTconfig":{
"mqttHost": "mqtt://192.168.1.28",
"mqttTopic": "buzzer/watcher"
}
}
}
}

View File

@@ -0,0 +1,45 @@
{
"mqttHost": "mqtt://192.168.1.201",
"services": {
"score": {
"MQTTconfig": {
"mqttScoreTopic": "game/score",
"mqttScoreChangeTopic": "game/score/update",
"mqttScoreRequestTopic": "game/score/request"
}
},
"quizzcollector": {
"MQTTconfig": {
"mqttQuizzCollectorListTopic": "game/quizz-collector/list",
"mqttQuizzCollectorCmdTopic": "game/quizz-collector/cmd"
}
},
"session": {
"MQTTconfig": {
"mqttSessionRequestTopic": "game/session/config/request",
"mqttSessionGetTopic": "game/session/config/get",
"mqttSessionUpdateTopic": "game/session/config/update",
"mqttSessionListTopic": "game/session/list/request",
"mqttSessionListResponseTopic": "game/session/list/response",
"mqttSessionCreateTopic": "game/session/create",
"mqttSessionDeleteTopic": "game/session/delete",
"mqttSessionDeleteMediaTopic": "game/session/media/delete",
"mqttSessionRenameMediaTopic": "game/session/media/rename"
},
"httpPort": 3001
}
},
"hosts": {
"buzzers": {
"IP": {
"redBuzzerIP": "8.8.8.6",
"blueBuzzerIP": "8.8.8.8",
"greenBuzzerIP": "8.8.8.8",
"yellowBuzzerIP": "8.8.8.8"
},
"MQTTconfig": {
"mqttTopic": "buzzer/watcher"
}
}
}
}

View File

@@ -1,11 +1,12 @@
const fs = require('fs'); const fs = require('fs');
const mqtt = require('mqtt'); const mqtt = require('mqtt');
const path = require('path');
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_game.json', 'utf8')); const config = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/configuration.json'), 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { services: { mqttHost, quizzcollector: { MQTTconfig: { mqttQuizzCollectorListTopic, mqttQuizzCollectorCmdTopic } } } } = config; const { mqttHost, services: { quizzcollector: { MQTTconfig: { mqttQuizzCollectorListTopic, mqttQuizzCollectorCmdTopic } } } } = config;
// Configuration // Configuration
const folderPath = 'quizz'; // Remplace par le chemin de ton dossier const folderPath = 'quizz'; // Remplace par le chemin de ton dossier
@@ -14,19 +15,19 @@ const folderPath = 'quizz'; // Remplace par le chemin de ton dossier
const client = mqtt.connect(mqttHost); const client = mqtt.connect(mqttHost);
client.on('connect', () => { client.on('connect', () => {
console.log('Connecté au broker MQTT'); console.log('[INFO] Connecté au broker MQTT');
client.subscribe(mqttQuizzCollectorCmdTopic, (err) => { client.subscribe(mqttQuizzCollectorCmdTopic, (err) => {
if (err) { if (err) {
console.error("Erreur lors de l'abonnement au topic de commande:", err); console.error("[ERREUR] Erreur lors de l'abonnement au topic de commande:", err);
} else { } else {
console.log(`Abonné au topic ${mqttQuizzCollectorCmdTopic}`); console.log(`[INFO] Abonné au topic ${mqttQuizzCollectorCmdTopic}`);
} }
}); });
}); });
client.on('message', (topic, message) => { client.on('message', (topic, message) => {
if (topic === mqttQuizzCollectorCmdTopic) { if (topic === mqttQuizzCollectorCmdTopic) {
console.log('Commande reçue, lecture du dossier en cours...'); console.log('[INFO] Commande reçue, lecture du dossier en cours...');
Collect(); Collect();
} }
}); });
@@ -35,17 +36,17 @@ client.on('message', (topic, message) => {
function Collect() { function Collect() {
fs.readdir(folderPath, (err, files) => { fs.readdir(folderPath, (err, files) => {
if (err) { if (err) {
console.error('Erreur lors de la lecture du dossier:', err); console.error('[ERREUR] Erreur lors de la lecture du dossier:', err);
return; return;
} }
console.log('Dossiers trouvés:', files); console.log('[INFO] Dossiers trouvés:', files);
const message = JSON.stringify( files ); const message = JSON.stringify(files);
client.publish(mqttQuizzCollectorListTopic, message, { qos: 1 }, (err) => { client.publish(mqttQuizzCollectorListTopic, message, { qos: 1 }, (err) => {
if (err) { if (err) {
console.error('Erreur lors de la publication MQTT:', err); console.error('[ERREUR] Erreur lors de la publication MQTT:', err);
} else { } else {
console.log('Liste des fichiers publiée sur MQTT'); console.log('[INFO] Liste des fichiers publiée sur MQTT');
} }
}); });
}); });

View File

@@ -1,9 +1,9 @@
const args = process.argv; const args = process.argv;
// Vérification si un paramètre est passé, si c'est le cas c'est qu'on fournis un fichier de score pour reprendre le jeu la ou il était // Vérification si un paramètre est passé, si c'est le cas c'est qu'on fournis un fichier de score pour reprendre le jeu la ou il était
if (args[2] !== undefined){ if (args[2] !== undefined) {
global.ScoreFile = args[2] global.ScoreFile = args[2]
}else{ } else {
global.ScoreFile = "XXX.json" global.ScoreFile = "XXX.json"
} }
@@ -33,14 +33,14 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
// Le fichier existe, on le lit et on le parse en JSON // Le fichier existe, on le lit et on le parse en JSON
fs.readFile(filePath, 'utf8', (err, data) => { fs.readFile(filePath, 'utf8', (err, data) => {
if (err) { if (err) {
console.error("Erreur de lecture du fichier :", err); console.error("[ERREUR] Erreur de lecture du fichier :", err);
return; return;
} }
try { try {
global.jsonData = JSON.parse(data); global.jsonData = JSON.parse(data);
console.log("Propriétés importées depuis le fichier JSON :"); console.log("[INFO] Propriétés importées depuis le fichier JSON :");
} catch (parseErr) { } catch (parseErr) {
console.error("Erreur de parsing JSON :", parseErr); console.error("[ERREUR] Erreur de parsing JSON :", parseErr);
} }
}); });
} else { } else {
@@ -50,42 +50,42 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
const initialContent = { const initialContent = {
"TEAM": { "TEAM": {
"Red": { "Red": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Blue": { "Blue": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Yellow": { "Yellow": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
}, },
"Green": { "Green": {
"Name": "XXX", "Name": "XXX",
"TotalScore": 0, "TotalScore": 0,
"RoundScore": 0, "RoundScore": 0,
"Penality": 0, "Penality": 0,
"MasterPoint": 0 "MasterPoint": 0
} }
} }
}; };
fs.writeFile(newFilePath, JSON.stringify(initialContent, null, 2), (err) => { fs.writeFile(newFilePath, JSON.stringify(initialContent, null, 2), (err) => {
if (err) { if (err) {
console.error("Erreur de création du fichier :", err); console.error("[ERREUR] Erreur de création du fichier :", err);
return; return;
} }
console.log(`Fichier JSON créé avec succès : ${newFilePath}`); console.log(`[INFO] Fichier JSON créé avec succès : ${newFilePath}`);
// Mettre à jour ScoreFile et filePath // Mettre à jour ScoreFile et filePath
// Charger les données initiales si nécessaire // Charger les données initiales si nécessaire
@@ -99,134 +99,182 @@ fs.access(filePath, fs.constants.F_OK, (err) => {
function updateTeamTotalScore(teamColor, points) { function updateTeamTotalScore(teamColor, points) {
fs.readFile(filePath, 'utf8', (err, data) => { fs.readFile(filePath, 'utf8', (err, data) => {
if (err) { if (err) {
console.error("Erreur de lecture du fichier :", err); console.error("[ERREUR] Erreur de lecture du fichier :", err);
return; return;
} }
try { try {
const jsonData = JSON.parse(data); const jsonData = JSON.parse(data);
// Vérifier si l'équipe existe // Vérifier si l'équipe existe
if (!jsonData.TEAM.hasOwnProperty(teamColor)) { if (!jsonData.TEAM.hasOwnProperty(teamColor)) {
console.error(`L'équipe ${teamColor} n'existe pas.`); console.error(`[ERREUR] L'équipe ${teamColor} n'existe pas.`);
return; return;
} }
const change = parseInt(points, 10); const change = parseInt(points, 10);
// Mettre à jour le score // Mettre à jour le score
jsonData.TEAM[teamColor].TotalScore += points; jsonData.TEAM[teamColor].TotalScore += points;
console.log(`Le score total pour l'équipe ${teamColor} est de ${jsonData.TEAM[teamColor].TotalScore} points !`)
// Update global state
global.jsonData = jsonData;
console.log(`[INFO] Le score total pour l'équipe ${teamColor} est de ${jsonData.TEAM[teamColor].TotalScore} points !`)
// Enregistrer les modifications dans le fichier // Enregistrer les modifications dans le fichier
client.publish(mqttScoreTopic, JSON.stringify(jsonData)); client.publish(mqttScoreTopic, JSON.stringify(jsonData));
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => { fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
if (err) { if (err) {
console.error("Erreur lors de l'écriture du fichier :", err); console.error("[ERREUR] Erreur lors de l'écriture du fichier :", err);
} else { } else {
console.log(`Le score total de l'équipe ${teamColor} a été mis à jour avec succès dans le fichier json !`); console.log(`[INFO] Le score total de l'équipe ${teamColor} a été mis à jour avec succès dans le fichier json !`);
} }
}); });
} catch (parseErr) { } catch (parseErr) {
console.error("Erreur de parsing JSON :", parseErr); console.error("[ERREUR] Erreur de parsing JSON :", parseErr);
} }
}); });
} }
// Lecture du fichier de configuration // Lecture du fichier de configuration
const config = JSON.parse(fs.readFileSync('\services\\config\\config_game.json', 'utf8')); const configPath = path.join(__dirname, '../config/configuration.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Extraction des informations de config // Extraction des informations de config
const { services: { mqttHost, score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic } } } } = config; const { mqttHost, services: { score: { MQTTconfig: { mqttScoreTopic, mqttScoreChangeTopic, mqttScoreRequestTopic } } } } = config;
console.log(mqttScoreChangeTopic) console.log("------------------------------------------------------------------------------");
console.log("[CONFIG] Configuration chargée depuis :", configPath);
console.log("[CONFIG] Hôte MQTT :", mqttHost);
console.log("[CONFIG] Topics chargés :", mqttScoreTopic, mqttScoreChangeTopic, mqttScoreRequestTopic);
console.log("------------------------------------------------------------------------------");
// Connexion au broker MQTT // Connexion au broker MQTT
const client = mqtt.connect(mqttHost); const client = mqtt.connect(mqttHost);
client.on('connect', () => { client.on('connect', () => {
console.log(`Connecté au broker MQTT à ${mqttHost}`); console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
client.subscribe(mqttScoreChangeTopic, (err) => { client.subscribe(mqttScoreChangeTopic, (err) => {
if (err) console.error('[ERROR] impossible de souscrire au topic de gestion du score total'); if (err) console.error('[ERREUR] impossible de souscrire au topic de gestion du score total');
else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreChangeTopic}]`); else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreChangeTopic}`);
});
client.subscribe(mqttScoreRequestTopic, (err) => {
if (err) console.error('[ERREUR] impossible de souscrire au topic de demande de score');
else console.log(`[INFO] Souscription réalisée avec succès au topic ${mqttScoreRequestTopic}`);
}); });
}); });
// Gestion des messages entrants // Gestion des messages entrants
client.on('message', (topic, message) => { client.on('message', (topic, message) => {
// Gestion de la demande de score (REFRESH)
if (topic === mqttScoreRequestTopic) {
console.log(`[INFO] Demande de rafraîchissement des scores reçue sur ${topic}`);
if (global.jsonData) {
client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
console.log(`[INFO] Scores envoyés sur ${mqttScoreTopic}`);
} else {
console.warn("[INFO] Aucune donnée de score disponible pour le rafraîchissement");
}
return; // Fin du traitement pour ce message
}
let payload; let payload;
let process; let process;
let Team; let Team;
let Action; let Action;
let TotalScore = null;
let RoundScore = null;
try { try {
// Analyse du message reçu // Analyse du message reçu
payload = JSON.parse(message.toString()); payload = JSON.parse(message.toString());
} catch (e) { } catch (e) {
console.error(`[ERROR] Invalid JSON message received on topic ${topic}: ${message.toString()}`); console.error(`[ERREUR] Invalid JSON message received on topic ${topic}: ${message.toString()}`);
return; return;
} }
// Vérifie que le payload est bien un objet // Vérifie que le payload est bien un objet
if (payload && typeof payload === 'object') { if (payload && typeof payload === 'object') {
// Extraire la clé (la couleur) et la valeur associée // Extraire la clé (la couleur) et la valeur associée
Team = Object.keys(payload)[0]; // La première (et unique) clé Team = Object.keys(payload)[0]; // La première (et unique) clé
Action = payload[Team]; // La valeur associée let value = payload[Team]; // La valeur associée
//console.log(`Team: ${Team}, Action: ${Action}`);
if (typeof value === 'object') {
// Mode SET (valeur absolue)
if (value.hasOwnProperty('Total')) TotalScore = parseInt(value.Total, 10);
if (value.hasOwnProperty('Round')) RoundScore = parseInt(value.Round, 10);
Action = "SET";
} else {
// Mode ADD (relatif)
Action = value;
}
process = true; process = true;
} else { } else {
console.error(typeof payload); console.error(typeof payload);
process = false; process = false;
} }
if (process === true){ if (process === true) {
let currentScore = 0; if (Action === "SET") {
let change = 0 ; // Mise à jour absolue
switch (Team){ updateTeamScoreAbsolute(Team, TotalScore, RoundScore);
case "Red": } else {
change = parseInt(Action, 10); // Convertit 'action' en entier // Mise à jour relative (existant)
if (!isNaN(change)) { let change = parseInt(Action, 10);
updateTeamTotalScore("Red", change) if (!isNaN(change)) {
} else { updateTeamTotalScore(Team, change);
console.error(`Action invalide : ${action}`); } else {
} console.error(`[ERREUR] Action invalide : ${Action}`);
break; }
case "Blue":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Blue", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
case "Green":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Green", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
case "Yellow":
change = parseInt(Action, 10); // Convertit 'action' en entier
if (!isNaN(change)) {
updateTeamTotalScore("Yellow", change)
} else {
console.error(`Action invalide : ${action}`);
}
break;
} }
} }
}); });
// Fonction pour mettre à jour le score d'une équipe (Absolu)
function updateTeamScoreAbsolute(teamColor, totalScore, roundScore) {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error("[ERREUR] Erreur de lecture du fichier :", err);
return;
}
try {
const jsonData = JSON.parse(data);
if (!jsonData.TEAM.hasOwnProperty(teamColor)) {
console.error(`L'équipe ${teamColor} n'existe pas.`);
return;
}
if (totalScore !== null && !isNaN(totalScore)) {
jsonData.TEAM[teamColor].TotalScore = totalScore;
}
if (roundScore !== null && !isNaN(roundScore)) {
jsonData.TEAM[teamColor].RoundScore = roundScore;
}
console.log(`Mise à jour absolue pour ${teamColor} -> Total: ${jsonData.TEAM[teamColor].TotalScore}, Round: ${jsonData.TEAM[teamColor].RoundScore}`);
// Update global state
global.jsonData = jsonData;
client.publish(mqttScoreTopic, JSON.stringify(jsonData));
fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), (err) => {
if (err) console.error("[ERREUR] Erreur d'écriture :", err);
});
} catch (parseErr) {
console.error("[ERREUR] Erreur JSON :", parseErr);
}
});
}
(async () => { (async () => {
while (true) { while (true) {
console.log("Boucle en arrière-plan"); await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes
//client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
await new Promise((resolve) => setTimeout(resolve, 2000)); // Pause de 2 secondes
//client.publish(mqttScoreTopic, JSON.stringify(global.jsonData));
} }
})(); })();
client.on('error', (error) => { client.on('error', (error) => {
console.error('Erreur de connexion au broker MQTT:', error.message); console.error("[ERREUR] Erreur de connexion au broker MQTT:", error.message);
}); });

View File

@@ -0,0 +1,399 @@
const fs = require('fs');
const mqtt = require('mqtt');
const path = require('path');
const express = require('express');
const multer = require('multer');
const cors = require('cors');
// Lecture du fichier de configuration
const configPath = path.join(__dirname, '../config/configuration.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Extraction des informations de config
const {
mqttHost,
services: {
session: {
MQTTconfig: {
mqttSessionRequestTopic,
mqttSessionGetTopic,
mqttSessionUpdateTopic,
mqttSessionListTopic,
mqttSessionListResponseTopic,
mqttSessionCreateTopic,
mqttSessionDeleteTopic,
mqttSessionDeleteMediaTopic,
mqttSessionRenameMediaTopic
},
httpPort
}
}
} = config;
const PORT = httpPort || 3000;
const quizzDir = path.join(__dirname, 'quizz');
// Configurer Express
const app = express();
app.use(cors());
// Configuration de Multer pour l'upload
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const sessionId = req.params.sessionId;
let subfolder = 'assets';
// Organiser par type (envoyé par le frontend dans req.body.type)
if (req.body.type === 'video') subfolder = 'assets/videos';
else if (req.body.type === 'audio') subfolder = 'assets/audios';
else if (req.body.type === 'picture') subfolder = 'assets/pictures';
const uploadPath = path.join(quizzDir, sessionId, subfolder);
// Créer le dossier s'il n'existe pas
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: function (req, file, cb) {
// Utiliser l'ID de la question comme nom de fichier si disponible
const ext = path.extname(file.originalname);
let filename = 'file-' + Date.now() + '-' + Math.round(Math.random() * 1E9);
if (req.body.questionId) {
filename = req.body.questionId;
}
cb(null, filename + ext);
}
});
const upload = multer({ storage: storage });
// Route d'upload
app.post('/upload/:sessionId', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded.');
}
// Calculer le chemin relatif pour l'URL
// req.file.path est le chemin absolu sur le disque
// On veut le chemin relatif à partir du dossier de session
const sessionId = req.params.sessionId;
const sessionDir = path.join(quizzDir, sessionId);
// path.relative(from, to) -> donne le chemin relatif
let relativeId = path.relative(sessionDir, req.file.path);
// Remplacer les backslashes par des slashs pour les URLs web
relativeId = relativeId.replace(/\\/g, '/');
// Ajouter le slash initial
const relativePath = `/${relativeId}`;
res.json({ path: relativePath, fullPath: req.file.path });
});
// App.use pour servir les fichiers statiques
app.use('/quizz', express.static(quizzDir));
app.listen(PORT, () => {
console.log(`[HTTP] Serveur d'upload démarré sur le port ${PORT}`);
});
// Connexion au broker MQTT
const client = mqtt.connect(mqttHost);
console.log("------------------------------------------------------------------------------");
console.log("[CONFIG] Session Manager chargé (Multi-Session + Upload)");
console.log("[CONFIG] Hôte MQTT :", mqttHost);
console.log("[CONFIG] Port HTTP :", PORT);
console.log("[CONFIG] Dossier Quizz :", quizzDir);
console.log("------------------------------------------------------------------------------");
client.on('connect', () => {
console.log(`[INFO] Connecté au broker MQTT à ${mqttHost}`);
client.subscribe(mqttSessionRequestTopic);
client.subscribe(mqttSessionUpdateTopic);
client.subscribe(mqttSessionListTopic);
client.subscribe(mqttSessionCreateTopic);
client.subscribe(mqttSessionDeleteTopic);
client.subscribe(mqttSessionDeleteMediaTopic);
client.subscribe(mqttSessionRenameMediaTopic);
console.log(`[INFO] Abonné aux topics session`);
});
client.on('message', (topic, message) => {
if (topic === mqttSessionListTopic) {
console.log(`[INFO] Demande de liste de sessions`);
sendSessionList();
} else if (topic === mqttSessionRequestTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Demande de configuration pour session: ${payload.SessionId}`);
if (payload.SessionId) {
sendSessionConfiguration(payload.SessionId);
}
} catch (e) { console.error("Erreur payload request", e); }
} else if (topic === mqttSessionUpdateTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Mise à jour configuration pour session: ${payload.SessionId}`);
if (payload.SessionId && payload.Config) {
saveSessionConfiguration(payload.SessionId, payload.Config);
}
} catch (e) {
console.error('[ERREUR] Impossible de parser la mise à jour', e);
}
} else if (topic === mqttSessionCreateTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Demande de création de session: ${payload.SessionName}`);
if (payload.SessionName) {
createSession(payload.SessionName);
}
} catch (e) {
console.error('[ERREUR] Impossible de parser la demande de création', e);
}
} else if (topic === mqttSessionDeleteTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Demande de suppression de session: ${payload.SessionId}`);
if (payload.SessionId) {
deleteSession(payload.SessionId);
}
} catch (e) {
console.error('[ERREUR] Impossible de parser la demande de suppression', e);
}
} else if (topic === mqttSessionDeleteMediaTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Demande de suppression de média: ${payload.MediaPath} pour session: ${payload.SessionId}`);
if (payload.SessionId && payload.MediaPath) {
deleteMedia(payload.SessionId, payload.MediaPath);
}
} catch (e) {
console.error('[ERREUR] Impossible de parser la demande de suppression de média', e);
}
} else if (topic === mqttSessionRenameMediaTopic) {
try {
const payload = JSON.parse(message.toString());
console.log(`[INFO] Demande de renommage de média: ${payload.OldPath} -> ${payload.NewName}`);
if (payload.SessionId && payload.OldPath && payload.NewName) {
renameMedia(payload.SessionId, payload.OldPath, payload.NewName);
}
} catch (e) {
console.error('[ERREUR] Impossible de parser la demande de renommage', e);
}
}
});
function sendSessionList() {
fs.readdir(quizzDir, { withFileTypes: true }, (err, entries) => {
if (err) {
console.error('[ERREUR] Lecture dossier quizz', err);
return;
}
const sessions = [];
entries.forEach(entry => {
if (entry.isDirectory()) {
const configPath = path.join(quizzDir, entry.name, 'session-configuration.json');
if (fs.existsSync(configPath)) {
try {
const sessConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
sessions.push({
id: entry.name,
title: sessConfig.PackTitle || entry.name
});
} catch (e) {
sessions.push({ id: entry.name, title: entry.name + " (Erreur Config)" });
}
}
}
});
client.publish(mqttSessionListResponseTopic, JSON.stringify(sessions));
console.log(`[INFO] Liste envoyée : ${sessions.length} sessions`);
});
}
function sendSessionConfiguration(sessionId) {
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
fs.readFile(sessionFilePath, 'utf8', (err, data) => {
if (err) {
console.error('[ERREUR] Impossible de lire le fichier de session', err);
return;
}
client.publish(mqttSessionGetTopic, data);
console.log(`[INFO] Configuration envoyée pour ${sessionId}`);
});
}
function saveSessionConfiguration(sessionId, newConfig) {
const sessionFilePath = path.join(quizzDir, sessionId, 'session-configuration.json');
// Validation
if (!newConfig.Questions || !Array.isArray(newConfig.Questions)) {
console.error('[ERREUR] Configuration invalide');
return;
}
const data = JSON.stringify(newConfig, null, 2);
fs.writeFile(sessionFilePath, data, (err) => {
if (err) {
console.error('[ERREUR] Impossible d\'écrire le fichier de session', err);
} else {
console.log(`[INFO] Session ${sessionId} mise à jour avec succès`);
// Confirmer la sauvegarde en renvoyant la config
client.publish(mqttSessionGetTopic, data);
}
});
}
function createSession(sessionName) {
// Nettoyer le nom pour le dossier
const safeName = sessionName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const newSessionPath = path.join(quizzDir, safeName);
if (fs.existsSync(newSessionPath)) {
console.error(`[ERREUR] La session ${safeName} existe déjà`);
return;
}
// Créer le dossier
try {
fs.mkdirSync(newSessionPath, { recursive: true });
// Créer la structure de base
const defaultConfig = {
PackId: "",
PackTitle: sessionName,
Questions: []
};
fs.writeFileSync(
path.join(newSessionPath, 'session-configuration.json'),
JSON.stringify(defaultConfig, null, 2)
);
console.log(`[INFO] Nouvelle session créée: ${safeName}`);
// Renvoyer la liste mise à jour
sendSessionList();
} catch (e) {
console.error(`[ERREUR] Impossible de créer la session ${safeName}`, e);
}
}
function deleteSession(sessionId) {
const sessionPath = path.join(quizzDir, sessionId);
if (!fs.existsSync(sessionPath)) {
console.error(`[ERREUR] La session ${sessionId} n'existe pas`);
return;
}
try {
fs.rmSync(sessionPath, { recursive: true, force: true });
console.log(`[INFO] Session supprimée: ${sessionId}`);
// Renvoyer la liste mise à jour
sendSessionList();
} catch (e) {
console.error(`[ERREUR] Impossible de supprimer la session ${sessionId}`, e);
}
}
function deleteMedia(sessionId, relativePath) {
// Sécuriser le chemin
// relativePath devrait être du type "/assets/..."
if (!relativePath) return;
// Nettoyer le chemin (retirer le slash initial s'il existe)
if (relativePath.startsWith('/')) relativePath = relativePath.substring(1);
const fullPath = path.join(quizzDir, sessionId, relativePath);
// Vérifier que le chemin reste dans le dossier de la session (protection directory traversal)
if (!fullPath.startsWith(path.join(quizzDir, sessionId))) {
console.error(`[ERREUR] Tentative de suppression hors session: ${fullPath}`);
return;
}
if (!fs.existsSync(fullPath)) {
console.error(`[ERREUR] Le fichier ${fullPath} n'existe pas`);
return;
}
try {
fs.unlinkSync(fullPath);
console.log(`[INFO] Média supprimé: ${fullPath}`);
} catch (e) {
console.error(`[ERREUR] Impossible de supprimer le fichier ${fullPath}`, e);
}
}
client.on('error', (error) => {
console.error('[ERREUR] Erreur de connexion au broker MQTT:', error.message);
});
// Helper pour les renames tenaces sur Windows
function renameWithRetry(oldPath, newPath, retries = 5, delay = 100) {
try {
fs.renameSync(oldPath, newPath);
console.log(`[INFO] Média renommé : ${oldPath} -> ${newPath}`);
} catch (e) {
if (retries > 0) {
console.log(`[WARN] Échec renommage, nouvel essai dans ${delay}ms... (${retries} restants)`);
setTimeout(() => {
renameWithRetry(oldPath, newPath, retries - 1, delay * 2);
}, delay);
} else {
console.error(`[ERREUR] Impossible de renommer le fichier après plusieurs essais`, e);
}
}
}
function renameMedia(sessionId, oldRelativePath, newName) {
if (!oldRelativePath || !newName) return;
// Clean paths
if (oldRelativePath.startsWith('/')) oldRelativePath = oldRelativePath.substring(1);
const oldFullPath = path.join(quizzDir, sessionId, oldRelativePath);
// Security check
if (!oldFullPath.startsWith(path.join(quizzDir, sessionId))) {
console.error(`[ERREUR] Tentative de renommage hors session`);
return;
}
if (!fs.existsSync(oldFullPath)) {
console.error(`[ERREUR] Fichier à renommer introuvable: ${oldFullPath}`);
return;
}
const dir = path.dirname(oldFullPath);
const ext = path.extname(oldFullPath);
// newName comes as "Q-005", we add the extension
const newFilename = newName + ext;
const newFullPath = path.join(dir, newFilename);
// Prevent overwriting existing files (check & delete)
if (fs.existsSync(newFullPath)) {
console.log(`[INFO] Le fichier de destination existe déjà, on supprime : ${newFullPath}`);
try {
fs.unlinkSync(newFullPath);
} catch (e) {
console.error(`[ERREUR] Impossible de supprimer le fichier existant ${newFullPath}`, e);
// On continue quand même pour tenter le rename (le rename écrasera peut-être ou échouera)
}
}
renameWithRetry(oldFullPath, newFullPath);
}

View File

@@ -2,7 +2,7 @@
const mqtt = require('mqtt'); const mqtt = require('mqtt');
// Configuration du broker MQTT et de WLED // Configuration du broker MQTT et de WLED
const brokerUrl = 'mqtt://localhost'; // Change ce lien si nécessaire const brokerUrl = 'mqtt://192.168.1.201'; // Change ce lien si nécessaire
const clientId = 'light_manager_wled'; const clientId = 'light_manager_wled';
const wledTopicBase = 'wled/all'; // Le topic de base pour ton ruban WLED const wledTopicBase = 'wled/all'; // Le topic de base pour ton ruban WLED
const options = { const options = {
@@ -11,7 +11,7 @@ const options = {
}; };
// État des lumières // État des lumières
let currentColor = '#FFFFFF'; // Couleur par défaut (blanc) let currentColor = '#FF00FF'; // Couleur par défaut
let currentEffect = 'none'; // Pas d'effet par défaut let currentEffect = 'none'; // Pas d'effet par défaut
// Connexion au broker MQTT // Connexion au broker MQTT
@@ -25,6 +25,12 @@ client.on('connect', () => {
if (err) console.error('[ERROR] Subscription to light topics failed'); if (err) console.error('[ERROR] Subscription to light topics failed');
else console.log('[INFO] Successfully subscribed to light topics'); else console.log('[INFO] Successfully subscribed to light topics');
}); });
// Souscription aux topics des buzzers pour synchronisation directe
client.subscribe('vulture/buzzer/pressed/#', (err) => {
if (err) console.error('[ERROR] Subscription to buzzer topics failed');
else console.log('[INFO] Successfully subscribed to buzzer topics');
});
}); });
// Fonction pour envoyer un message au ruban WLED // Fonction pour envoyer un message au ruban WLED
@@ -65,9 +71,9 @@ function applyLightChange(color, effect, intensity) {
function getWLEDEffectId(effect) { function getWLEDEffectId(effect) {
const effectsMap = { const effectsMap = {
'none': 0, 'none': 0,
'blink': 1, // Effet de fondu 'blink': 0, // Effet de fondu
'fade': 12, // Clignotement 'fade': 0, // Clignotement
'rainbow': 9 // Effet arc-en-ciel 'rainbow': 0 // Effet arc-en-ciel
}; };
return effectsMap[effect] || 0; // Par défaut, aucun effet return effectsMap[effect] || 0; // Par défaut, aucun effet
} }
@@ -116,7 +122,7 @@ client.on('message', (topic, message) => {
} else if (topic === 'vulture/light/reset') { } else if (topic === 'vulture/light/reset') {
// Réinitialisation des lumières à la couleur et l'effet par défaut // Réinitialisation des lumières à la couleur et l'effet par défaut
console.log('[INFO] Resetting lights to default state'); console.log('[INFO] Resetting lights to default state');
applyLightChange('#FFFFFF', 'reset', 255); applyLightChange('#FF00FF', 'reset', 255);
} else if (topic === 'vulture/light/status/request') { } else if (topic === 'vulture/light/status/request') {
// Répondre à la requête de statut // Répondre à la requête de statut
@@ -125,6 +131,23 @@ client.on('message', (topic, message) => {
} else if (topic === 'vulture/light/status/response') { } else if (topic === 'vulture/light/status/response') {
// Répondre à la requête de statut // Répondre à la requête de statut
console.log('[INFO] Light status response received'); console.log('[INFO] Light status response received');
} else if (topic.startsWith('vulture/buzzer/pressed/')) {
// Synchronisation directe Buzzer -> WLED
const { color } = payload;
if (color && /^#[0-9A-F]{6}$/i.test(color)) {
console.log(`[INFO] Buzzer pressed, syncing WLED color to ${color}`);
// Envoi direct de la couleur (format hex avec #)
sendToWLED('col', color);
// Mise à jour de l'état interne
currentColor = color;
currentEffect = 'none'; // Reset effect on direct color set
} else {
console.warn(`[WARN] Invalid color in buzzer payload: ${color}`);
}
} else { } else {
console.error(`[ERROR] Unrecognized topic: ${topic}`); console.error(`[ERROR] Unrecognized topic: ${topic}`);
} }

49
compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
nanomq:
image: docker.io/emqx/nanomq:latest
container_name: nanomq
restart: always
networks:
- vulture-net
ports:
- "1883:1883"
- "9001:9001"
- "8081:8081"
- "8083:8083"
- "8883:8883"
volumes:
- ./VContainers/MQTT/config/nanomq.conf:/etc/nanomq.conf:Z
command: ["--conf", "/etc/nanomq.conf"]
vnode:
image: vnode:latest
container_name: vnode
build:
context: .
dockerfile: ./VContainers/VNode/Containerfile
restart: always
networks:
- vulture-net
depends_on:
- nanomq
vapp:
image: vapp:latest
container_name: vapp
build:
context: .
dockerfile: ./VContainers/VApp/Containerfile
restart: always
networks:
- vulture-net
ports:
- "5173:5173"
volumes:
- ./VContainers/VApp/config/config_prod.js:/usr/share/nginx/html/config.js:Z
depends_on:
- nanomq
networks:
vulture-net:
name: vulture-net
driver: bridge