diff --git a/.dockerignore b/.dockerignore
index 4cdd802..7cf9c1a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,10 +3,22 @@
!server/
!server/server.mjs
!server/telemetry.mjs
+!server/services.mjs
!server/Dockerfile
!ui/
!ui/index.html
!ui/styles.css
!ui/app.js
+!ui/applications.js
+!ui/applications.css
+!ui/cockpit.js
+!ui/cockpit.css
+!ui/embeds.js
+!ui/journey.js
+!ui/geo.js
+!ui/theme.js
+!ui/journey.css
+!ui/vendor/
+!ui/vendor/**
!ui/nginx.conf
!ui/Dockerfile
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9b445ad
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+CARIA_PORT=8080
+# Optional compatible self-hosted providers. Empty uses the public prototype default.
+WEATHER_URL=
+GEOCODER_URL=
+ROUTER_URL=
+# Raster tile template, for example https://your-server/{z}/{x}/{y}.png
+TILE_URL=
+TILE_ATTRIBUTION=© OpenStreetMap contributors
diff --git a/README.md b/README.md
index 097ebad..f3b2028 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,18 @@
# CARIA Automotive · Cockpit
-Première version de l’interface intérieure : accueil multimédia et instrumentation automobile, indépendants du matériel. Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`.
+Version 0.3 de l’interface intérieure : accueil multimédia, instrumentation automobile, thèmes, météo, navigation et catalogue d’applications, indépendants du matériel. Référence d’affichage : **10 pouces tactile, paysage, 1200 × 1080 pixels** (dimensions fournies comme 1080 × 1200 puis orientation horizontale). Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`.
+
+**Mise à jour d’une installation existante** : récupérer les fichiers modifiés puis exécuter `docker compose up -d --build` et recharger la page (Ctrl+F5). La version 0.1 a été construite et démarrée avec succès par l’utilisateur ; la nouvelle image 0.3 reste à construire sur son serveur.
+
+## Nouveautés 0.3
+
+- Bouton hamburger pour masquer/afficher les favoris, préférence mémorisée ; en-tête intégré au contenu et pied de page conservé pour les tests. Titres de page visuellement masqués quand les commandes suffisent.
+- Quatre favoris personnalisables et catalogue : Accueil, Conduite, Navigation, Musique locale, Jellyfin, Spotify, YouTube, **YouTube Music**, Waze. Afficher/masquer, ordre et mode d’ouverture réglables via Applications → Personnaliser ou Réglages → Personnaliser les applications.
+- Conduite : compteurs agrandis, base aplatie sous leurs libellés, quatre mesures rapides et page de détails (`#vehicle-details`). Lien dédié `/?display=drive#drive` ouvrant Conduite avec la barre repliée. Le favori Conduite reste présent par défaut pour les essais.
+- Musique : bibliothèque conservée et seconde vue `#now-playing`, accessible via « Vue lecture immersive ». Progression en arc, titre, durée, lecture/pause, précédent/suivant, ±15 s, déplacement dans la piste, volume, silence et répétition. Les deux vues partagent le même élément audio.
+- Services externes : nouvel onglet, même onglet, ou lecteur intégré pour les contenus compatibles. Waze peut recevoir la destination choisie dans CARIA.
+
+Voir [le guide applications et affichage tactile](docs/applications.md) pour les limites des intégrations Spotify, YouTube Music et Jellyfin.
## Démarrage avec Docker
@@ -20,7 +32,7 @@ docker compose down
Le port est configurable avec `CARIA_PORT` dans un fichier `.env` à la racine (exemple : `CARIA_PORT=8090`). Deux services : Nginx sert l’interface et relaie `/api/` vers le simulateur Node.js. Seul le port de l’interface est publié. Aucun périphérique ni privilège matériel n’est demandé.
-Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Le déploiement Docker n’a pas pu être exécuté sur le poste de développement, où Docker est absent.
+Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Docker est absent du poste de développement.
## Démarrage sans Docker
@@ -34,18 +46,23 @@ Le serveur local sert également les trois fichiers de l’interface, sur le mê
## Fonctions disponibles
-- **Accueil** : heure/date de l’appareil, météo de démonstration, carburant/autonomie et accès à la musique.
+- **Accueil** : heure/date de l’appareil, météo réelle Open-Meteo après localisation, carburant/autonomie simulés et accès à la musique.
- **Conduite** : vitesse et régime moteur à aiguilles SVG, rapport, carburant, température moteur, tension batterie, consommation et trajet simulés.
- **Musique** : sélection multiple de fichiers audio locaux, liste de lecture, lecture/pause, précédent/suivant, progression et volume via le lecteur natif. Les fichiers restent sur l’appareil qui affiche le navigateur ; ils ne sont pas transférés au serveur. La sélection doit être refaite après un rechargement. Une bibliothèque persistante sur disque/USB côté serveur pourra être ajoutée ensuite.
- **Streaming** : lien Jellyfin préconfiguré vers `https://media.cunatbrule.fr/web/`, modifiable dans les réglages. Ouverture dans un autre onglet ; aucune intégration de compte ni lecteur vidéo Jellyfin embarqué à ce stade.
-- **Réglages** : ambiance jour/nuit, adresse du streaming, raccourcis pour ouvrir deux écrans et bouton plein écran. Les réglages sont conservés dans le stockage local du navigateur.
+- **Réglages** : ambiance jour/nuit combinable avec bleu, rouge, vert ou couleur utilisateur ; la palette colore les commandes, aiguilles, graphiques et illustrations. Luminosité adaptée au contraste. Adresse du streaming, raccourcis multiécran et plein écran. Les réglages sont conservés dans le stockage local du navigateur.
+- **Navigation** : carte Leaflet/OpenStreetMap, recherche de départ/destination avec Photon, itinéraire automobile OSRM, étapes en français, suivi GPS et voix optionnelle. Recalcul manuel en cas de sortie de route. Pas de trafic temps réel ni de cartographie hors ligne.
+- **Alertes de zones** : import GeoJSON local, sélection du pays, approche GPS, signal visuel et bip optionnel. Aucune base de radars ni alerte communautaire n’est fournie. Le test d’alerte est explicitement fictif. Les alertes sont désactivées à chaque rechargement/changement de pays et suspendues si le GPS est ancien ou imprécis.
-Le menu à gauche contient les quatre applications : accueil, conduite, musique et streaming. Les réglages sont en bas. La météo est fictive et explicitement marquée « DÉMO » ; l’heure est réelle. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Jellyfin dépend de l’accès à son serveur.
+Le menu à gauche contient jusqu’à quatre favoris, avec un accès Applications permanent et les réglages en bas. Le badge « Démonstration » concerne les données du véhicule, pas la météo. La météo utilise une estimation de modèle et ne remplace pas une sonde extérieure. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Les services météo, carte, recherche et itinéraires nécessitent Internet avec la configuration par défaut.
+
+**GPS sur le réseau local** : les navigateurs demandent un contexte sécurisé, donc HTTPS avec certificat reconnu, ou localhost. Sur `http://IP_DU_SERVEUR:8080`, utilisez le départ manuel (recherche ou coordonnées) pour la météo et les aperçus d’itinéraire. Le GPS de l’ODroid devra être fourni à son navigateur ; cette version n’intègre pas encore gpsd ou un récepteur USB. Voir [le guide météo/navigation/alertes](docs/navigation.md).
## Un ou plusieurs écrans
-- Instrumentation : `http://ADRESSE_DU_SERVEUR:8080/#drive`
+- Instrumentation dédiée : `http://ADRESSE_DU_SERVEUR:8080/?display=drive#drive`
- Multimédia : `http://ADRESSE_DU_SERVEUR:8080/#home`
+- Navigation : `http://ADRESSE_DU_SERVEUR:8080/#map`
Chaque écran ouvre sa propre page. Les valeurs proviennent du même serveur. Le profil route/ville/arrêt se synchronise entre les onglets d’un même navigateur et d’une même origine ; sur deux appareils, sélectionner le même profil sur chacun. Les fichiers audio et réglages restent propres à chaque navigateur.
@@ -65,22 +82,27 @@ Navigateur ODroid / tablette / second écran
Étape suivante : passerelle Raspberry Pi CAN → données normalisées → interface
```
-L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré.
+L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Leaflet 1.9.4 (BSD-2-Clause) est livré localement et chargé à l’ouverture de la carte. Les tuiles sont téléchargées auprès du fournisseur cartographique. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré.
La passerelle CAN est un futur composant : pas de décodage Mégane III, de SocketCAN ni de commande véhicule dans cette version. Elle pourra être écrite indépendamment en Python, Rust, C++ ou JavaScript. Le contrat proposé est décrit dans [docs/architecture.md](docs/architecture.md). Le passage à des données réelles nécessitera également une adaptation de leur validation et de leurs indicateurs dans l’interface.
-Navigation, météo réelle, alertes radar et bibliothèque audio sur le serveur sont des étapes ultérieures. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers.
+Restent notamment : source de zones autorisée et maintenue par pays, GPS matériel, cartographie hors ligne et bibliothèque audio persistante sur le serveur. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers.
## Validation
```sh
-node --test server/telemetry.test.mjs
+node --test server/telemetry.test.mjs server/journey.test.mjs server/applications.test.mjs
node --check ui/app.js
+node --check ui/journey.js
```
-Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et impossibilité d’accéder aux fichiers du projet historique via le nouveau serveur. Tests Chrome automatisés : quatre vues à 1280×800, 1024×600, 800×480 et 390×844, absence de débordement horizontal, réglages persistants, lien Jellyfin, lecture de deux WAV locaux, changement de piste et retour après coupure API. Les captures accueil et conduite ont été examinées. Défilement vertical possible sur petits écrans selon la vue.
+Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et isolation des fichiers historiques ; cache des services, coordonnées invalides, limites de requêtes, erreurs fournisseur ; calcul géographique, progression et validation des zones, exclusion des données expirées ou du GPS imprécis/ancien ; couleurs extrêmes. Tests Chrome : cinq vues à 1280×800, 1024×600, 800×480 et 390×844, sans débordement horizontal ; persistance du thème, météo manuelle, recherche/itinéraire, GPS simulé, import et alerte de zone, pause à l’arrêt du GPS. Les fournisseurs sont simulés et les tuiles remplacées localement pendant ces tests automatiques. Les réponses météo, recherche et itinéraire ont aussi été vérifiées sur les services réels depuis Node.js avec des coordonnées de test à Paris. Les tests audio/Jellyfin/coupure API de la version précédente sont conservés comme validation de cette base. Défilement vertical possible sur petits écrans selon la vue.
-À valider sur la cible : Docker/Compose, architecture ARM exacte, résolution et densité de l’écran, tactile, sortie audio, démarrage kiosque, consommation CPU/RAM et lecture Jellyfin réelle avec le compte utilisateur. Le lien Jellyfin a été vérifié dans l’interface, sans connexion au service ni test de son contenu.
+Le scénario navigateur est dans `tests/browser.mjs`. Il demande Playwright et un Chromium installé dans l’environnement de test, et un serveur CARIA démarré. Exécuter `node tests/browser.mjs`. Variables optionnelles : `CARIA_URL`, `CHROME_PATH`, `PLAYWRIGHT_MODULE` (chemin de module sous forme d’URL `file:///...` si hors résolution npm). Playwright n’est pas une dépendance de production et n’est pas inclus dans Docker.
+
+Scénarios 0.3 également exécutés : `node tests/applications-browser.mjs` (catalogue, favoris, masquage, persistance, onglets et continuité audio) et `node tests/touch-browser.mjs` (1200×1080 tactile, hamburger, conduite dédiée, détails, lecture en arc, avance/volume/répétition, intégration à la demande et arrêt en quittant la vue). Les services externes sont interceptés dans ces tests : aucune connexion à un compte ni lecture commerciale réelle n’y est effectuée. Les vues sont vérifiées sans débordement horizontal à plusieurs tailles, et les captures de Conduite et de la lecture immersive ont été examinées.
+
+À valider sur la cible : construction Docker 0.3, architecture ARM exacte, densité tactile, sortie audio, démarrage kiosque, consommation CPU/RAM, GPS réel et lectures externes avec les comptes utilisateur. Le guidage n’a pas été testé sur route. Un contrôle HEAD public de Jellyfin a répondu HTTP 200 sans `X-Frame-Options` ni `Content-Security-Policy` ; cela ne constitue pas un test de connexion ou de lecture intégrée.
Voir [l’audit du dossier historique](docs/audit-initial.md).
diff --git a/compose.yaml b/compose.yaml
index b8ddb3e..0ec8ab3 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -16,6 +16,12 @@ services:
timeout: 3s
retries: 3
telemetry:
+ environment:
+ WEATHER_URL: ${WEATHER_URL:-https://api.open-meteo.com/v1/forecast}
+ GEOCODER_URL: ${GEOCODER_URL:-https://photon.komoot.io/api/}
+ ROUTER_URL: ${ROUTER_URL:-https://router.project-osrm.org/route/v1/driving/}
+ TILE_URL: ${TILE_URL:-}
+ TILE_ATTRIBUTION: ${TILE_ATTRIBUTION:-© OpenStreetMap contributors}
build:
context: .
dockerfile: server/Dockerfile
diff --git a/docs/applications.md b/docs/applications.md
new file mode 100644
index 0000000..777cc21
--- /dev/null
+++ b/docs/applications.md
@@ -0,0 +1,39 @@
+# Applications, conduite et musique — 0.3
+
+## Favoris et affichage tactile
+
+La référence actuelle est un écran **10 pouces tactile en paysage**, interprété comme **1200 pixels de large et 1080 de haut**. Le rendu reste adaptatif. Le bouton hamburger replie la barre latérale sans recharger la page ; le choix est enregistré dans ce navigateur. L’en-tête est placé dans le contenu et le pied de page reste visible pour les essais.
+
+Le catalogue se trouve dans Applications (`#apps`). Personnaliser permet de sélectionner jusqu’à quatre favoris, leur ordre, les applications visibles et le mode d’ouverture des services externes. Choisir un favori déjà présent échange les positions. Masquer un favori libère sa place ; cela ne désactive pas une fonction de CARIA et ne met pas la musique en pause. Applications et le logo d’accueil restent accessibles. Les préférences sont propres au navigateur et synchronisées entre ses onglets de même origine.
+
+Conduite est un favori par défaut pour les essais. Pour son futur écran séparé : `/?display=drive#drive` ouvre directement les instruments et replie la barre. Les grands compteurs ont une base plate sous Vitesse/Régime moteur. Carburant, refroidissement, batterie et extérieur sont visibles rapidement ; Détails du véhicule ouvre les valeurs et leur explication. Toutes ces mesures restent simulées et sont masquées si l’API devient indisponible.
+
+## Services Web et lecteurs intégrés
+
+Les raccourcis externes par défaut sont Jellyfin, Spotify, YouTube, YouTube Music et Waze. Aucun de ces services n’est chargé en arrière-plan par le catalogue. L’ouverture n’intervient qu’après un appui. Aucun logiciel natif n’est installé dans Docker.
+
+| Mode | Comportement |
+| --- | --- |
+| Nouvel onglet (défaut) | Conserve CARIA et son audio local. Le navigateur ou l’OS décide s’il ouvre aussi une application associée. |
+| Même onglet | Quitte CARIA ; le bouton Retour permet de revenir. La musique locale et le GPS peuvent être interrompus. |
+| Lecteur intégré | Reste dans CARIA, avec un cadre officiel/service compatible et un lien externe de secours. Disponible hors Waze. |
+
+**Jellyfin** : utilise l’adresse configurée dans les réglages. Le serveur et son reverse proxy doivent autoriser l’origine de CARIA à l’afficher dans un cadre. Le contrôle public des en-têtes de `https://media.cunatbrule.fr/web/` a répondu HTTP 200 sans restriction de cadrage explicite dans ces en-têtes au moment du test ; les pages d’authentification, cookies et lectures restent à vérifier avec le compte réel. Aucune configuration de serveur Jellyfin n’a été changée. L’interface CARIA ne peut ni garantir ni inspecter une connexion dans un cadre d’une autre origine.
+
+**Spotify** : le mode intégré attend un lien de morceau, album, playlist, épisode ou émission, et construit le lecteur officiel `open.spotify.com/embed/...`. Il ne reproduit pas la navigation complète de l’application. La connexion, la durée/possibilité de lecture et les restrictions appartiennent au lecteur Spotify. Voir les [Embeds officiels](https://developer.spotify.com/documentation/embeds).
+
+**YouTube / YouTube Music** : un lien vidéo (`watch?v=...` ou `youtu.be/...`) ou playlist produit un lecteur vidéo YouTube officiel. La vidéo et ses commandes restent visibles. Ce n’est pas l’application YouTube Music complète, ni une extraction audio ; certains contenus ou playlists peuvent refuser la lecture intégrée. Voir [la documentation du lecteur](https://developers.google.com/youtube/iframe_api_reference).
+
+**Waze** : le lien ouvre le site ou l’application selon l’OS. Après sélection d’une destination dans CARIA, le catalogue et le bouton de navigation utilisent un [lien Waze avec latitude/longitude et `navigate=yes`](https://developers.google.com/waze/deeplinks). Ce bouton fonctionne aussi si le calcul OSRM échoue, tant qu’une destination a été choisie. Il est masqué si Waze est masqué. Terminer le trajet retire la destination du lien. Aucune carte Waze n’est intégrée dans CARIA.
+
+Les lecteurs intégrés se chargent après un appui et, pour Spotify/YouTube, après fourniture d’un lien compatible (mémorisé localement). Ils sont déchargés lorsque l’on quitte la vue intégrée, pour éviter une lecture invisible et libérer leurs ressources. Cela ne coupe pas le lecteur de musique locale. Un seul cadre est chargé à la fois. CARIA ne modifie pas les interfaces, marques, publicités ou restrictions de ces services. Leur apparence interne ne peut pas être recolorée librement depuis CARIA.
+
+## Lecture locale immersive
+
+Depuis Musique locale, « Vue lecture immersive » ouvre `#now-playing`. La bibliothèque reste disponible et le morceau en cours continue : les deux vues pilotent le même lecteur HTML audio.
+
+L’arc coloré suit le temps réellement lu. La barre de progression permet de déplacer la lecture au tactile ou au clavier ; les commandes proposent lecture/pause, morceau précédent/suivant, recul/avance de 15 secondes, répétition du morceau, volume et silence. Les contrôles de transport sont désactivés sans morceau. Le titre, la durée et les commandes se synchronisent avec le lecteur de la bibliothèque. La sélection des fichiers reste locale et doit encore être refaite après rechargement de la page.
+
+## Tests et limites
+
+Les tests navigateur couvrent la navigation, les favoris et leur permutation, les modes d’ouverture, l’absence de préchargement des services, l’ouverture YouTube Music, le masquage sans interruption audio, la barre repliée persistante, l’affichage Conduite, les détails et la synchronisation de la lecture immersive. Les lecteurs externes sont simulés dans les tests ; leurs URL officielles et le déchargement du cadre sont vérifiés. Le tactile est émulé dans Chrome ; le toucher, le son et les comptes réels restent à tester sur l’appareil.
diff --git a/docs/architecture.md b/docs/architecture.md
index 68d8a88..cfd3cb2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,8 +39,8 @@ Le simulateur et la future passerelle doivent être sélectionnés explicitement
## Évolutions indépendantes
- Bibliothèque audio persistante : volume en lecture seule, catalogue et prise en charge des requêtes Range ; la musique serait alors lue depuis le serveur plutôt que choisie dans l’appareil client.
-- Météo réelle : fournisseur à définir, position configurable, cache et date de dernière mise à jour.
-- Cartographie/radar : fournisseur open source, besoin hors ligne, zone géographique et données à définir avec l’utilisateur.
+- Météo réelle et cartographie : intégrées en 0.2, voir [navigation.md](navigation.md). Restent le hors ligne et le GPS matériel.
+- Alertes de zones : moteur local livré en 0.2 ; la source de données réelle et son utilisation par pays restent à définir.
- Affichage : kiosque Linux et configuration des moniteurs au niveau de l’OS ; paramètres propres à chaque écran si nécessaire.
- Distribution : figer les images par version/digest une fois la première construction ARM validée ; ajouter une construction automatisée multiarchitecture au dépôt si son hébergement CI le permet.
diff --git a/docs/navigation.md b/docs/navigation.md
new file mode 100644
index 0000000..e34a793
--- /dev/null
+++ b/docs/navigation.md
@@ -0,0 +1,80 @@
+# Météo, navigation et zones — version 0.2
+
+## Utilisation
+
+1. Ouvrir Navigation, puis activer le GPS ou choisir un départ par recherche/coordonnées. Le GPS demande l’autorisation du navigateur ; le mode manuel convient à la préparation d’un trajet et à la météo.
+2. Rechercher une ville ou adresse, avec « Destination », puis sélectionner le résultat. Les résultats ne sont chargés qu’à la validation de la recherche.
+3. L’itinéraire affiche sa distance, une durée indicative et les manœuvres. Un GPS de moins de 15 secondes et précis à 100 mètres ou mieux active le suivi. Le départ manuel reste un aperçu. Hors route à plus de 100 mètres : bouton Recalculer. Le suivi ne recentre pas automatiquement la carte, pour préserver le déplacement manuel ; utiliser Recentrer.
+4. Activer Voix pour annoncer les changements d’étape si le navigateur propose la synthèse vocale française. Les capacités vocales dépendent de l’OS et du navigateur.
+
+Ce premier guidage projette la position sur la ligne de l’itinéraire. Il n’effectue pas de map matching avancé : les boucles, croisements et routes parallèles peuvent donner une progression ambiguë. Il n’intègre pas le trafic ni les restrictions temporaires. La voix annonce les changements d’étape, sans plusieurs préannonces à distance. Ce prototype reste à tester sur le matériel et sur route.
+
+## Localisation et météo
+
+La [géolocalisation Web](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition) demande un contexte sécurisé. Un accès HTTP par IP LAN ne donne généralement pas accès au GPS. Prévoir un reverse proxy HTTPS avec certificat reconnu pour l’ODroid. Ne pas désactiver la sécurité du navigateur pour contourner cette contrainte.
+
+Le bouton Arrêter le GPS ferme le suivi, suspend le guidage et les alertes et efface la météo GPS affichée. Le dernier lieu manuel, enregistré dans le navigateur, est repris au rechargement. La météo est actualisée toutes les 15 minutes ou après un déplacement de plus de 5 km, avec un bouton d’actualisation. Si le service échoue, les valeurs sont remplacées par des tirets. Les données météo ne sont pas remplacées par une simulation.
+
+Open-Meteo fournit température, état du ciel, vent et minimum/maximum du jour. L’heure de modèle affichée est celle du lieu météo. Les coordonnées envoyées au fournisseur sont arrondies au centième de degré. Les mêmes valeurs peuvent être servies depuis le cache du serveur pendant 15 minutes.
+
+## Fournisseurs et exploitation
+
+| Fonction | Par défaut | Configuration serveur |
+| --- | --- | --- |
+| Météo | Open-Meteo | `WEATHER_URL`, endpoint compatible forecast |
+| Recherche | Photon public | `GEOCODER_URL`, endpoint compatible Photon |
+| Itinéraire voiture | OSRM public | `ROUTER_URL`, préfixe `.../route/v1/driving/` |
+| Tuiles raster | OpenStreetMap standard | `TILE_URL`, template `{z}/{x}/{y}` |
+| Crédit cartographique | © OpenStreetMap contributors | `TILE_ATTRIBUTION` |
+
+Configurer ces variables dans `.env`, puis relancer Compose. Le serveur ne permet pas aux requêtes Web de fournir une URL arbitraire à récupérer. Les appels sortants utilisent un User-Agent CARIA, un délai de 8 secondes et une limite globale d’un appel par fournisseur toutes les 1,1 secondes. Cache mémoire borné à 128 entrées, regroupement des demandes simultanées identiques. Recherche conservée au maximum 24 heures dans le cache, route 1 minute, météo 15 minutes. L’API renvoie 429 lorsqu’il faut attendre, 502 en cas d’indisponibilité et 422 lorsqu’aucune route n’est trouvée.
+
+Les logiciels sont open source ; les services publics ne constituent pas une infrastructure gratuite illimitée :
+
+- [Open-Meteo](https://open-meteo.com/en/pricing) : accès gratuit pour usage non commercial dans les limites publiées, données avec attribution CC BY 4.0. Une commercialisation exige de revoir l’offre ou d’auto-héberger.
+- [Photon](https://github.com/komoot/photon) : serveur de démonstration autorisant un usage raisonnable sans garantie de disponibilité ; auto-hébergement possible. Recherche à la demande seulement dans CARIA.
+- [OSRM](https://github.com/Project-OSRM/osrm-backend/wiki/Demo-server) : serveur public de démonstration, sans garantie. Respecter ses conditions et prévoir son instance pour une exploitation soutenue.
+- [OpenStreetMap](https://operations.osmfoundation.org/policies/tiles/) : attribution visible, HTTPS, cache natif du navigateur, pas de préchargement ni de téléchargement hors ligne. Les tests automatiques interceptent les tuiles pour ne pas solliciter les serveurs publics.
+
+Les coordonnées météo, recherches et points d’itinéraire transitent par le serveur CARIA puis par le fournisseur concerné. Le navigateur demande les tuiles directement au fournisseur, qui connaît donc la zone affichée et l’adresse IP du client. Pas d’historique GPS en fichier : les coordonnées restent en mémoire, à l’exception du départ manuel mémorisé localement. Les journaux d’accès Nginx sont désactivés sur `/api/` pour ne pas y enregistrer les paramètres géographiques. Si vous ajoutez un reverse proxy, appliquer la même règle à ses journaux.
+
+## Système d’alertes et couverture
+
+Le système livré avertit à l’approche de **zones géographiques importées**. Il ne télécharge pas une base radar, ne détecte pas les contrôles et ne fournit pas de signalements communautaires. Sans fichier de zones, il n’a aucune couverture réelle. Le bouton de test génère une alerte fictive indépendante du GPS.
+
+Les règles varient selon le pays ; le sélecteur de pays filtre les données et ne certifie aucune autorisation. Le [Code de la route français, article R413-15](https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000025111528/2016-03-19) vise notamment les dispositifs informant de la localisation des appareils de contrôle. Transformer un point radar en cercle ne suffit pas à établir la conformité d’un service. Aucune base n’est transformée automatiquement de cette manière dans CARIA.
+
+Pour la France et les pays voisins, il reste à sélectionner une source maintenue de zones de vigilance, vérifier sa licence et ses conditions pays par pays. Les alertes sont désactivées au démarrage et à chaque changement de pays. Il faut sélectionner le pays manuellement : cette version ne détecte pas le franchissement d’une frontière.
+
+## Import GeoJSON
+
+Maximum 1,5 Mo, 1 000 zones. Chaque zone est un Point GeoJSON dont les coordonnées représentent le centre d’une **zone de vigilance** et dont `radiusM` donne l’étendue. Champs obligatoires : pays ISO à deux lettres, rayon entre 300 et 10 000 m, source, dates de mise à jour et d’expiration. Les points ne sont pas affichés comme emplacements de radars sur la carte. Le nom et la source sont affichés comme texte, pas interprétés comme HTML.
+
+```json
+{
+ "type": "FeatureCollection",
+ "features": [{
+ "type": "Feature",
+ "geometry": { "type": "Point", "coordinates": [2.35, 48.85] },
+ "properties": {
+ "id": "exemple-fictif",
+ "name": "EXEMPLE FICTIF — ne correspond pas à un radar",
+ "country": "FR",
+ "radiusM": 500,
+ "source": "Jeu de test utilisateur",
+ "updatedAt": "2026-09-12T00:00:00Z",
+ "expiresAt": "2026-12-31T23:59:59Z"
+ }
+ }]
+}
+```
+
+Les zones expirées sont ignorées. L’alerte se déclenche à moins de 400 m de la limite d’une zone, ou à l’intérieur, avec un GPS récent et précis. Calcul de proximité à vol d’oiseau, sans sens de circulation ni rattachement à une route. Le son est optionnel et limité pour éviter les répétitions. Le fichier reste dans le navigateur ; Effacer les zones le retire. Le fichier ne constitue pas une preuve de qualité ou d’actualité des données importées.
+
+## Priorités proposées pour la suite
+
+1. HTTPS, modèle d’ODroid et récepteur GPS : indispensables pour valider le guidage embarqué.
+2. Musique sur volume Docker/USB avec catalogue persistant, pour éviter la resélection des fichiers.
+3. Cartographie hors ligne avec tuiles autorisées et routage auto-hébergé, adaptés à la mémoire disponible.
+4. Passage des données CAN simulées aux mesures réelles, avec fraîcheur par signal.
+5. Source de zones maintenue et règles locales vérifiées avant toute utilisation routière de l’avertisseur.
diff --git a/package.json b/package.json
index 8a35b13..f25a620 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "caria-cockpit",
- "version": "0.1.0",
+ "version": "0.3.0",
"private": true,
"type": "module",
"scripts": { "start": "node server/server.mjs", "test": "node --test server/*.test.mjs" },
diff --git a/server/applications.test.mjs b/server/applications.test.mjs
new file mode 100644
index 0000000..6ed566a
--- /dev/null
+++ b/server/applications.test.mjs
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { normalizePreferences, safeWebUrl, wazeUrl } from '../ui/applications.js';
+import { embedUrl } from '../ui/embeds.js';
+test('launcher preferences migrate defaults and reject duplicate/unknown/hidden favorites', () => {
+ assert.deepEqual(normalizePreferences(null).favorites,['home','drive','map','music']);
+ const p=normalizePreferences({favorites:['youtube-music','youtube-music','invalid','spotify','home'],hidden:['spotify','invalid'],modes:{youtube:'same',spotify:'invalid'}});
+ assert.deepEqual(p.favorites,['youtube-music',null,null,null]);
+ assert.deepEqual(p.hidden,['spotify']); assert.equal(p.modes.youtube,'same'); assert.equal(p.modes.spotify,'new');
+ assert.deepEqual(normalizePreferences({favorites:[]}).favorites,[null,null,null,null]);
+});
+test('official embed URLs reject foreign origins and unsupported content',()=>{
+ assert.equal(embedUrl('spotify','https://open.spotify.com/playlist/abc123'),'https://open.spotify.com/embed/playlist/abc123');
+ assert.equal(embedUrl('spotify','https://open.spotify.com/'),'');
+ assert.equal(embedUrl('spotify','https://evil.example/playlist/abc123'),'');
+ assert.equal(embedUrl('youtube-music','https://music.youtube.com/watch?v=M7lc1UVf-VE'),'https://www.youtube.com/embed/M7lc1UVf-VE');
+ assert.equal(embedUrl('youtube-music','https://music.youtube.com/'),'');
+ assert.equal(embedUrl('stream','javascript:alert(1)'),'');
+ assert.equal(normalizePreferences({modes:{spotify:'embed',waze:'embed'}}).modes.spotify,'embed');
+ assert.equal(normalizePreferences({modes:{waze:'embed'}}).modes.waze,'new');
+});
+test('external links only allow web URLs and valid Waze destination coordinates', () => {
+ for (const value of ['javascript:alert(1)','file:///x','https://name:pass@example.org','bad']) assert.equal(safeWebUrl(value),'');
+ assert.equal(safeWebUrl('https://media.cunatbrule.fr/web/'),'https://media.cunatbrule.fr/web/');
+ const url = new URL(wazeUrl({lat:48.85,lon:2.35}));
+ assert.equal(url.searchParams.get('ll'),'48.85,2.35'); assert.equal(url.searchParams.get('navigate'),'yes');
+ assert.equal(new URL(wazeUrl({lat:NaN,lon:0})).searchParams.has('ll'),false);
+});
diff --git a/server/journey.test.mjs b/server/journey.test.mjs
new file mode 100644
index 0000000..25df8a1
--- /dev/null
+++ b/server/journey.test.mjs
@@ -0,0 +1,57 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createServices } from './services.mjs';
+import { distance, validateZones, nearbyZones, routeProgress, instruction } from '../ui/geo.js';
+import { palette } from '../ui/theme.js';
+
+test('geographic distances, segment projection and French instructions', () => {
+ assert.equal(distance({lat:48,lon:2},{lat:48,lon:2}), 0);
+ assert.ok(Math.abs(distance({lat:0,lon:0},{lat:0,lon:1}) - 111195) < 1);
+ const p = routeProgress([[2,48],[2.1,48]], {lon:2.05,lat:48});
+ assert.ok(p.away < 1 && Math.abs(p.fraction - .5) < .001);
+ assert.match(instruction({maneuver:{type:'turn',modifier:'left'},name:'Rue A'}), /gauche.*Rue A/);
+});
+const now = Date.UTC(2026,8,12);
+const collection = {type:'FeatureCollection',features:[{type:'Feature',geometry:{type:'Point',coordinates:[2,48]},properties:{id:'a',country:'FR',radiusM:1000,source:'Test',updatedAt:'2026-09-01',expiresAt:'2026-10-01'}}]};
+test('zone validation and alerts exclude expired, foreign, stale and inaccurate positions', () => {
+ const zones = validateZones(collection);
+ const gps = {lat:48,lon:2,source:'gps',accuracy:10,timestamp:now};
+ assert.equal(nearbyZones(zones,gps,'FR',now).length,1);
+ for (const change of [{source:'manual'},{accuracy:200},{timestamp:now-16000}]) assert.equal(nearbyZones(zones,{...gps,...change},'FR',now).length,0);
+ assert.equal(nearbyZones(zones,gps,'BE',now).length,0);
+ assert.equal(nearbyZones(zones,{...gps,timestamp:now+40*86400000},'FR',now+40*86400000).length,0);
+ assert.throws(()=>validateZones({type:'FeatureCollection',features:[{}]}));
+ const invalid = structuredClone(collection); invalid.features[0].properties.radiusM = 0;
+ assert.throws(()=>validateZones(invalid));
+});
+test('custom extreme colors are adjusted for light and dark contrast', () => {
+ assert.notEqual(palette('#ffffff',true).accent,'#ffffff');
+ assert.notEqual(palette('#000000',false).accent,'#000000');
+ assert.equal(palette('invalid').accent,palette('#d2ef92').accent);
+});
+test('weather cache, coordinate validation, upstream errors, search normalization', async () => {
+ let calls = 0, time = 100000;
+ const service = createServices({now:()=>time,env:{},fetcher:async url=>{
+ calls++;
+ if (url.hostname.includes('photon')) return {ok:true,json:async()=>({features:[{geometry:{type:'Point',coordinates:[2,48]},properties:{name:'Paris',city:'Paris',country:'France'}}]})};
+ return {ok:true,json:async()=>({current:{temperature_2m:20,wind_speed_10m:5,weather_code:0,time:'2026-09-12T12:00'},daily:{temperature_2m_min:[12],temperature_2m_max:[22]}})};
+ }});
+ const params = new URLSearchParams({lat:'48',lon:'2'});
+ const responses = await Promise.all([service('/api/weather',params),service('/api/weather',params)]);
+ assert.equal(responses[0].temperature,20); assert.equal(calls,1);
+ await service('/api/weather',params); assert.equal(calls,1);
+ await assert.rejects(service('/api/weather',new URLSearchParams({lat:'',lon:'2'})),{status:400});
+ await assert.rejects(service('/api/weather',new URLSearchParams({lat:'91',lon:'2'})),{status:400});
+ await assert.rejects(service('/api/weather',new URLSearchParams({lat:'49',lon:'2'})),{status:429});
+ time += 2000;
+ const places = await service('/api/search',new URLSearchParams({q:'Paris'}));
+ assert.equal(places[0].label,'Paris, France');
+ const broken = createServices({env:{},fetcher:async()=>{throw new Error('Offline');}});
+ await assert.rejects(broken('/api/weather',params),{status:502});
+});
+test('routing coordinates and NoRoute response are handled', async () => {
+ let target;
+ const service = createServices({env:{},fetcher:async url=>{target=url;return {ok:true,json:async()=>({code:'NoRoute'})};}});
+ await assert.rejects(service('/api/route',new URLSearchParams({fromLat:'48',fromLon:'2',toLat:'49',toLon:'3'})),{status:422});
+ assert.match(target.pathname,/2,48;3,49$/);
+});
diff --git a/server/server.mjs b/server/server.mjs
index 5b73e7f..02cc161 100644
--- a/server/server.mjs
+++ b/server/server.mjs
@@ -3,12 +3,20 @@ import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { telemetry, profiles } from './telemetry.mjs';
+import { createServices } from './services.mjs';
const assets = new Map([
['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']],
- ['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']]
+ ['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']],
+ ...['journey.js', 'geo.js', 'theme.js', 'applications.js', 'cockpit.js', 'embeds.js'].map(name => [`/${name}`, [name, 'text/javascript']]),
+ ['/cockpit.css', ['cockpit.css', 'text/css']],
+ ['/applications.css', ['applications.css', 'text/css']],
+ ['/journey.css', ['journey.css', 'text/css']],
+ ['/vendor/leaflet/leaflet.js', ['vendor/leaflet/leaflet.js', 'text/javascript']],
+ ['/vendor/leaflet/leaflet.css', ['vendor/leaflet/leaflet.css', 'text/css']]
]);
-export function createServer() {
+export function createServer(options) {
+ const services = createServices(options);
return http.createServer(async (req, res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'no-store');
@@ -23,6 +31,10 @@ export function createServer() {
const profile = url.searchParams.get('profile') || 'route';
return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' });
}
+ if (url.pathname.startsWith('/api/')) {
+ try { return send(200, await services(url.pathname, url.searchParams)); }
+ catch (error) { return send(error.status || 502, { error: error.status ? error.message : 'Service indisponible' }); }
+ }
const asset = assets.get(url.pathname);
if (!asset) return send(404, { error: 'Introuvable' });
try {
diff --git a/server/services.mjs b/server/services.mjs
new file mode 100644
index 0000000..de3be3d
--- /dev/null
+++ b/server/services.mjs
@@ -0,0 +1,72 @@
+// Public prototype providers, replaceable with self-hosted instances.
+export class ServiceError extends Error {
+ constructor(status, message) { super(message); this.status = status; }
+}
+function number(params, name, min, max) {
+ const raw = params.get(name), n = raw?.trim() ? Number(raw) : NaN;
+ if (!Number.isFinite(n) || n < min || n > max) throw new ServiceError(400, `Coordonnée ${name} invalide`);
+ return n;
+}
+export function createServices({ fetcher = fetch, now = Date.now, env = process.env } = {}) {
+ const cache = new Map(), inflight = new Map(), last = new Map();
+ const endpoints = {
+ weather: env.WEATHER_URL || 'https://api.open-meteo.com/v1/forecast',
+ search: env.GEOCODER_URL || 'https://photon.komoot.io/api/',
+ route: env.ROUTER_URL || 'https://router.project-osrm.org/route/v1/driving/'
+ };
+ async function request(kind, url, ttl) {
+ const key = url.href, saved = cache.get(key);
+ if (saved && now() - saved.time < ttl) return saved.data;
+ if (inflight.has(key)) return inflight.get(key);
+ if (now() - (last.get(kind) ?? -Infinity) < 1100 || inflight.size >= 6) throw new ServiceError(429, 'Patientez un instant avant de réessayer.');
+ last.set(kind, now());
+ const promise = (async () => {
+ try {
+ const response = await fetcher(url, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'CARIA-Cockpit/0.2 (personal automotive interface)', Accept: 'application/json' } });
+ if (!response.ok) throw new Error('Upstream');
+ const data = await response.json();
+ if (cache.size >= 128) cache.delete(cache.keys().next().value);
+ cache.set(key, { time: now(), data }); return data;
+ } catch { throw new ServiceError(502, 'Service externe indisponible. Réessayez avec une connexion Internet.'); }
+ finally { inflight.delete(key); }
+ })();
+ inflight.set(key, promise); return promise;
+ }
+ return async (path, params) => {
+ if (path === '/api/map-config') return {
+ tileUrl: env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ tileAttribution: env.TILE_ATTRIBUTION || '© OpenStreetMap contributors'
+ };
+ if (path === '/api/weather') {
+ const lat = number(params, 'lat', -90, 90), lon = number(params, 'lon', -180, 180);
+ const url = new URL(endpoints.weather);
+ url.search = new URLSearchParams({ latitude: lat.toFixed(2), longitude: lon.toFixed(2), current: 'temperature_2m,weather_code,wind_speed_10m', daily: 'temperature_2m_max,temperature_2m_min', timezone: 'auto', forecast_days: '1' });
+ const d = await request('weather', url, 15 * 60000);
+ if (![d.current?.temperature_2m, d.current?.wind_speed_10m, d.current?.weather_code, d.daily?.temperature_2m_min?.[0], d.daily?.temperature_2m_max?.[0]].every(Number.isFinite)) throw new ServiceError(502, 'Réponse météo incomplète');
+ return { source: 'Open-Meteo', fetchedAt: now(), observedAt: d.current.time, temperature: d.current.temperature_2m, code: d.current.weather_code, wind: d.current.wind_speed_10m, min: d.daily.temperature_2m_min[0], max: d.daily.temperature_2m_max[0] };
+ }
+ if (path === '/api/search') {
+ const q = params.get('q')?.trim();
+ if (!q || q.length < 3 || q.length > 180) throw new ServiceError(400, 'Saisissez entre 3 et 180 caractères.');
+ const url = new URL(endpoints.search); url.search = new URLSearchParams({ q, lang: 'fr', limit: '5' });
+ const d = await request('search', url, 24 * 3600000);
+ if (!Array.isArray(d.features)) throw new ServiceError(502, 'Réponse de recherche invalide');
+ return d.features.slice(0, 5).filter(f => f.geometry?.type === 'Point' && f.geometry.coordinates?.length === 2 && f.geometry.coordinates.every(Number.isFinite)).map(f => ({
+ lon: f.geometry.coordinates[0], lat: f.geometry.coordinates[1],
+ label: [...new Set([f.properties?.name, f.properties?.street, f.properties?.housenumber, f.properties?.postcode, f.properties?.city, f.properties?.country].filter(Boolean))].join(', ').slice(0, 300)
+ }));
+ }
+ if (path === '/api/route') {
+ const fromLat = number(params, 'fromLat', -85, 85), fromLon = number(params, 'fromLon', -180, 180);
+ const toLat = number(params, 'toLat', -85, 85), toLon = number(params, 'toLon', -180, 180);
+ const url = new URL(`${endpoints.route.replace(/\/$/, '')}/${fromLon},${fromLat};${toLon},${toLat}`);
+ url.search = new URLSearchParams({ overview: 'full', geometries: 'geojson', steps: 'true' });
+ const d = await request('route', url, 60000), route = d.routes?.[0];
+ if (d.code !== 'Ok' || !route || route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates) || route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000 || route.geometry.coordinates.some(c => !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite)) || !Number.isFinite(route.distance) || !Number.isFinite(route.duration) || !Array.isArray(route.legs)) throw new ServiceError(422, 'Aucun itinéraire routier trouvé.');
+ const steps = route.legs.flatMap(leg => leg.steps || []);
+ if (!steps.length || steps.some(s => !s.maneuver || !Number.isFinite(s.distance) || !Number.isFinite(s.duration))) throw new ServiceError(502, 'Étapes de navigation incomplètes');
+ return { source: 'OSRM', distance: route.distance, duration: route.duration, geometry: route.geometry, steps: steps.map(s => ({ distance: s.distance, duration: s.duration, name: s.name, maneuver: s.maneuver })) };
+ }
+ throw new ServiceError(404, 'Introuvable');
+ };
+}
diff --git a/tests/applications-browser.mjs b/tests/applications-browser.mjs
new file mode 100644
index 0000000..9356ac6
--- /dev/null
+++ b/tests/applications-browser.mjs
@@ -0,0 +1,50 @@
+const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
+import assert from 'node:assert/strict';
+const browser=await chromium.launch({...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{}),headless:true});
+try {
+ const context=await browser.newContext({viewport:{width:1280,height:800}});
+ const external=[];
+ await context.route(/https:\/\/(music\.youtube\.com|www\.youtube\.com|open\.spotify\.com|waze\.com|media\.cunatbrule\.fr)\//,r=>{external.push(r.request().url());return r.fulfill({contentType:'text/html',body:'
Service de test'});});
+ const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
+ const base=process.env.CARIA_URL||'http://127.0.0.1:8080';
+ await page.goto(base+'/#apps');
+ await page.locator('#applications-grid .application-card').first().waitFor();
+ assert.equal(await page.locator('#favorite-apps a').count(),5);
+ assert.equal(await page.locator('#applications-grid .application-card').count(),9);
+ assert.deepEqual(external,[]);
+ await page.locator('#applications-customize').click();
+ await page.locator('[data-favorite-slot="0"]').selectOption('youtube-music');
+ await page.locator('[data-favorite-slot="1"]').selectOption('youtube-music');
+ assert.equal(await page.locator('[data-favorite-slot="0"]').inputValue(),'drive');
+ await page.getByLabel('Afficher Spotify',{exact:true}).uncheck();
+ await page.getByLabel('Ouverture de YouTube',{exact:true}).selectOption('same');
+ await page.locator('#application-settings-close').click();
+ await page.reload();
+ assert.equal(await page.locator('#favorite-apps a').nth(1).getAttribute('data-application'),'youtube-music');
+ assert.equal(await page.locator('#applications-grid [data-application="spotify"]').count(),0);
+ assert.equal(await page.locator('#applications-grid [data-application="youtube"]').getAttribute('target'),'_self');
+ const popupEvent=page.waitForEvent('popup');
+ await page.locator('#applications-grid [data-application="youtube-music"]').click();
+ const popup=await popupEvent;await popup.waitForLoadState();
+ assert.equal(popup.url(),'https://music.youtube.com/');
+ assert.equal(page.url(),base+'/#apps');await popup.close();
+ await page.locator('#applications-customize').click();await page.locator('#applications-reset').click();await page.locator('#application-settings-close').click();
+ await page.locator('#favorite-apps [data-view="music"]').click();
+ const wav=Buffer.alloc(44+16000*60);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVE',8);wav.write('fmt ',12);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(8000,24);wav.writeUInt32LE(16000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(wav.length-44,40);
+ await page.locator('#music-files').setInputFiles({name:'Test.wav',mimeType:'audio/wav',buffer:wav});await page.locator('#play').click();
+ await page.waitForFunction(()=>!document.querySelector('#audio').paused);
+ await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#applications-customize').click();
+ await page.getByLabel('Afficher Musique locale',{exact:true}).uncheck();await page.locator('#application-settings-close').click();
+ assert.equal(await page.evaluate(()=>document.querySelector('#audio').paused),false);
+ assert.equal(await page.locator('#favorite-apps [data-application="music"]').count(),0);
+ assert.equal(await page.locator('#applications-grid [data-application="music"]').count(),0);
+ await page.locator('#applications-customize').click();
+ const names=['Accueil','Conduite','Navigation','Musique locale','Jellyfin','Spotify','YouTube','YouTube Music','Waze'];
+ for (const name of names) await page.getByLabel(`Afficher ${name}`,{exact:true}).uncheck();
+ await page.locator('#application-settings-close').click();assert.equal(await page.locator('#favorite-apps a').count(),1);await page.locator('#applications-empty').waitFor({state:'visible'});
+ await page.locator('#applications-customize').click();await page.locator('#applications-reset').click();await page.locator('#application-settings-close').click();
+ for (const size of [{width:1280,height:800},{width:1024,height:600},{width:800,height:480},{width:390,height:844}]) {await page.setViewportSize(size);assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);}
+ await page.setViewportSize({width:1280,height:800});
+ if(process.env.QA_SCREENSHOT) await page.screenshot({path:process.env.QA_SCREENSHOT,fullPage:true});
+ assert.deepEqual(errors,[]);console.log('PASS catalog; favorites swap; hide/restore; persistence; opening modes; YouTube Music popup; no preloading; audio survives hiding; empty catalog; responsive layouts.');
+} finally {await browser.close();}
diff --git a/tests/browser.mjs b/tests/browser.mjs
new file mode 100644
index 0000000..e8fca00
--- /dev/null
+++ b/tests/browser.mjs
@@ -0,0 +1,66 @@
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
+import assert from 'node:assert/strict';
+const browser = await chromium.launch({...(process.env.CHROME_PATH ? {executablePath:process.env.CHROME_PATH} : {}),headless:true});
+const context = await browser.newContext({viewport:{width:1024,height:600},permissions:['geolocation'],geolocation:{latitude:48.85,longitude:2.35,accuracy:10}});
+const page = await context.newPage(), errors=[];
+page.on('pageerror',e=>errors.push(e.message));
+// Never load public OSM tiles from automated QA.
+await context.route('https://tile.openstreetmap.org/**',r=>r.fulfill({status:200,contentType:'image/png',body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j1ioAAAAASUVORK5CYII=','base64')}));
+await page.route('**/api/weather?*',r=>r.fulfill({json:{source:'Open-Meteo',temperature:18,code:2,wind:8,min:12,max:22,observedAt:'2026-09-12T09:00'}}));
+await page.route('**/api/search?*',r=>r.fulfill({json:[{lat:48.86,lon:2.36,label:'Destination de test'}]}));
+await page.route('**/api/route?*',r=>r.fulfill({json:{source:'OSRM',distance:1500,duration:300,geometry:{type:'LineString',coordinates:[[2.35,48.85],[2.355,48.855],[2.36,48.86]]},steps:[{distance:700,duration:140,name:'Rue A',maneuver:{type:'depart',location:[2.35,48.85]}},{distance:800,duration:160,name:'Rue B',maneuver:{type:'turn',modifier:'left',location:[2.355,48.855]}},{distance:0,duration:0,name:'',maneuver:{type:'arrive',location:[2.36,48.86]}}]}}));
+await page.goto(process.env.CARIA_URL || 'http://127.0.0.1:8080');
+await page.getByText('● Démonstration',{exact:true}).waitFor();
+await page.locator('#settings-open').click();
+await page.locator('#accent').selectOption('blue');
+await page.getByRole('button',{name:'Enregistrer',exact:true}).click();
+assert.equal(await page.evaluate(()=>document.body.style.getPropertyValue('--accent')),'#8ac7ff');
+await page.reload();
+assert.equal(await page.evaluate(()=>document.body.style.getPropertyValue('--accent')),'#8ac7ff');
+await page.locator('#favorite-apps [data-view="map"]').click();
+await page.locator('.leaflet-container').waitFor();
+await page.getByText('Coordonnées manuelles',{exact:true}).click();
+await page.locator('#latitude').fill('48.85'); await page.locator('#longitude').fill('2.35');
+await page.getByRole('button',{name:'Utiliser ce départ',exact:true}).click();
+await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='18');
+await page.locator('#place-query').fill('Paris'); await page.getByRole('button',{name:'Rechercher un lieu'}).click();
+await page.getByRole('button',{name:'Destination de test',exact:true}).click();
+await page.getByText('Aperçu du trajet · activez le GPS pour le guidage',{exact:true}).waitFor();
+assert.equal(await page.locator('#route-steps li').count(),3);
+await page.locator('[data-waze-destination]').waitFor({state:'visible'});
+assert.equal(new URL(await page.locator('[data-waze-destination]').getAttribute('href')).searchParams.get('ll'),'48.86,2.36');
+await page.getByRole('button',{name:'⌖ Activer le GPS',exact:true}).click();
+await page.waitForFunction(()=>document.querySelector('#position-status').textContent.includes('GPS actif'));
+await page.waitForFunction(()=>document.querySelector('#next-instruction').textContent.includes('gauche'));
+await page.getByText('Alertes de zones',{exact:true}).click();
+const zones = {type:'FeatureCollection',features:[{type:'Feature',geometry:{type:'Point',coordinates:[2.35,48.85]},properties:{id:'test',name:'Zone QA',country:'FR',radiusM:500,source:'TEST AUTOMATISÉ',updatedAt:new Date(Date.now()-100000).toISOString(),expiresAt:new Date(Date.now()+86400000).toISOString()}}]};
+await page.locator('#zones-file').setInputFiles({name:'zones.geojson',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(zones))});
+await page.waitForFunction(()=>document.querySelector('#zones-status').textContent.includes('1 zones'));
+await page.locator('#alert-country').selectOption('FR'); await page.locator('#alerts-enabled').check();
+await page.locator('#zone-alert').waitFor({state:'visible'});
+await page.locator('#zone-alert-close').click();
+await page.locator('#gps-stop').click();
+await page.waitForFunction(()=>document.querySelector('#next-instruction').textContent.includes('pause'));
+for (const size of [{width:1280,height:800},{width:1024,height:600},{width:800,height:480},{width:390,height:844}]) {
+ await page.setViewportSize(size);
+ for (const view of ['home','drive','music','stream','map']) {
+ await page.evaluate(view=>location.hash=view,view); await page.locator(`#${view}`).waitFor({state:'visible'});
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,`Overflow ${view} ${size.width}`);
+ }
+}
+await page.setViewportSize({width:1024,height:600});
+await page.locator('#favorite-apps [data-view="map"]').click();
+await page.locator('#favorite-apps [data-view="home"]').click();
+await page.locator('#settings-open').click(); await page.locator('#theme').selectOption('light'); await page.locator('#accent').selectOption('custom'); await page.locator('#custom-color').fill('#ff0000'); await page.getByRole('button',{name:'Enregistrer',exact:true}).click();
+await page.locator('#favorite-apps [data-view="map"]').click();
+await page.getByRole('button',{name:'Utiliser ce départ',exact:true}).click();
+await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='18');
+await page.route('**/api/weather?*',r=>r.fulfill({status:502,json:{error:'Météo hors ligne'}}));
+await page.locator('#favorite-apps [data-view="home"]').click();
+await page.locator('#weather-refresh').click();
+await page.waitForFunction(()=>document.querySelector('#weather-temperature').textContent==='—' && document.querySelector('#weather-status').textContent==='Météo hors ligne');
+await page.reload();
+assert.equal(await page.locator('#alerts-enabled').isChecked(),false);
+assert.deepEqual(errors,[]);
+console.log('PASS themes + persistence; manual weather; search/route; GPS guidance; zone import/proximity alert; GPS stop; 5 views × 4 resolutions; no JS errors. Providers mocked; tiles replaced locally.');
+await browser.close();
diff --git a/tests/touch-browser.mjs b/tests/touch-browser.mjs
new file mode 100644
index 0000000..c7b4c77
--- /dev/null
+++ b/tests/touch-browser.mjs
@@ -0,0 +1,104 @@
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
+import assert from 'node:assert/strict';
+const browser=await chromium.launch({...(process.env.CHROME_PATH?{executablePath:process.env.CHROME_PATH}:{}),headless:true});
+try {
+ const context=await browser.newContext({viewport:{width:1200,height:1080},hasTouch:true,reducedMotion:'reduce'});
+ const external=[];
+ await context.route(/https:\/\/(open\.spotify\.com|www\.youtube\.com|media\.cunatbrule\.fr)\//,r=>{external.push(r.request().url());return r.fulfill({contentType:'text/html',body:'Lecteur de test'});});
+ const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
+ const base=process.env.CARIA_URL||'http://127.0.0.1:8080';
+ await page.goto(base+'/#drive');await page.locator('#speed-gauge svg').waitFor();
+ assert.equal(await page.locator('main > header').count(),1);
+ assert.equal(await page.locator('.workspace > footer').count(),0);
+ assert.equal(await page.locator('#settings footer #footer-status').count(),1);
+ const pageCount = context.pages().length;
+ assert.equal(await page.locator('#drive-immersive').count(),0);
+ await page.locator('#rail-toggle').click();
+ assert.equal(context.pages().length,pageCount);
+ assert.equal(await page.locator('#side-rail').isVisible(),false);
+ assert.ok((await page.locator('.page-top').boundingBox()).height<=44);
+ await page.locator('#rail-toggle').click();
+ const gauge=await page.locator('#speed-gauge svg').boundingBox();assert.ok(gauge.width>350);
+ await page.locator('#rail-toggle').click();assert.equal(await page.locator('#side-rail').isVisible(),false);
+ await page.reload();assert.equal(await page.locator('#side-rail').isVisible(),false);
+ await page.waitForFunction(()=>document.querySelector('[data-value="fuelPercent"]').textContent==='68');
+ if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-touch-drive.png',fullPage:true});
+ await page.getByRole('link',{name:'Détails du véhicule'}).click();await page.locator('#vehicle-details').waitFor({state:'visible'});
+ assert.equal(await page.locator('#vehicle-details .card').count(),8);
+ await page.locator('#rail-toggle').click();await page.locator('#favorite-apps [data-view="music"]').click();
+ await page.locator('#settings-open').click();
+ await page.locator('#dial-style').selectOption('digital');
+ await page.locator('#settings-form button[type="submit"]').click();
+ assert.equal(await page.evaluate(()=>document.body.classList.contains('digital-dials')),true);
+ await page.reload();
+ assert.equal(await page.evaluate(()=>document.body.classList.contains('digital-dials')),true);
+ await page.waitForFunction(()=>document.querySelector('[data-level="fuelPercent"]').getAttribute('aria-valuenow')==='68');
+ assert.equal(await page.locator('[data-level="coolantCelsius"]').getAttribute('aria-valuenow'),'89');
+ await page.evaluate(()=>location.hash='drive');
+ await page.locator('#drive').waitFor({state:'visible'});
+ assert.equal(await page.locator('#speed-gauge .needle').isVisible(),false);
+ assert.equal(await page.locator('#speed-gauge .tick.major').first().isVisible(),true);
+ assert.equal(await page.locator('#speed-gauge .tick.intermediate').first().isVisible(),true);
+ assert.equal(await page.locator('#speed-gauge .tick.quarter').first().isVisible(),false);
+ assert.equal(await page.locator('#rpm-gauge .rpm-red-zone').isVisible(),true);
+ assert.equal(await page.locator('#speed-gauge .digital-progress').isVisible(),true);
+ assert.equal(await page.locator('#drive .quick-sensors').count(),0);
+ if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-digital-drive.png'});
+ await page.evaluate(()=>location.hash='music');
+ await page.locator('#settings-open').click();
+ assert.equal(await page.locator('#settings').evaluate(el=>getComputedStyle(el).overflowY),'auto');
+ await page.locator('#settings-close').click();
+ const wav=Buffer.alloc(44+16000*120);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVE',8);wav.write('fmt ',12);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(8000,24);wav.writeUInt32LE(16000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(wav.length-44,40);
+ await page.locator('#music-files').setInputFiles({name:'Night Drive.wav',mimeType:'audio/wav',buffer:wav});
+ await page.getByRole('link',{name:'Vue lecture immersive'}).click();await page.locator('#now-playing').waitFor({state:'visible'});
+ await page.locator('#immersive-play').click();await page.waitForFunction(()=>!document.querySelector('#audio').paused);
+ assert.equal(await page.locator('#immersive-seek').count(),0); await page.locator('#audio').evaluate(el=>el.currentTime=60);
+ await page.waitForFunction(()=>document.querySelector('#audio').currentTime>=60);
+ await page.waitForFunction(()=>Number(document.querySelector('#track-arc').style.strokeDashoffset)<=50);
+ await page.locator('#immersive-volume').fill('0.3');await page.locator('#immersive-volume').dispatchEvent('input');assert.equal(await page.locator('#audio').evaluate(el=>el.volume),.3);
+ await page.locator('#immersive-repeat').click();assert.equal(await page.locator('#audio').evaluate(el=>el.loop),true);
+ await page.locator('#immersive-play').click();assert.equal(await page.locator('#audio').evaluate(el=>el.paused),true);
+ if(process.env.QA_DIRECTORY)await page.screenshot({path:process.env.QA_DIRECTORY+'/caria-touch-music.png',fullPage:true});
+ assert.equal(await page.locator('.brand,.wordmark').count(),0);
+ await page.locator('#immersive-mute').click();assert.equal(await page.locator('#audio').evaluate(el=>el.muted),true);
+ await page.locator('#immersive-mute').click();assert.equal(await page.locator('#audio').evaluate(el=>el.muted),false);
+ assert.equal(await page.locator('#audio').evaluate(el=>el.volume),.3);
+ await page.locator('#immersive-repeat').click();
+ await page.locator('#music-files').setInputFiles(['Second','Third'].map(name=>({name:name+'.wav',mimeType:'audio/wav',buffer:wav})));
+ await page.locator('#immersive-shuffle').click();
+ assert.equal(await page.locator('#immersive-shuffle').getAttribute('aria-pressed'),'true');
+ const titles=[await page.locator('#immersive-title').textContent()];
+ for(let i=0;i<2;i++){
+ await page.locator('#immersive-next').click();
+ await page.waitForFunction(previous=>document.querySelector('#immersive-title').textContent!==previous,titles.at(-1));
+ titles.push(await page.locator('#immersive-title').textContent());
+ }
+ assert.equal(new Set(titles).size,3,'Shuffle visits each remaining track once');
+ await page.locator('#audio').evaluate(el=>el.currentTime=30);
+ await page.locator('#immersive-previous').click();
+ assert.ok(await page.locator('#audio').evaluate(el=>el.currentTime<2));
+ assert.equal(await page.locator('#immersive-title').textContent(),titles[2]);
+ await page.locator('#immersive-previous').click();
+ await page.waitForFunction(title=>document.querySelector('#immersive-title').textContent===title,titles[1]);
+ await page.locator('#audio').evaluate(el=>el.dispatchEvent(new Event('ended')));
+ await page.waitForFunction(title=>document.querySelector('#immersive-title').textContent!==title,titles[1]);
+ await page.locator('#audio').evaluate(el=>el.pause());
+ await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#applications-customize').click();await page.getByLabel('Ouverture de Spotify',{exact:true}).selectOption('embed');await page.locator('#application-settings-close').click();
+ assert.deepEqual(external,[]);
+ await page.locator('#applications-grid [data-application="spotify"]').click();await page.locator('#embedded').waitFor({state:'visible'});
+ assert.equal(await page.locator('#embed-container iframe').count(),0);
+ await page.locator('#embed-url').fill('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M');await page.getByRole('button',{name:'Charger le lecteur',exact:true}).click();
+ assert.equal(await page.locator('#embed-container iframe').getAttribute('src'),'https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M');
+ await page.locator('#favorite-apps [data-view="apps"]').click();await page.locator('#embed-container iframe').waitFor({state:'detached'});
+ for (const size of [{width:1200,height:1080},{width:1920,height:1080},{width:1024,height:600},{width:390,height:844}]) {
+ await page.setViewportSize(size);
+ for(const view of ['home','drive','vehicle-details','music','now-playing','apps','embedded']){
+ await page.evaluate(view=>location.hash=view,view);await page.locator('#'+view).waitFor({state:'visible'});
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,`Overflow ${view} ${size.width}`);
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollHeight<=innerHeight),true,`Vertical overflow ${view} ${size.width}`);
+ assert.ok(await page.locator('#'+view).evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight+1),`View fits ${view} ${size.width}`);
+ }
+ }
+ await page.goto(base+'/?display=drive#drive');await page.locator('#speed-gauge svg').waitFor();assert.equal(await page.locator('#side-rail').isVisible(),false);
+ assert.deepEqual(errors,[]);console.log('PASS landscape/touch; sidebar persistence; dedicated drive; large gauges; sensor details; shared arc player/seek/volume/repeat; opt-in embed/unload; 7 views × 4 sizes.');
+}finally{await browser.close();}
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 7a4ff6d..66da333 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -1,4 +1,8 @@
FROM nginx:stable-alpine
COPY ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/
+COPY ui/journey.js ui/geo.js ui/theme.js ui/journey.css /usr/share/nginx/html/
+COPY ui/vendor /usr/share/nginx/html/vendor
+COPY ui/applications.js ui/applications.css /usr/share/nginx/html/
+COPY ui/cockpit.js ui/cockpit.css ui/embeds.js /usr/share/nginx/html/
EXPOSE 80
diff --git a/ui/app.js b/ui/app.js
index e0f786f..45ba629 100644
--- a/ui/app.js
+++ b/ui/app.js
@@ -1,3 +1,8 @@
+import { applyTheme, accents } from './theme.js';
+import { initJourney } from './journey.js';
+import { initCockpit } from './cockpit.js';
+import { initEmbeds } from './embeds.js';
+import { initApplications, applications } from './applications.js';
const $ = selector => document.querySelector(selector);
const all = selector => document.querySelectorAll(selector);
const storage = {
@@ -6,14 +11,16 @@ const storage = {
};
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
function navigate() {
- const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home';
+ const titles = { home: 'Accueil', drive: 'Conduite', music: 'Musique', stream: 'Jellyfin', map: 'Navigation', apps: 'Applications', 'vehicle-details': 'Détails véhicule', 'now-playing': 'Lecture', embedded: 'Lecteur intégré' };
+ const view = Object.hasOwn(titles, location.hash.slice(1)) ? location.hash.slice(1) : 'home';
all('.view').forEach(section => section.hidden = section.id !== view);
all('[data-view]').forEach(link => {
const active = link.dataset.view === view;
link.classList.toggle('active', active);
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
});
- document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`;
+ document.title = `CARIA · ${titles[view]}`;
+ document.dispatchEvent(new CustomEvent('caria-view', { detail: view }));
}
window.addEventListener('hashchange', navigate);
navigate();
@@ -35,14 +42,35 @@ function makeGauge(id, max, step, unit, label) {
for (let v = 0; v <= max; v += step / 4) {
const angle = -130 + v / max * 260;
const major = v % step === 0;
- const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
- ticks += ``;
- if (major) ticks += `${max === 7000 ? v / 1000 : v}`;
+ const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 96);
+ ticks += ``;
+ if (major) ticks += `${max === 7000 ? v / 1000 : v}`;
}
- $(id).innerHTML = ``;
+ // Decorative demonstration red zone; not a calibrated vehicle redline.
+ const redStart = point(-130 + 5500 / 7000 * 260, 128), redEnd = point(130, 128);
+ const redZone = max === 7000 ? `` : '';
+ const levelKey = max === 7000 ? 'coolantCelsius' : 'fuelPercent';
+ const iconPath = document.querySelector(`[data-level="${levelKey}"] .aux-title svg`).innerHTML;
+ // Compact auxiliary arc, separated from the main instrument.
+ const levelPoint = (angle, radius = 64) => [150+radius*Math.sin(angle*Math.PI/180),244-radius*Math.cos(angle*Math.PI/180)];
+ // 86.8 degrees: 30% shorter than the previous 124-degree auxiliary arc.
+ const arcPath = (from,to) => `M${levelPoint(from).join(' ')} A64 64 0 0 0 ${levelPoint(to).join(' ')}`;
+ const arc = arcPath(223.4,136.6);
+ const redArc = max===7000 ? arcPath(153.96,136.6) : arcPath(223.4,212.984);
+ const tickRatios = max === 7000 ? [0,.28,.56,.84,1] : [0,.12,.4133333333,.7066666667,1];
+ const levelTicks = tickRatios.map((fraction,index) => {
+ const angle = 223.4 - 86.8*fraction;
+ const inner=levelPoint(angle,54), outer=levelPoint(angle,61);
+ return ``;
+ }).join('');
+ const level = `${levelTicks}${max===7000?'C':'E'}${max===7000?'H':'F'}${max===7000?'':'½'}${iconPath}— ${max===7000?'°C':'%'}`;
+ const low = max === 7000 ? 'C' : 'E', high = max === 7000 ? 'H' : 'F';
+ const mini = `${low}${high}${max===7000?'':`½`}${iconPath}— ${max===7000?'°C':'%'}`;
+ $(id).innerHTML = ``;
return value => {
const valid = typeof value === 'number' && Number.isFinite(value);
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
+ $(`${id} .digital-progress`).style.strokeDashoffset = String(valid ? 100 - Math.max(0,Math.min(1,value/max))*100 : 100);
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
@@ -53,6 +81,37 @@ const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME
const savedProfile = storage.get('profile', 'route');
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
let generation = 0, timer;
+function updateLevels(data) {
+ all('[data-level]').forEach(el => {
+ const key = el.dataset.level, value = data?.[key], valid = Number.isFinite(value);
+ const min = Number(el.getAttribute('aria-valuemin')), max = Number(el.getAttribute('aria-valuemax'));
+ const needle = el.querySelector('.aux-needle');
+ if (needle) { needle.style.transform = `rotate(${valid ? -90+Math.max(0,Math.min(1,(value-min)/(max-min)))*180 : -90}deg)`; needle.style.opacity = valid ? '1' : '0'; }
+ // Demo visualization ranges; actual CAN calibration belongs to the vehicle profile.
+ const ratio = valid ? Math.max(0,Math.min(1,(value-min)/(max-min))) : 0;
+ const starts = key === 'fuelPercent' ? [0,.1,.4,.7] : [0,.25,.5,.75];
+ el.querySelectorAll('.sensor-segments i').forEach((segment,i) => {
+ segment.classList.toggle('filled', valid && ratio > starts[i]);
+ });
+ const analog = document.querySelector(`[data-analog-level="${key}"]`);
+ analog.querySelector('.mini-needle').style.transform = `rotate(${-90 + ratio*180}deg)`;
+ analog.querySelector('.mini-needle').style.opacity = valid ? '1' : '0';
+ analog.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
+ const digital = document.querySelector(`[data-digital-level="${key}"]`);
+ const stops = key === 'fuelPercent' ? [0,.12,.4133333333,.7066666667,1] : [0,.28,.56,.84,1];
+ const stepped = stops.filter(stop => stop <= ratio).at(-1) ?? 0;
+ const progress = digital.querySelector('.level-arc-progress');
+ progress.style.strokeDashoffset = String(100*(1-stepped));
+ progress.style.opacity = valid && stepped > 0 ? '1' : '0';
+ digital.classList.toggle('last-level', valid && key === 'coolantCelsius' && ratio >= .84);
+ digital.classList.toggle('reserve-level', valid && key === 'fuelPercent' && stepped === .12);
+ digital.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
+ el.classList.toggle('red-zone', valid && (key === 'fuelPercent' ? ratio <= .25 : ratio >= .75));
+ el.classList.toggle('reserve', valid && key === 'fuelPercent' && value <= 10);
+ if (valid) el.setAttribute('aria-valuenow', String(value)); else el.removeAttribute('aria-valuenow');
+ el.setAttribute('aria-valuetext', valid ? value + (key === 'fuelPercent' ? ' %' : ' °C') : 'Indisponible');
+ });
+}
function offline() {
$('#connection').textContent = 'Données indisponibles';
$('#connection').classList.add('error');
@@ -60,6 +119,7 @@ function offline() {
document.body.classList.add('stale');
all('[data-value]').forEach(el => el.textContent = '—');
all('[data-fuel]').forEach(el => el.style.width = '0%');
+ updateLevels(null);
speedGauge(null); rpmGauge(null);
}
async function poll() {
@@ -81,6 +141,7 @@ async function poll() {
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
});
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
+ updateLevels(data);
speedGauge(data.speedKph); rpmGauge(data.rpm);
} catch { if (current === generation) offline(); }
finally { if (current === generation) timer = setTimeout(poll, 500); }
@@ -88,41 +149,34 @@ async function poll() {
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
window.addEventListener('storage', event => {
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
- if (event.key === 'caria.stream') configureStream();
- if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
+ if (['caria.theme', 'caria.accent', 'caria.customColor', 'caria.dialStyle'].includes(event.key)) applyTheme(storage);
});
poll();
-function validUrl(value) {
- try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
-}
-const defaultStream = 'https://media.cunatbrule.fr/web/';
-function configureStream() {
- const url = validUrl(storage.get('stream', defaultStream));
- $('#stream-link').hidden = !url;
- if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href');
- $('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.';
- $('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site';
-}
-configureStream();
-document.body.classList.toggle('light', storage.get('theme') === 'light');
+const jellyfinUrl = applications.find(app => app.id === 'stream').url;
+$('#stream-link').hidden = false;
+$('#stream-link').href = jellyfinUrl;
+$('#stream-description').textContent = new URL(jellyfinUrl).hostname;
+applyTheme(storage);
function openSettings() {
- $('#stream-url').value = storage.get('stream', defaultStream);
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
+ $('#accent').value = storage.get('accent', 'green');
+ $('#dial-style').value = ['digital', 'luminous'].includes(storage.get('dialStyle')) ? storage.get('dialStyle') : 'analog';
+ $('#custom-color').value = storage.get('customColor', accents.green);
$('#settings-message').textContent = '';
$('#settings').showModal();
}
+$('#custom-color').addEventListener('input', () => { $('#accent').value = 'custom'; });
$('#settings-open').onclick = openSettings;
-$('#stream-configure').onclick = openSettings;
$('#settings-close').onclick = () => $('#settings').close();
$('#settings-form').addEventListener('submit', event => {
event.preventDefault();
- const raw = $('#stream-url').value.trim(), url = validUrl(raw);
- if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
- const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
- if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
- document.body.classList.toggle('light', $('#theme').value === 'light');
- configureStream(); $('#settings').close(); toast('Réglages enregistrés');
+ const dialSaved = storage.set('dialStyle', $('#dial-style').value);
+ const themeSaved = storage.set('theme', $('#theme').value);
+ const accentSaved = storage.set('accent', $('#accent').value), colorSaved = storage.set('customColor', $('#custom-color').value);
+ if (!dialSaved || !themeSaved || !accentSaved || !colorSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
+ applyTheme(storage);
+ $('#settings').close(); toast('Réglages enregistrés');
});
$('#fullscreen').onclick = async () => {
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
@@ -131,6 +185,25 @@ $('#fullscreen').onclick = async () => {
const audio = $('#audio');
let tracks = [], selected = -1;
+let shuffle = storage.get('shuffle') === 'true', shuffleBag = [], playbackHistory = [];
+$('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
+$('#immersive-shuffle').onclick = () => {
+ shuffle = !shuffle; shuffleBag = []; playbackHistory = [];
+ storage.set('shuffle', String(shuffle));
+ $('#immersive-shuffle').setAttribute('aria-pressed', String(shuffle));
+ toast(shuffle ? 'Lecture aléatoire activée' : 'Lecture aléatoire désactivée');
+};
+function nextTrack() {
+ if (!shuffle || tracks.length < 2) return selectTrack(selected + 1, true);
+ if (!shuffleBag.length) {
+ shuffleBag = tracks.map((_, index) => index).filter(index => index !== selected);
+ for (let i = shuffleBag.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffleBag[i], shuffleBag[j]] = [shuffleBag[j], shuffleBag[i]];
+ }
+ }
+ selectTrack(shuffleBag.pop(), true);
+}
function renderPlaylist() {
$('#playlist').replaceChildren();
tracks.forEach((track, index) => {
@@ -149,9 +222,15 @@ async function playAudio() {
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
}
-function selectTrack(index, play = false) {
+function selectTrack(index, play = false, remember = true) {
if (!tracks.length) return;
+ previousTap = -Infinity;
+ if (remember && selected >= 0 && index !== selected) {
+ playbackHistory.push(selected);
+ if (playbackHistory.length > 256) playbackHistory.shift();
+ }
selected = (index + tracks.length) % tracks.length;
+ shuffleBag = shuffleBag.filter(index => index !== selected);
audio.src = tracks[selected].url;
$('#track-title').textContent = tracks[selected].name;
$('#home-track').textContent = tracks[selected].name;
@@ -165,17 +244,30 @@ $('#music-files').addEventListener('change', event => {
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
}
+ shuffleBag = [];
renderPlaylist();
if (selected === -1 && tracks.length) selectTrack(0);
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
event.target.value = '';
});
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
-$('#previous').onclick = () => selectTrack(selected - 1, true);
-$('#next').onclick = () => selectTrack(selected + 1, true);
+let previousTap = -Infinity;
+$('#previous').onclick = () => {
+ const now = performance.now(), twice = now - previousTap < 600;
+ previousTap = twice ? -Infinity : now;
+ if (!twice) { audio.currentTime = 0; return; }
+ if (shuffle) { shuffleBag = []; selectTrack(playbackHistory.pop() ?? selected, true, false); }
+ else selectTrack(selected - 1, true);
+};
+$('#next').onclick = nextTrack;
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
-audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; });
+audio.addEventListener('ended', () => { if (audio.loop) return; if (shuffle || selected + 1 < tracks.length) nextTrack(); else $('#play').textContent = 'Lecture'; });
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
renderPlaylist();
+initJourney({ storage, toast });
+initApplications({ storage, toast, navigate });
+initEmbeds({ storage, toast });
+
+initCockpit({ storage, toast });
diff --git a/ui/applications.css b/ui/applications.css
new file mode 100644
index 0000000..f745d93
--- /dev/null
+++ b/ui/applications.css
@@ -0,0 +1,5 @@
+#applications-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.catalog-heading{display:flex;justify-content:space-between;gap:15px;align-items:center;margin-bottom:18px}.catalog-heading p{font-size:13px;color:var(--muted);margin:0}.application-card{padding:0;position:relative;box-shadow:none}.application-open{display:block;padding:25px;min-height:205px}.application-open>.icon{display:grid;place-items:center;width:52px;height:52px;border-radius:15px;background:color-mix(in srgb,var(--accent) 10%,var(--surface));color:var(--accent);font-size:29px;margin-bottom:18px}.application-open h2{font-size:19px;margin-bottom:8px}.application-open p{font-size:12px;color:var(--muted);margin:0 0 16px;min-height:36px}.application-kind{font-size:10px;color:var(--accent)}.application-card:hover{border-color:var(--accent)}.favorite-toggle{position:absolute;right:12px;top:12px;font-size:23px;padding:4px 10px;border-color:transparent;background:transparent;color:var(--accent)}.favorite-toggle[aria-pressed=true]{background:color-mix(in srgb,var(--accent) 10%,var(--surface))}.application-open[aria-disabled=true]{opacity:.55;cursor:default}#application-settings{width:min(640px,calc(100% - 24px))}#application-settings h3{font-size:16px;margin:24px 0 8px}#favorite-slots{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}#favorite-slots label{margin-top:6px}#favorite-slots select{width:100%}.application-setting{display:grid;grid-template-columns:minmax(0,1fr) minmax(130px,180px);gap:12px;align-items:center;min-height:60px;border-bottom:1px solid var(--border);padding:8px 0}.application-setting label{display:flex;flex-direction:row;align-items:center;margin:0;min-height:44px;gap:12px}.application-setting input{width:20px;height:20px;accent-color:var(--accent)}.application-setting>span{color:var(--muted);font-size:12px}.application-setting select{width:100%;font-size:12px}.rail nav a span:last-child{overflow-wrap:anywhere;text-align:center}.map-actions a[data-waze-destination]{font-size:11px;padding:12px;border:1px solid var(--border);border-radius:10px;color:var(--accent)}
+@media(min-width:1450px){#applications-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
+@media(max-width:900px){#applications-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.application-open{padding:20px}}
+@media(max-width:520px){#applications-grid{grid-template-columns:1fr}.application-open{min-height:165px}.application-open>.icon{margin-bottom:12px}.application-open p{min-height:0}#favorite-slots{grid-template-columns:1fr}.application-setting{grid-template-columns:1fr}.application-setting select{margin-bottom:6px}.catalog-heading{align-items:flex-start}.catalog-heading p{font-size:11px}}
+@media(max-height:650px) and (min-width:760px){.application-open{padding:18px;min-height:180px}.application-open>.icon{width:44px;height:44px;margin-bottom:12px}.application-open h2{font-size:17px}.application-open p{font-size:11px;margin-bottom:10px}}
diff --git a/ui/applications.js b/ui/applications.js
new file mode 100644
index 0000000..85c4a7a
--- /dev/null
+++ b/ui/applications.js
@@ -0,0 +1,146 @@
+export const applications = [
+ { id: 'home', name: 'Accueil', icon: '⌂', description: 'Heure, météo et votre espace à bord.' },
+ { id: 'drive', name: 'Conduite', icon: '◉', description: 'Les instruments de votre véhicule.' },
+ { id: 'map', name: 'Navigation', icon: '⌖', description: 'Carte, itinéraires et zones de vigilance.' },
+ { id: 'music', name: 'Musique locale', icon: '♫', description: 'Vos morceaux sur cet appareil.' },
+ { id: 'stream', name: 'Jellyfin', icon: '▷', external: true, url: 'https://media.cunatbrule.fr/web/', description: 'Votre bibliothèque multimédia personnelle.' },
+ { id: 'spotify', name: 'Spotify', icon: '≋', external: true, url: 'https://open.spotify.com/', description: 'Ouvrir le lecteur Web Spotify.' },
+ { id: 'youtube', name: 'YouTube', icon: '▶', external: true, url: 'https://www.youtube.com/', description: 'Vidéos, chaînes et playlists.' },
+ { id: 'youtube-music', name: 'YouTube Music', icon: '◉', external: true, url: 'https://music.youtube.com/', description: 'Votre musique sur YouTube Music.' },
+ { id: 'waze', name: 'Waze', icon: '⌁', external: true, url: 'https://waze.com/ul?utm_source=caria', description: 'Ouvrir Waze ou lui transmettre votre destination.' }
+];
+const ids = new Set(applications.map(a => a.id));
+export function normalizePreferences(value) {
+ const raw = value && typeof value === 'object' ? value : {};
+ const hidden = [...new Set(Array.isArray(raw.hidden) ? raw.hidden.filter(id => ids.has(id)) : [])];
+ const seen = new Set();
+ const favorites = Array.from({ length: 4 }, (_, i) => {
+ const id = (Array.isArray(raw.favorites) ? raw.favorites : ['home', 'drive', 'map', 'music'])[i];
+ if (!ids.has(id) || hidden.includes(id) || seen.has(id)) return null;
+ seen.add(id); return id;
+ });
+ const modes = Object.fromEntries(applications.filter(a => a.external).map(a => [a.id, raw.modes?.[a.id] === 'same' ? 'same' : raw.modes?.[a.id] === 'embed' && a.id !== 'waze' ? 'embed' : 'new']));
+ return { hidden, favorites, modes };
+}
+export function wazeUrl(destination) {
+ const url = new URL('https://waze.com/ul'); url.searchParams.set('utm_source', 'caria');
+ if (destination && Number.isFinite(destination.lat) && Number.isFinite(destination.lon) && Math.abs(destination.lat) <= 90 && Math.abs(destination.lon) <= 180) {
+ url.searchParams.set('ll', `${destination.lat},${destination.lon}`); url.searchParams.set('navigate', 'yes');
+ }
+ return url.href;
+}
+export function safeWebUrl(value) {
+ try { const u = new URL(value); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password ? u.href : ''; } catch { return ''; }
+}
+export function initApplications({ storage, toast, navigate }) {
+ const $ = s => document.querySelector(s);
+ let preferences, destination = null;
+ function load() { let raw; try { raw = JSON.parse(storage.get('applications', 'null')); } catch {} preferences = normalizePreferences(raw); }
+ load();
+ function urlFor(app) {
+ if (!app.external) return `#${app.id}`;
+ if (app.id === 'waze') return wazeUrl(destination);
+ return app.url;
+ }
+ function configureLink(link, app) {
+ link.onclick=null;
+ const href = urlFor(app);
+ link.dataset.application = app.id;
+ if (href) { link.href = href; link.removeAttribute('aria-disabled'); }
+ else { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); }
+ if (app.external && preferences.modes[app.id] === 'embed') {
+ link.href='#embedded';link.removeAttribute('target');
+ link.onclick=e=>{e.preventDefault();location.hash='embedded';navigate();document.dispatchEvent(new CustomEvent('caria-embed',{detail:app.id}));};
+ }
+ else if (app.external) { link.target = preferences.modes[app.id] === 'same' ? '_self' : '_blank'; link.rel = 'noopener noreferrer'; }
+ else link.dataset.view = app.id;
+ }
+ function icon(app) { const span = document.createElement('span'); span.className = 'icon'; span.textContent = app.icon; span.setAttribute('aria-hidden', 'true'); return span; }
+ function save(next) {
+ const normalized = normalizePreferences(next);
+ if (!storage.set('applications', JSON.stringify(normalized))) { toast('Le navigateur bloque l’enregistrement des applications.'); renderSettings(); return; }
+ preferences = normalized; render(); renderSettings();
+ }
+ function render() {
+ const nav = $('#favorite-apps'); nav.replaceChildren();
+ preferences.favorites.filter(Boolean).forEach(id => {
+ const app = applications.find(a => a.id === id), link = document.createElement('a'); configureLink(link, app);
+ const name = document.createElement('span'); name.textContent = app.id === 'music' ? 'Musique' : app.name;
+ link.append(icon(app), name); nav.append(link);
+ });
+ const catalogLink = document.createElement('a'); catalogLink.href = '#apps'; catalogLink.dataset.view = 'apps';
+ const label = document.createElement('span'); label.textContent = 'Applications';
+ catalogLink.append(icon({ icon: '▦' }), label); nav.append(catalogLink);
+ const catalog = $('#applications-grid'); catalog.replaceChildren();
+ applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => {
+ const card = document.createElement('article'); card.className = 'card application-card';
+ const link = document.createElement('a'); configureLink(link, app); link.className = 'application-open';
+ const name = document.createElement('h2'); name.textContent = app.name;
+ const description = document.createElement('p'); description.textContent = app.description;
+ const hint = document.createElement('span'); hint.className = 'application-kind';
+ hint.textContent = !urlFor(app) ? 'Adresse à configurer dans les réglages' : app.external ? preferences.modes[app.id] === 'embed' ? 'Lecteur intégré · dans CARIA' : preferences.modes[app.id] === 'same' ? 'Site externe · même onglet ↗' : 'Site externe · nouvel onglet ↗' : 'Dans CARIA';
+ link.append(icon(app), name, description, hint);
+ const favorite = document.createElement('button'); favorite.className = 'favorite-toggle';
+ const selected = preferences.favorites.includes(app.id); favorite.textContent = selected ? '★' : '☆';
+ favorite.setAttribute('aria-label', `${selected ? 'Retirer' : 'Ajouter'} ${app.name} ${selected ? 'des' : 'aux'} favoris`); favorite.setAttribute('aria-pressed', String(selected));
+ favorite.onclick = () => {
+ const next = structuredClone(preferences), slot = next.favorites.indexOf(selected ? app.id : null);
+ if (slot < 0) return toast('Quatre favoris maximum. Utilisez Personnaliser pour remplacer un favori.');
+ next.favorites[slot] = selected ? null : app.id; save(next);
+ };
+ card.append(link, favorite); catalog.append(card);
+ });
+ $('#applications-empty').hidden = catalog.childElementCount !== 0;
+ $('#favorite-count').textContent = `${preferences.favorites.filter(Boolean).length} / 4 favoris`;
+ document.querySelectorAll('[data-waze-destination]').forEach(link => {
+ configureLink(link, applications.find(a => a.id === 'waze'));
+ link.hidden = !destination || preferences.hidden.includes('waze');
+ });
+ configureLink($('#stream-link'), applications.find(a=>a.id==='stream'));
+ $('#stream-opening').textContent = preferences.modes.stream === 'embed' ? 'S’ouvre dans le lecteur intégré CARIA, si votre serveur Jellyfin autorise cet affichage.' : preferences.modes.stream === 'same' ? 'S’ouvre dans cet onglet. Utilisez Retour pour retrouver CARIA ; la lecture locale peut être interrompue.' : 'S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.';
+ navigate();
+ }
+ function renderSettings() {
+ const slots = $('#favorite-slots'); slots.replaceChildren();
+ preferences.favorites.forEach((id, index) => {
+ const label = document.createElement('label'); label.textContent = `Favori ${index + 1}`;
+ const select = document.createElement('select'); select.dataset.favoriteSlot = index;
+ select.add(new Option('Aucun', ''));
+ applications.filter(a => !preferences.hidden.includes(a.id)).forEach(app => select.add(new Option(app.name, app.id)));
+ select.value = id || '';
+ select.onchange = () => {
+ const next = structuredClone(preferences), chosen = select.value || null, other = chosen ? next.favorites.indexOf(chosen) : -1;
+ if (other >= 0 && other !== index) next.favorites[other] = id;
+ next.favorites[index] = chosen; save(next);
+ };
+ label.append(select); slots.append(label);
+ });
+ const list = $('#application-settings-list'); list.replaceChildren();
+ applications.forEach(app => {
+ const row = document.createElement('div'); row.className = 'application-setting'; row.dataset.appSetting = app.id;
+ const visible = document.createElement('label'), checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = !preferences.hidden.includes(app.id);
+ checkbox.setAttribute('aria-label', `Afficher ${app.name}`); visible.append(checkbox, document.createTextNode(app.name));
+ checkbox.onchange = () => {
+ const next = structuredClone(preferences); next.hidden = checkbox.checked ? next.hidden.filter(id => id !== app.id) : [...next.hidden, app.id]; save(next);
+ };
+ row.append(visible);
+ if (app.external) {
+ const mode = document.createElement('select'); mode.setAttribute('aria-label', `Ouverture de ${app.name}`);
+ mode.add(new Option('Nouvel onglet', 'new')); mode.add(new Option('Même onglet', 'same')); mode.value = preferences.modes[app.id];
+ if(app.id !== 'waze') mode.add(new Option('Lecteur intégré','embed'));
+ mode.value=preferences.modes[app.id];
+ mode.onchange = () => { const next = structuredClone(preferences); next.modes[app.id] = mode.value; save(next); }; row.append(mode);
+ } else { const hint = document.createElement('span'); hint.textContent = 'Dans CARIA'; row.append(hint); }
+ list.append(row);
+ });
+ }
+ function openSettings() { renderSettings(); $('#application-settings').showModal(); }
+ $('#applications-customize').onclick = openSettings;
+ $('#applications-settings-open').onclick = () => { $('#settings').close(); openSettings(); };
+ $('#application-settings-close').onclick = () => $('#application-settings').close();
+ $('#applications-reset').onclick = () => save(normalizePreferences(null));
+ document.addEventListener('caria-destination', event => { destination = event.detail; render(); });
+ document.addEventListener('caria-stream', render);
+ window.addEventListener('storage', event => { if (event.key === 'caria.applications' || event.key === null) { load(); render(); renderSettings(); } });
+ render();
+}
diff --git a/ui/cockpit.css b/ui/cockpit.css
new file mode 100644
index 0000000..b0a1bb9
--- /dev/null
+++ b/ui/cockpit.css
@@ -0,0 +1,123 @@
+/* Touch layout, reference panel: 1200 wide × 1080 high, 10 inches, landscape. */
+.rail{position:sticky;top:0;height:100dvh;min-height:440px}.rail-collapsed .rail{display:none}.page-top{height:auto;min-height:62px;padding:0 0 20px;border:0;margin-bottom:14px}.header-left{display:flex;align-items:center;gap:18px}#rail-toggle{font-size:25px;min-width:52px;min-height:52px;background:transparent}.page-heading>div:first-child:not(.map-actions){position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.page-heading>.subtle{display:none}.page-heading{justify-content:flex-end;margin-bottom:16px}.page-heading:has(>div:only-child),#home>.page-heading,#drive>.page-heading{margin-bottom:14px}#home>.page-heading{display:none}main{padding:22px 26px}.rail nav a{min-height:72px}.rail nav a span:last-child{font-size:12px}.page-top .wordmark{font-size:21px}.page-top .wordmark span{font-size:8px}.page-top .header-right{gap:16px}.view-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}.view-toolbar a{display:inline-flex;align-items:center;min-height:48px;color:var(--accent);font-size:14px}.cluster{padding:18px 12px 0}.gauges{grid-template-columns:minmax(0,1fr) 62px minmax(0,1fr);max-width:none;gap:0}.gauge svg{max-height:none;aspect-ratio:300/265}.gauge .dial-value{font-size:44px}.gauge .dial-label{font-size:10px}.gear strong{font-size:54px}.cluster-top{padding:0 12px;margin-bottom:14px}.cluster-bottom{margin-top:14px;padding:16px 12px;font-size:13px}.quick-sensors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:22px 0}.quick-sensor{padding:24px;display:flex;align-items:center;gap:20px}.quick-sensor>.sensor-icon{font-size:31px;color:var(--accent);width:42px}.quick-sensor .eyebrow{font-size:12px;letter-spacing:1px}.quick-sensor h2{font-size:46px;line-height:1.2;margin:8px 0 0}.quick-sensor h2 small{font-size:20px;color:var(--muted)}.quick-sensor p:last-child{font-size:12px;color:var(--muted);margin:8px 0 0}.drive-links{display:flex;justify-content:space-between;gap:15px;align-items:center}.drive-links a{min-height:52px}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-grid .card{padding:26px}.detail-grid h2{font-size:18px}.detail-grid strong{font-size:36px;font-weight:500}.detail-grid p{font-size:14px;color:var(--muted)}.details-notice{color:var(--muted);font-size:13px;line-height:1.7}.player-view-link{display:inline-flex;min-height:48px;align-items:center;justify-content:center;margin-top:15px;color:var(--accent);font-size:14px}.embed-frame{border:1px solid var(--border);border-radius:16px;width:100%;min-height:500px;background:var(--surface)}.embed-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}.embed-controls input{flex:1;min-width:180px;min-height:48px}.embed-controls button,.embed-controls a{min-height:48px}.embed-notice{font-size:13px;color:var(--muted);line-height:1.6}.embedded-card{padding:24px}.embedded-card iframe{display:block}.embedded-card iframe[data-provider=spotify]{min-height:352px}.embedded-card iframe[data-provider=youtube-music],.embedded-card iframe[data-provider=youtube]{aspect-ratio:16/9;min-height:220px}
+@media(min-width:900px) and (min-height:950px){.home-grid{grid-template-columns:1fr 1fr}.clock-card{min-height:355px}.weather-card{min-height:355px}.home-grid .vehicle-card,.home-grid .music-card{min-height:265px}.clock-card>time{font-size:100px}.application-open{min-height:230px}.cluster{margin-top:12px}.quick-sensors{margin-top:28px}.quick-sensor{min-height:152px}.player-grid{grid-template-columns:1fr 1fr}.album-art.large{width:230px}.library{min-height:620px}}
+@media(max-width:759px){main{padding:15px}.page-top{gap:8px;min-height:54px;padding-bottom:12px}.page-top .header-left{gap:7px}.page-top .header-right{gap:5px}.page-top .wordmark{font-size:15px;letter-spacing:2px}.page-top .wordmark span{display:none}.page-top .badge{font-size:7px;max-width:100px;white-space:normal}.page-top .header-right time{font-size:12px}#rail-toggle{min-width:44px;padding:5px}.gauges{grid-template-columns:1fr 1fr}.gear{grid-column:1/-1;grid-row:2;display:flex;gap:14px}.gear strong{font-size:32px}.quick-sensor{padding:16px;gap:8px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:32px}.quick-sensor .eyebrow{font-size:10px}.detail-grid{grid-template-columns:1fr}.drive-links{flex-wrap:wrap}.cluster-bottom{font-size:10px}.quick-sensors{gap:10px}.view-toolbar{flex-wrap:wrap}}
+@media(max-height:650px) and (min-width:760px){.page-top{margin-bottom:8px;padding-bottom:6px}.gauge svg{max-height:260px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:14px 0}.quick-sensor{padding:14px;gap:0}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:30px}.quick-sensor .eyebrow{font-size:9px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin-bottom:0}.cluster-bottom{padding:10px;font-size:11px}.rail nav a{min-height:55px}}
+@media(min-width:1000px) and (min-height:651px){.gauge svg{max-height:440px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:20px 0}.quick-sensor{padding:22px 16px;min-height:140px}.quick-sensor>.sensor-icon{display:none}.quick-sensor h2{font-size:39px}.quick-sensor .eyebrow{font-size:11px}.quick-sensor p:last-child{font-size:11px}}
+
+/* Shared local player: centered controls inside a cropped circular progress arc. */
+.drive-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.drive-actions .profile-label{margin:0}.drive-actions select{min-height:48px}
+.immersive-player{max-width:920px;margin:auto;padding:20px 28px 28px;text-align:center;background:radial-gradient(ellipse at 50% 35%,color-mix(in srgb,var(--accent) 8%,transparent),transparent 70%)}
+.immersive-stage{position:relative;width:100%;max-width:780px;aspect-ratio:600/430;margin:auto}
+.music-orbit{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
+.orbit-track,#track-arc{fill:none;stroke:var(--border);stroke-width:7;stroke-linecap:round}
+#track-arc{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;transition:stroke-dashoffset .2s linear}
+.immersive-caption{position:absolute;top:43%;left:12%;right:12%}
+.immersive-caption h2{font-size:clamp(18px,2.4vw,30px);line-height:1.25;margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
+.immersive-caption p{font-size:13px;color:var(--muted);margin:12px 0 0}
+.immersive-controls{position:absolute;top:68%;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:18px}
+.immersive-player button{display:inline-flex;align-items:center;justify-content:center;min-width:54px;min-height:54px;padding:12px;border-radius:16px}
+.immersive-player button svg{width:25px;height:25px;flex-shrink:0}
+.immersive-controls #immersive-play{width:82px;height:82px;border-radius:50%;background:var(--accent);color:var(--ink);box-shadow:0 4px 26px color-mix(in srgb,var(--accent) 18%,transparent)}
+#immersive-play svg{width:32px;height:32px}
+.immersive-times{position:absolute;left:4%;right:4%;bottom:5%;display:flex;justify-content:space-between;font-size:16px;font-variant-numeric:tabular-nums;color:var(--muted)}
+.immersive-progress{max-width:680px;margin:-8px auto 6px}
+.immersive-player input[type=range]{height:44px;min-width:0;width:100%;margin:0;accent-color:var(--accent);cursor:pointer}
+.immersive-bottom{display:flex;align-items:center;justify-content:space-between;gap:24px;max-width:680px;margin:auto}
+.immersive-secondary{display:flex;gap:10px}
+.immersive-player button[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
+.immersive-volume{display:flex;align-items:center;gap:12px;width:340px;max-width:100%}
+.immersive-volume input{flex:1}.immersive-volume output{min-width:48px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
+@media(max-width:600px){.immersive-player{padding:12px}.immersive-stage{min-height:300px}.immersive-caption{top:39%;left:8%;right:8%}.immersive-caption p{font-size:11px;margin-top:8px}.immersive-controls{top:65%;gap:12px}.immersive-controls #immersive-play{width:66px;height:66px}.immersive-times{bottom:1%;font-size:13px}.immersive-progress{margin:8px auto}.immersive-bottom{flex-wrap:wrap;justify-content:center;gap:12px}.immersive-volume{width:100%}}
+@media(max-height:700px) and (min-width:760px){.immersive-player{max-width:820px;padding:8px 24px 12px}.immersive-stage{max-width:440px}.immersive-caption h2{font-size:18px}.immersive-caption p{font-size:11px;margin-top:6px}.immersive-controls{top:67%;gap:14px}.immersive-controls #immersive-play{width:58px;height:58px}.immersive-times{bottom:0}.immersive-progress{margin:0 auto}.immersive-bottom{gap:16px}}
+@media(prefers-reduced-motion:reduce){#track-arc{transition:none}}
+/* Viewport-sized cockpit. Long collections use explicit pages. */
+html,body{height:100%;overflow:hidden}.shell{height:100dvh;min-height:0}.workspace{height:100%;min-height:0}main{height:100%;min-height:0;display:flex;flex-direction:column;padding:12px 22px;margin:0 auto}.page-top{flex-shrink:0;min-height:52px;padding:0;margin-bottom:10px}.view:not([hidden]){flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}.view>.page-heading{flex-shrink:0;margin:0}.view>.page-heading:has(>div:only-child){display:none}.view-toolbar{margin:0;flex-shrink:0}.rail{min-height:0;padding-top:12px;padding-bottom:12px;gap:8px}.rail nav{gap:6px}.rail nav a{min-height:52px}.rail-collapsed .page-top{min-height:44px;height:44px;margin-bottom:4px}.rail-collapsed .header-right .badge,.rail-collapsed .header-temp{display:none}.rail-collapsed #rail-toggle{min-height:44px;height:44px}.rail-collapsed main{padding-top:6px}
+#settings footer{padding:12px 0;flex-wrap:wrap;margin:14px 0}#settings footer>span:last-child{display:none}
+.home-grid{flex:1;min-height:0;grid-template-rows:1.1fr 1fr}.home-grid>.card{min-height:0!important}.clock-card>time{font-size:clamp(52px,9vh,100px)}.weather-main{margin:8px 0}.home-grid .card{padding:18px}.home-grid .album-art{width:90px}.home-grid .music-card{gap:14px}
+#drive .cluster{flex:1;min-height:0;display:flex;flex-direction:column;margin:0}.gauges{flex:1;min-height:0;width:100%}.gauge{height:100%;min-height:0}.gauge svg{height:100%;max-height:none!important;width:100%;aspect-ratio:auto}.cluster-top,.cluster-bottom{flex-shrink:0}.cluster-bottom{margin-top:4px;padding:10px}.quick-sensors{flex-shrink:0;margin:0!important;gap:12px}.quick-sensor{min-height:0!important;padding:14px!important}.quick-sensor h2{font-size:clamp(26px,4vh,42px)}.drive-links{flex-shrink:0;flex-wrap:wrap}.drive-links a{display:inline-flex;align-items:center}.drive-actions{gap:12px}
+#now-playing .immersive-player{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:10px 24px 18px;gap:8px}#now-playing .immersive-stage{flex:1;min-height:0;width:100%;max-width:min(780px,calc((100dvh - 230px)*1.395));margin:0;aspect-ratio:600/430;max-height:559px}#now-playing .immersive-bottom{flex-shrink:0;width:100%;margin:0;z-index:1}.immersive-stage:before{content:'';position:absolute;inset:20% 15% 10%;border-radius:50%;background:radial-gradient(ellipse,color-mix(in srgb,var(--accent) 17%,transparent),transparent 70%);opacity:.65;pointer-events:none;animation:music-halo 7s ease-in-out infinite;animation-play-state:paused}.playing .immersive-stage:before{animation-play-state:running}@keyframes music-halo{50%{opacity:1;transform:scale(1.06)}}@media(prefers-reduced-motion:reduce){.immersive-stage:before{animation:none}}
+.player-grid{flex:1;min-height:0}.player-grid>.card{min-height:0!important}.player{display:flex;flex-direction:column;justify-content:center;align-items:center}.player>*{flex-shrink:0}.player .album-art.large{width:min(170px,20vh);margin-bottom:10px}.player h2{font-size:22px;margin:0}.player p{margin:8px 0}.player audio{height:44px}.library{display:flex;flex-direction:column}#playlist{flex:1;min-height:0;max-height:none;overflow:hidden;margin:10px 0}.collection-pages{display:flex;justify-content:center;align-items:center;gap:16px;flex-shrink:0;min-height:48px}.collection-pages span{font-size:12px;color:var(--muted)}.collection-pages button{min-width:48px}.detail-grid{flex:1;min-height:0;grid-template-columns:repeat(4,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));gap:12px}.detail-grid .card{padding:16px}.detail-grid strong{font-size:30px}.detail-grid p{font-size:12px}.details-notice{margin:0;font-size:11px}#applications-grid{flex:1;min-height:0;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:minmax(0,1fr)}.catalog-heading{margin:0}.application-open{min-height:0!important;height:100%;padding:16px}.application-open>.icon{margin-bottom:10px}.application-open p{min-height:0;margin-bottom:8px}#apps>.subtle{margin:0}
+.journey-grid{flex:1;min-height:0}.map-column{display:flex;flex-direction:column;min-height:0}#map-canvas{flex:1;min-height:100px;height:auto!important}.journey-panel{min-height:0;overflow:auto}.embedded-card{flex:1;min-height:0;display:flex;flex-direction:column}.embed-controls{margin-bottom:8px}#embed-container{flex:1;min-height:0}#embed-container iframe{height:100%;min-height:0}.stream-card{margin:auto 0}
+@media(max-height:750px){.home-grid .clock-card,.home-grid .weather-card{padding:14px}.home-grid .weather-card>p{margin-bottom:6px}.home-grid .music-card h2{font-size:17px}.home-grid .vehicle-card h2{display:none}.home-grid .clock-bottom{bottom:14px}.home-grid .weather-bottom{padding-top:8px}.home-grid .weather-main strong{font-size:40px}.home-grid .weather-sun{font-size:40px}.quick-sensor p:last-child{margin-top:4px}.quick-sensors{grid-template-columns:repeat(4,minmax(0,1fr))}.quick-sensor .eyebrow{font-size:9px}.quick-sensor h2{font-size:28px;margin-top:4px}.quick-sensor p:last-child{font-size:10px}.cluster-top{margin:0}.view:not([hidden]){gap:8px}.player .album-art.large{width:80px}.player p{font-size:11px}.detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.detail-grid h2{font-size:15px}.detail-grid strong{font-size:25px}.detail-grid p{font-size:11px;line-height:1.4}.detail-grid .card{padding:12px}.application-open p{display:none}.application-open>.icon{width:36px;height:36px;font-size:23px}.application-open h2{font-size:16px}.application-open{padding:12px}.application-kind{font-size:9px}.catalog-heading{display:none}}
+@media(max-width:759px){main{padding:8px}.home-grid{grid-template-rows:1fr 1fr}.home-grid .card{padding:14px}.player-grid{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr)}.player .album-art.large,.player .eyebrow,.player #audio-message{display:none}.player{padding:12px}.player h2{font-size:17px}.player-view-link{margin-top:0}.player p{margin:4px 0}.quick-sensors{grid-template-columns:1fr 1fr}.gear{padding:0}.detail-grid{grid-template-columns:1fr 1fr}.details-notice{font-size:10px}.drive-links>a:last-child{font-size:11px;min-height:44px}.drive-actions{gap:6px}.drive-actions .pill{padding:10px;font-size:10px}.profile-label{gap:4px}#applications-grid{grid-template-columns:1fr 1fr}.application-open h2{font-size:15px}.application-open p{display:none}.application-open{padding:12px}#now-playing .immersive-stage{max-width:100%;flex:0 1 auto;min-height:280px}#now-playing .immersive-bottom{gap:8px}.immersive-caption h2{font-size:18px}.journey-grid{grid-template-columns:1fr}.journey-panel{max-height:40vh}}
+main{width:100%}
+dialog[open]{display:flex;flex-direction:column;overflow:hidden;max-height:calc(100dvh - 24px)}.journey-panel{display:flex;flex-direction:column;overflow:hidden;max-height:none}.panel-content{flex:1;min-height:0;overflow:hidden}.panel-pages{flex-shrink:0;margin-top:8px}.panel-content>form{margin:0}.rail nav a span:last-child{font-size:11px}
+@media(max-width:759px){.journey-grid{grid-template-rows:minmax(120px,1fr) minmax(220px,1fr)}.journey-panel{max-height:none}.rail nav a span:last-child{font-size:9px}}
+/* Dial appearance and touch menu refinements. */
+dialog[open]{display:block;overflow:auto;overscroll-behavior:contain}#apps{overflow-y:auto;overscroll-behavior:contain}#applications-grid{flex:none;grid-auto-rows:auto}.application-open{min-height:180px!important}#apps>.page-heading{position:sticky;top:0;z-index:2;background:var(--bg);padding-bottom:8px}
+.page-top .header-left{height:44px;display:flex;align-items:center}#rail-toggle,.rail-collapsed #rail-toggle{display:grid;place-items:center;width:44px;min-width:44px;height:44px;min-height:44px;padding:0;line-height:1;font-size:24px;flex-shrink:0}.rail-collapsed .page-top{gap:12px;align-items:center}.rail-collapsed .header-right{height:44px;gap:12px}.rail-collapsed #fullscreen{height:44px;min-height:44px;padding:0 8px}
+.immersive-stage:before{inset:5% -8% -5%;background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 32%,transparent) 0%,color-mix(in srgb,var(--accent) 13%,transparent) 38%,transparent 70%);opacity:.8}.immersive-player{isolation:isolate}@keyframes music-halo{50%{opacity:1;transform:scale(1.09)}}
+.gauge text{font-size:15px;font-weight:600}.gauge .dial-value{font-size:49px;font-weight:600;letter-spacing:-1px}.gauge .dial-label{font-size:11px;font-weight:500;letter-spacing:.6px}.gauge .major{stroke-width:2.3}.dial-light{fill:none;stroke:transparent;stroke-width:2.5;pointer-events:none}.luminous-dials .dial-light{stroke:var(--accent);filter:drop-shadow(0 0 4px var(--accent))}.luminous-dials .gauge .major,.luminous-dials .gauge .tick{stroke:var(--accent)}.luminous-dials .gauge text{fill:var(--accent)}.luminous-dials .gauge .dial-value{fill:var(--text)}.luminous-dials .dial-rim{stroke:color-mix(in srgb,var(--accent) 30%,var(--border))}.luminous-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 6%,transparent),transparent 65%)}
+.quick-sensor>div{flex:1;min-width:0}.sensor-level{height:9px;background:var(--border);border-radius:8px;overflow:hidden;margin-top:10px}.sensor-level>span{display:block;height:100%;width:0;background:var(--accent);border-radius:inherit;transition:width .4s ease}.quick-sensor .sensor-level+p{font-size:11px}@media(prefers-reduced-motion:reduce){.sensor-level>span{transition:none}}
+/* Analog instruments and needle-free digital instruments. */
+.gauges{grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr)}.gear strong{font-size:82px;font-weight:500}.gear .eyebrow{font-size:12px}.gear>span{font-size:10px}.gauge .dial-number{font-size:16px}.digital-track,.digital-progress{display:none;fill:none;stroke-width:9;stroke-linecap:round}.digital-dials .digital-track{display:block;stroke:color-mix(in srgb,var(--accent) 13%,var(--surface))}.digital-dials .digital-progress{display:block;stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 5px var(--accent));transition:stroke-dashoffset .45s linear}.digital-dials .needle,.digital-dials .needle-hub,.digital-dials .tick,.digital-dials .dial-number,.digital-dials .dial-rim,.digital-dials .dial-light{display:none}.digital-dials .gauge .dial-value{font-size:59px;transform:translateY(-40px);fill:var(--text);font-weight:500;letter-spacing:-2px}.digital-dials .gauge .dial-label{font-size:12px;fill:var(--accent)}.digital-dials .gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 14%,transparent),transparent 64%)}
+.aux-gauges{display:flex;justify-content:center;gap:clamp(24px,6vw,100px);flex-shrink:0;margin:0 auto 8px;width:min(100%,650px)}.aux-gauge{position:relative;text-align:center;flex:1;max-width:250px;min-width:0}.aux-analog{display:block;width:160px;height:96px;margin:auto;overflow:visible}.aux-analog path{fill:none;stroke:var(--muted);stroke-width:2}.aux-analog .aux-ticks{stroke:var(--text)}.aux-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:80px 76px;transition:transform .45s}.aux-analog circle{fill:var(--accent)}.aux-title{font-size:12px;color:var(--muted);margin:4px 0}.aux-gauge strong{font-size:18px;font-weight:400}.aux-gauge strong b{font-weight:600}.aux-gauge .sensor-level{display:none}.aux-limits{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin:6px auto 0;max-width:160px}.digital-dials .aux-analog{display:none}.digital-dials .aux-gauge .sensor-level{display:block;height:12px;box-shadow:0 0 14px color-mix(in srgb,var(--accent) 15%,transparent)}.digital-dials .aux-gauge .sensor-level>span{box-shadow:0 0 10px var(--accent)}.digital-dials .aux-gauge strong{font-size:28px}.digital-dials .aux-limits{max-width:none}.digital-dials .aux-gauge{padding:12px 8px}.digital-dials .gear strong{color:var(--accent);text-shadow:0 0 22px color-mix(in srgb,var(--accent) 45%,transparent)}
+@media(max-height:750px){.aux-analog{width:112px;height:67px}.aux-gauges{margin-bottom:4px;max-width:480px}.aux-title{font-size:10px}.aux-gauge strong{font-size:15px}.aux-limits{margin-top:2px;font-size:9px}.digital-dials .aux-gauge{padding:2px 8px}.digital-dials .aux-gauge strong{font-size:23px}.gear strong{font-size:62px}}
+@media(max-width:759px){.gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.gear{grid-column:2;grid-row:1;display:block}.gauge:last-child{grid-column:3;grid-row:1}.gear strong{font-size:40px}.gear .eyebrow{font-size:8px}.gear>span{font-size:7px}.aux-gauges{gap:20px}.aux-analog{width:110px;height:66px}}
+@media(prefers-reduced-motion:reduce){.digital-progress,.aux-needle{transition:none!important}}
+.digital-unit{display:none}.digital-dials .digital-unit{display:block}.digital-dials .analog-unit{display:none}
+.digital-scale{display:none}.digital-dials .digital-scale{display:block}.digital-scale line{stroke:var(--accent);stroke-width:1.3}.gauge .digital-scale text{font-size:10px;font-weight:600;fill:var(--accent)}.digital-dials .gauge .dial-value{font-size:48px}.luminous-dials .tick:not(.major){display:none}
+.sensor-segments{display:none}.digital-dials .sensor-segments{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-top:10px;height:20px}.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr repeat(4,minmax(0,1fr))}.sensor-segments i{border:1px solid var(--border);border-radius:4px;background:var(--surface2);--segment-color:#7acb9b}.sensor-segments i.filled{background:var(--segment-color);border-color:var(--segment-color);box-shadow:0 0 10px color-mix(in srgb,var(--segment-color) 28%,transparent)}[data-level=fuelPercent] .sensor-segments i:first-child{--segment-color:#ed6b60}[data-level=fuelPercent] .sensor-segments i:nth-child(2){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:first-child{--segment-color:#74b9ef}[data-level=coolantCelsius] .sensor-segments i:nth-child(2){--segment-color:#70c7cf}[data-level=coolantCelsius] .sensor-segments i:nth-child(4){--segment-color:#e7b65a}[data-level=coolantCelsius] .sensor-segments i:nth-child(5){--segment-color:#ed6b60}.aux-mark{fill:var(--text);text-anchor:middle;font:600 12px system-ui}.aux-analog .temperature-hot{stroke:url(#temperature-red);stroke-width:6;stroke-linecap:round}.aux-gauge:not(.digital-dials .aux-gauge) .aux-limits{visibility:hidden}.digital-dials .aux-limits{visibility:visible}.reserve strong{color:#ed6b60}
+.page-top .header-temp,.rail-collapsed .page-top .header-temp{display:inline-block;white-space:nowrap}@media(max-width:759px){.page-top .header-temp{font-size:12px}.page-top .header-temp small{display:none}.page-top .header-right .badge{display:none}}
+/* Shared luminous graduations: labelled twenties, intermediate tens. */
+.digital-dials .gauge .dial-number{display:block;fill:var(--accent);font-size:16px}.digital-dials .gauge .tick.major,.digital-dials .gauge .tick.intermediate,.luminous-dials .gauge .tick.intermediate{display:block;stroke:var(--accent)}.gauge .tick.intermediate{stroke-width:1.6}.digital-dials .gauge .dial-value{font-size:46px}.rpm-red-zone{fill:none;stroke:url(#rpm-red-zone);stroke-width:7;stroke-linecap:round;pointer-events:none}.digital-dials .rpm-red-zone,.luminous-dials .rpm-red-zone{filter:drop-shadow(0 0 3px #ef535977)}
+.aux-analog .fuel-reserve-zone{fill:none;stroke:url(#fuel-reserve-red);stroke-width:6;stroke-linecap:round}
+.aux-title{display:flex;justify-content:center;align-items:center}.aux-title svg{width:24px;height:24px;color:var(--accent)}.gauge .tick.quarter{display:none}.aux-limits{font-weight:600}@media(max-height:750px){.aux-title svg{width:20px;height:20px}}
+.gear strong{border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border));border-radius:14px;background:var(--surface2);line-height:1.1;padding:10px 4px;min-width:0}.gear .eyebrow{margin-bottom:10px}
+body:not(.digital-dials) .aux-gauges{--aux-size:clamp(150px,23vh,240px);width:var(--aux-size);height:calc(var(--aux-size) + 22px);max-width:none;gap:0;position:relative;margin-bottom:4px}body:not(.digital-dials) .aux-gauge{width:50%;max-width:none;flex:none;height:100%}body:not(.digital-dials) .aux-analog{position:absolute;top:0;width:var(--aux-size);height:var(--aux-size);max-width:none;pointer-events:none}body:not(.digital-dials) [data-level=fuelPercent] .aux-analog{left:0}body:not(.digital-dials) [data-level=coolantCelsius] .aux-analog{right:0}body:not(.digital-dials) .aux-title{position:absolute;top:48%;left:0;right:0;margin:0;z-index:1}body:not(.digital-dials) .aux-gauge>strong{position:absolute;top:88%;left:0;right:0;font-size:16px}body:not(.digital-dials) .aux-limits{display:none}.aux-gauge.red-zone .aux-title svg{color:#fa4d5b;filter:drop-shadow(0 0 3px #fa4d5b55)}
+@media(max-width:759px){.gear strong{border-radius:9px;padding:7px 2px}}
+/* Keep a small physical-looking gap between the two half-dials. */
+body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-5px)}
+body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(5px)}
+#settings .theme-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);gap:14px}.color-setting{display:flex;align-items:flex-end;gap:8px;min-width:0}.color-setting label{flex:1;min-width:0}.color-setting select{width:100%}#settings #custom-color{flex:0 0 44px;width:44px;height:44px;min-height:44px;padding:8px;border:1px solid var(--border);border-radius:50%;background:var(--surface2);cursor:pointer}#custom-color::-webkit-color-swatch-wrapper{padding:0}#custom-color::-webkit-color-swatch{border:0;border-radius:50%}#custom-color::-moz-color-swatch{border:0;border-radius:50%}
+/* Clearly separated analog instruments, aligned digital level segments. */
+body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-18px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(18px)}body:not(.digital-dials) .aux-gauges:after{content:'';position:absolute;left:50%;top:24%;height:48%;width:1px;background:var(--border);pointer-events:none}
+.digital-dials #speed-gauge svg,.luminous-dials #speed-gauge svg{background:radial-gradient(ellipse at center,color-mix(in srgb,var(--accent) 5%,transparent),transparent 64%)}
+.digital-dials .aux-gauges{width:100%;max-width:none;display:grid;grid-template-columns:minmax(0,1fr) 100px minmax(0,1fr);gap:0;margin:0 0 14px}.digital-dials .aux-gauge{width:min(100%,340px);max-width:none;justify-self:center;padding:0 16px}.digital-dials [data-level=coolantCelsius].aux-gauge{grid-column:3}.digital-dials .sensor-segments,.digital-dials [data-level=fuelPercent] .sensor-segments{grid-template-columns:.5fr 1fr 1fr 1.4fr;height:22px;gap:7px;margin:14px 0 0;align-items:center}.digital-dials .sensor-segments i{height:18px;--segment-color:var(--accent);border-radius:5px}.digital-dials .sensor-segments i:first-child{--segment-color:#ed6b60;transform:translateY(-7px) rotate(12deg)}.digital-dials .sensor-segments i:nth-child(2){transform:translateY(1px) rotate(4deg)}.digital-dials .sensor-segments i:nth-child(3){transform:translateY(1px) rotate(-4deg)}.digital-dials .sensor-segments i:nth-child(4){height:22px;transform:translateY(-7px) rotate(-12deg)}.digital-dials .aux-limits{margin-top:10px}
+@media(max-width:759px){.digital-dials .aux-gauges{grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr)}.digital-dials .aux-gauge{padding:0 4px}.digital-dials .sensor-segments{gap:4px}.digital-dials .aux-gauge strong{font-size:20px}body:not(.digital-dials) [data-level=fuelPercent].aux-gauge{transform:translateX(-14px)}body:not(.digital-dials) [data-level=coolantCelsius].aux-gauge{transform:translateX(14px)}}
+.digital-dials .aux-gauge .sensor-segments i{--segment-color:var(--accent)}.digital-dials .aux-gauge .sensor-segments i:first-child{--segment-color:#ed6b60}.digital-dials #drive .cluster{justify-content:center}.digital-dials .gauges{flex:0 1 auto;height:min(55vh,620px)}.digital-dials .aux-gauges{margin-top:0}.digital-dials .cluster-bottom{margin-top:16px}
+.digital-levels{display:none}.digital-dials .digital-levels{display:block}.digital-dials .aux-gauges{display:none}.digital-dials .gauges{flex:1;height:auto;min-height:0}.digital-dials .level-segment{fill:var(--surface2);stroke:var(--border);stroke-width:1.5;stroke-linejoin:round}.digital-dials .level-segment.filled{fill:var(--accent);stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 25%,transparent))}.digital-dials .level-segment:first-child.filled{fill:#ed6b60;stroke:#ed6b60}.digital-levels .level-icon{color:var(--accent)}.digital-levels.red-zone .level-icon{color:#ed6b60}.gauge .digital-levels .level-end{font-size:10px;fill:var(--muted);font-weight:500}.gauge .digital-levels .level-value{font-size:23px;fill:var(--text);font-weight:600}.digital-dials .gear{align-self:center}
+/* Analog auxiliary instruments integrated into each main dial. */
+body:not(.digital-dials) .aux-gauges{display:none}.analog-levels{display:block}.digital-dials .analog-levels{display:none}.analog-levels .mini-track{fill:none;stroke:var(--muted);stroke-width:2.5}.analog-levels .mini-red{fill:none;stroke:#ed6b60;stroke-width:4;stroke-linecap:round}.analog-levels .mini-ticks{fill:none;stroke:var(--text);stroke-width:1.5}.analog-levels .mini-needle{stroke:var(--accent);stroke-width:3;stroke-linecap:round;transform-origin:150px 226px;transition:transform .45s linear}.gauge .analog-levels .mini-end{font-size:11px;fill:var(--text)}.analog-levels .level-icon{color:var(--accent)}.analog-levels.red-zone .level-icon{color:#ed6b60}.gauge .analog-levels .level-value{font-size:23px;fill:var(--text)}body:not(.digital-dials) .gauge .dial-value{transform:translateY(-23px);font-size:43px}body:not(.digital-dials) .gauge .dial-label:last-of-type{transform:translateY(-22px)}body:not(.digital-dials) .dial-rim{display:none}.luminous-dials .analog-levels .mini-track,.luminous-dials .analog-levels .mini-ticks{stroke:var(--accent)}.luminous-dials .mini-track,.luminous-dials .mini-needle{filter:drop-shadow(0 0 3px var(--accent))}@media(prefers-reduced-motion:reduce){.mini-needle{transition:none!important}}
+.analog-levels .mini-needle{transform-origin:150px 150px}body:not(.digital-dials):not(.luminous-dials) .dial-light{stroke:var(--border);stroke-width:1.5}
+/* Separate the analog auxiliary dials from the main instruments. */
+body:not(.digital-dials) .analog-levels{transform:translateY(22px)}
+body:not(.digital-dials) .analog-levels{transform:none}.analog-levels .mini-needle{transform-origin:150px 308px}.mini-hub{fill:var(--accent)}
+/* Float the gear above the center gap so both instruments gain width. */
+#drive .gauges{position:relative;grid-template-columns:minmax(0,1fr) minmax(0,1fr);column-gap:12px}
+#drive #speed-gauge{grid-column:1;grid-row:1}#drive #rpm-gauge{grid-column:2;grid-row:1}
+#drive .gear{position:absolute;z-index:1;top:10px;left:50%;transform:translateX(-50%);width:86px;display:block;padding:0;grid-column:auto;grid-row:auto}
+#drive .gear strong{font-size:58px;padding:5px 4px}#drive .gear .eyebrow{font-size:10px;margin-bottom:6px}
+@media(max-height:750px){#drive .gear{top:0;width:66px}#drive .gear strong{font-size:42px}#drive .gear .eyebrow{font-size:9px}}
+@media(max-width:759px){#drive .gauges{column-gap:6px}#drive .gear{top:0;width:52px}#drive .gear strong{font-size:34px}#drive .gear .eyebrow{font-size:8px}}
+body:not(.digital-dials) .analog-levels{transform:translateY(-24px)}.analog-separator{fill:none;stroke:color-mix(in srgb,var(--accent) 35%,var(--border));stroke-width:1.2;stroke-linejoin:round;stroke-linecap:round}.digital-dials .analog-separator{display:none}.analog-levels .level-icon{paint-order:stroke;stroke:currentColor}.luminous-dials .analog-separator{stroke:color-mix(in srgb,var(--accent) 55%,var(--border))}
+/* Use the available width and height for all four instruments together. */
+#drive .cluster{padding-left:4px;padding-right:4px;padding-top:8px}#drive .gauges{column-gap:0;overflow:visible}#drive .gauge{overflow:visible}#drive .gauge>svg{overflow:visible}#drive .gear{top:0}
+.analog-separator{stroke:color-mix(in srgb,var(--accent) 65%,var(--muted));stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
+/* Three straight segments, ending on the main dial circumference. */
+body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke:var(--border);stroke-width:1.5;filter:none}
+.luminous-dials .analog-separator{stroke:var(--accent);stroke-width:2.5;filter:drop-shadow(0 0 4px var(--accent))}
+.analog-separator{fill:none;stroke-linecap:butt;stroke-linejoin:miter}
+/* Slightly finer separator while retaining the dial color and glow. */
+body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1.3}
+.luminous-dials .analog-separator{stroke-width:2.2}
+/* Compact illuminated auxiliary arcs, digital style only. */
+.digital-dials .digital-levels .level-segment{fill:none;stroke:var(--border);stroke-width:8;stroke-linecap:round;filter:none}
+.digital-dials .digital-levels .level-segment.filled{fill:none;stroke:var(--accent);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--accent) 45%,transparent))}
+.digital-dials .digital-levels .danger-segment{stroke:color-mix(in srgb,#ed6b60 40%,var(--surface))}
+.digital-dials .digital-levels .danger-segment.filled{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b6060)}
+.level-outline{fill:none;stroke:var(--border);stroke-width:1}.gauge .digital-levels .level-end{font-size:11px}
+.digital-dials .digital-levels .level-segment{fill:var(--border);stroke:none;stroke-width:0;stroke-linejoin:miter}
+.digital-dials .digital-levels .level-segment.filled{fill:var(--accent);stroke:none}
+.digital-dials .digital-levels .danger-segment{fill:color-mix(in srgb,#ed6b60 40%,var(--surface));stroke:none}
+.digital-dials .digital-levels .danger-segment.filled{fill:#ed6b60;stroke:none}
+.level-arc-track,.level-arc-progress,.level-arc-red{fill:none;stroke-width:6;stroke-linecap:round}.level-arc-track{stroke:var(--border)}.level-arc-progress{stroke:var(--accent);stroke-dasharray:100;stroke-dashoffset:100;filter:drop-shadow(0 0 3px var(--accent));transition:stroke-dashoffset .4s linear}.level-arc-red{stroke:#ed6b60;stroke-width:3}.digital-levels.red-zone .level-arc-red{filter:drop-shadow(0 0 3px #ed6b60)}@media(prefers-reduced-motion:reduce){.level-arc-progress{transition:none}}
+.digital-levels .level-tick{fill:none;stroke:var(--text);stroke-width:1.2;stroke-linecap:butt}
+.digital-dials .digital-levels{transform:translateY(-14px)}.digital-levels .level-arc-track{display:none}.digital-levels .level-arc-progress{transition:none}.aux-gauge[data-level=fuelPercent] .aux-title svg{width:17px;height:17px}.aux-gauge[data-level=coolantCelsius] .aux-title svg{width:20px;height:20px}
+body:not(.digital-dials):not(.luminous-dials) .analog-separator{stroke-width:1}
+.luminous-dials .analog-separator{stroke-width:1.6}
+.digital-levels[data-digital-level=coolantCelsius] .level-arc-red{display:none}
+.digital-levels[data-digital-level=coolantCelsius].last-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
+.digital-levels[data-digital-level=fuelPercent].reserve-level .level-arc-progress{stroke:#ed6b60;filter:drop-shadow(0 0 3px #ed6b60)}
+.digital-levels[data-digital-level=fuelPercent] .level-arc-red{display:none}
diff --git a/ui/cockpit.js b/ui/cockpit.js
new file mode 100644
index 0000000..3a5d1d1
--- /dev/null
+++ b/ui/cockpit.js
@@ -0,0 +1,93 @@
+export function initCockpit({ storage, toast }) {
+ const $ = s => document.querySelector(s), audio = $('#audio');
+ const dedicated = new URLSearchParams(location.search).get('display') === 'drive';
+ let collapsed = dedicated || storage.get('railCollapsed') === 'true';
+ function rail() {
+ document.body.classList.toggle('rail-collapsed', collapsed);
+ $('#rail-toggle').setAttribute('aria-expanded', String(!collapsed));
+ $('#rail-toggle').setAttribute('aria-label', collapsed ? 'Afficher les favoris' : 'Masquer les favoris');
+ document.dispatchEvent(new Event('caria-layout'));
+ }
+ $('#rail-toggle').onclick = () => { collapsed = !collapsed; storage.set('railCollapsed', String(collapsed)); rail(); };
+ document.querySelectorAll('.screen-links a').forEach(link => link.onclick = () => $('#settings').close());
+ rail();
+ if (dedicated && !location.hash) location.hash = 'drive';
+ const time = seconds => Number.isFinite(seconds) ? `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2,'0')}` : '0:00';
+ function updatePlayer() {
+ const duration = Number.isFinite(audio.duration) ? audio.duration : 0, progress = duration ? audio.currentTime / duration : 0;
+ $('#track-arc').style.strokeDashoffset = String(100 * (1 - progress));
+ $('#immersive-current').textContent = time(audio.currentTime); $('#immersive-duration').textContent = time(duration);
+ $('#immersive-title').textContent = $('#track-title').textContent;
+ $('#immersive-subtitle').textContent = $('#track-subtitle').textContent;
+ $('#immersive-play').innerHTML = audio.paused ? '' : '';
+ $('#immersive-play').setAttribute('aria-label', audio.paused ? 'Lire la musique' : 'Mettre en pause');
+ $('#now-playing').classList.toggle('playing', !audio.paused);
+ $('#immersive-volume').value = audio.muted ? '0' : String(audio.volume);
+ const silent = audio.muted || audio.volume === 0;
+ $('#immersive-mute').innerHTML = silent ? '' : '';
+ $('#immersive-mute').setAttribute('aria-label', silent ? 'Activer le son' : 'Couper le son');
+ $('#immersive-mute').title = silent ? 'Activer le son' : 'Couper le son';
+ $('#immersive-mute').setAttribute('aria-pressed', String(silent));
+ $('#immersive-volume-value').textContent = Math.round((silent ? 0 : audio.volume) * 100) + ' %';
+ document.querySelectorAll('[data-audio-control]').forEach(button => button.disabled = !audio.getAttribute('src'));
+ }
+ $('#immersive-play').onclick = () => $('#play').click();
+ $('#immersive-previous').onclick = () => $('#previous').click();
+ $('#immersive-next').onclick = () => $('#next').click();
+ $('#immersive-volume').oninput = e => { audio.volume = Number(e.target.value); audio.muted = false; };
+ $('#immersive-mute').onclick = () => { if (audio.volume === 0) { audio.volume = .5; audio.muted = false; } else audio.muted = !audio.muted; };
+ $('#immersive-repeat').onclick = () => { audio.loop = !audio.loop; $('#immersive-repeat').setAttribute('aria-pressed', String(audio.loop)); toast(audio.loop ? 'Répétition du morceau activée' : 'Répétition désactivée'); };
+ for (const event of ['timeupdate','loadedmetadata','durationchange','play','pause','volumechange','emptied','ended']) audio.addEventListener(event,updatePlayer);
+ new MutationObserver(updatePlayer).observe($('#track-title'),{childList:true,subtree:true});
+ audio.addEventListener('error', () => $('#immersive-subtitle').textContent = 'Lecture impossible. Choisissez un autre morceau dans la bibliothèque.');
+ updatePlayer();
+ // Keep lengthy setup panels reachable with large page buttons.
+ for (const panel of document.querySelectorAll('.journey-panel')) {
+ const content = document.createElement('div'), nav = document.createElement('nav');
+ content.className = 'panel-content'; nav.className = 'collection-pages panel-pages';
+ content.append(...panel.childNodes); panel.append(content,nav);
+ const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
+ back.type = next.type = 'button'; back.textContent = '‹'; next.textContent = '›';
+ back.setAttribute('aria-label','Page précédente du panneau'); next.setAttribute('aria-label','Page suivante du panneau');
+ nav.append(back,label,next);
+ function refresh() {
+ if (!panel.getClientRects().length) return;
+ const max = content.scrollHeight - content.clientHeight;
+ nav.hidden = max <= 2;
+ back.disabled = content.scrollTop < 2; next.disabled = content.scrollTop >= max - 2;
+ label.textContent = max <= 2 ? '' : `${Math.floor(content.scrollTop / Math.max(1,content.clientHeight - 60)) + 1} / ${Math.ceil(max / Math.max(1,content.clientHeight - 60)) + 1}`;
+ }
+ back.onclick = () => { content.scrollTop -= Math.max(44,content.clientHeight - 60); refresh(); };
+ next.onclick = () => { content.scrollTop += Math.max(44,content.clientHeight - 60); refresh(); };
+ new ResizeObserver(refresh).observe(content);
+ new MutationObserver(refresh).observe(content,{childList:true,subtree:true,attributes:true,attributeFilter:['open','hidden']});
+ new MutationObserver(refresh).observe(panel,{attributes:true,attributeFilter:['open']});
+ content.addEventListener('scroll',refresh); document.addEventListener('caria-view',refresh);
+ }
+ // Paginate collections rather than shrinking touch targets or scrolling the screen.
+ for (const selector of ['#playlist', '.detail-grid', '.home-grid']) {
+ const container = $(selector), pager = document.createElement('nav');
+ pager.className = 'collection-pages'; pager.setAttribute('aria-label', 'Pages de contenu');
+ const back = document.createElement('button'), next = document.createElement('button'), label = document.createElement('span');
+ back.textContent = '‹'; next.textContent = '›';
+ back.setAttribute('aria-label', 'Page précédente'); next.setAttribute('aria-label', 'Page suivante');
+ pager.append(back,label,next); container.after(pager);
+ let page = 0;
+ function layout() {
+ if (!container.getClientRects().length) return;
+ const items = [...container.children], small = innerWidth < 760;
+ let count = items.length || 1;
+ if (selector === '#playlist') count = Math.max(1, Math.floor(container.clientHeight / 58));
+ if (selector === '.detail-grid') count = small || innerHeight < 750 ? 4 : 8;
+ if (selector === '.home-grid') count = small ? 2 : 4;
+ const pages = Math.max(1,Math.ceil(items.length / count)); page = Math.min(page,pages - 1);
+ items.forEach((item,i) => item.hidden = i < page * count || i >= (page + 1) * count);
+ pager.hidden = pages === 1; label.textContent = `${page + 1} / ${pages}`;
+ back.disabled = page === 0; next.disabled = page === pages - 1;
+ }
+ back.onclick = () => { page--; layout(); }; next.onclick = () => { page++; layout(); };
+ new ResizeObserver(layout).observe(container);
+ new MutationObserver(() => { page = 0; layout(); }).observe(container,{childList:true});
+ document.addEventListener('caria-view',layout); layout();
+ }
+}
diff --git a/ui/embeds.js b/ui/embeds.js
new file mode 100644
index 0000000..d4d904f
--- /dev/null
+++ b/ui/embeds.js
@@ -0,0 +1,47 @@
+import { applications, safeWebUrl } from './applications.js';
+export function embedUrl(provider, value) {
+ const safe = safeWebUrl(value); if (!safe) return '';
+ const url = new URL(safe);
+ if (provider === 'stream') return safe;
+ if (provider === 'spotify' && url.hostname === 'open.spotify.com') {
+ const match = url.pathname.match(/^\/(?:intl-[a-z]+\/)?(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)\/?$/);
+ return match ? `https://open.spotify.com/embed/${match[1]}/${match[2]}` : '';
+ }
+ if (['youtube','youtube-music'].includes(provider) && ['music.youtube.com','www.youtube.com','youtube.com','youtu.be'].includes(url.hostname)) {
+ const video = url.hostname === 'youtu.be' ? url.pathname.slice(1) : url.searchParams.get('v');
+ const list = url.searchParams.get('list');
+ if (video && /^[\w-]{11}$/.test(video)) return `https://www.youtube.com/embed/${video}`;
+ if (list && /^[\w-]{10,200}$/.test(list)) return `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(list)}`;
+ }
+ return '';
+}
+export function initEmbeds({storage,toast}) {
+ const $=s=>document.querySelector(s); let provider=null;
+ function stop() { $('#embed-container').replaceChildren(); $('#embed-status').textContent='Lecteur arrêté. Vous pouvez le charger à nouveau.'; }
+ function load() {
+ if (!provider) return;
+ const url=embedUrl(provider,provider==='stream'?applications.find(app=>app.id==='stream').url:$('#embed-url').value.trim());
+ if (!url) return toast('Lien non reconnu. Choisissez un contenu compatible avec ce lecteur.');
+ if (location.protocol==='https:' && url.startsWith('http:')) return toast('Ce lecteur doit utiliser HTTPS sur votre accès sécurisé.');
+ stop();
+ const frame=document.createElement('iframe');frame.className='embed-frame';frame.dataset.provider=provider;
+ frame.title=`Lecteur ${applications.find(a=>a.id===provider).name}`;frame.allow='autoplay; encrypted-media; fullscreen; picture-in-picture';frame.referrerPolicy='strict-origin-when-cross-origin';
+ frame.setAttribute('sandbox','allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation');
+ frame.src=url;$('#embed-container').append(frame);
+ storage.set(`embed.${provider}`,$('#embed-url').value.trim());
+ $('#embed-status').textContent=provider==='stream' ? 'Si Jellyfin refuse l’affichage, son serveur doit autoriser l’origine CARIA dans sa politique d’intégration. Le lien externe reste disponible.' : 'Le lecteur officiel gère la connexion et les restrictions de lecture. Si le contenu est indisponible, utilisez le lien externe.';
+ }
+ document.addEventListener('caria-embed',e=>{
+ provider=e.detail;
+ const app=applications.find(a=>a.id===provider);if(!app?.external)return;
+ stop();$('#embedded-name').textContent=app.name;
+ $('#embed-form').hidden=provider==='stream';
+ $('#embedded-explanation').textContent=provider==='stream' ? 'Jellyfin dans CARIA. La connexion reste gérée par votre serveur ; son autorisation d’affichage intégré est nécessaire.' : provider==='spotify' ? 'Lecteur officiel Spotify pour un morceau, album, podcast ou playlist. Ce n’est pas l’application Spotify complète ; la lecture dépend de votre compte et des règles du lecteur.' : 'Lecteur vidéo officiel YouTube à partir d’un lien YouTube ou YouTube Music. La vidéo reste visible. L’application YouTube Music complète ne peut pas être intégrée ici.';
+ $('#embed-url').value=provider==='stream'?app.url:storage.get(`embed.${provider}`);
+ $('#embed-external').href=provider==='stream'?app.url:app.url;
+ if (embedUrl(provider,$('#embed-url').value)) load();
+ else $('#embed-status').textContent='Collez le lien d’un contenu pour charger son lecteur dans CARIA.';
+ });
+ $('#embed-form').onsubmit=e=>{e.preventDefault();load();};$('#embed-stop').onclick=stop;
+ document.addEventListener('caria-view',e=>{if(e.detail!=='embedded' && $('#embed-container').childElementCount)stop();});
+}
diff --git a/ui/geo.js b/ui/geo.js
new file mode 100644
index 0000000..75756dc
--- /dev/null
+++ b/ui/geo.js
@@ -0,0 +1,44 @@
+export function distance(a, b) {
+ const rad = Math.PI / 180, lat = (b.lat - a.lat) * rad, lon = (b.lon - a.lon) * rad;
+ const h = Math.sin(lat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(lon / 2) ** 2;
+ return 6371000 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
+}
+export function validateZones(data) {
+ if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.length > 1000) throw new Error('Fichier GeoJSON attendu, 1 000 zones maximum.');
+ const ids = new Set();
+ return data.features.map((f, i) => {
+ const p = f.properties || {}, c = f.geometry?.coordinates;
+ if (f.geometry?.type !== 'Point' || !Array.isArray(c) || c.length !== 2 || !c.every(Number.isFinite) || Math.abs(c[0]) > 180 || Math.abs(c[1]) > 85 || !Number.isFinite(p.radiusM) || p.radiusM < 300 || p.radiusM > 10000 || !/^[A-Z]{2}$/.test(p.country || '') || typeof p.source !== 'string' || !p.source.trim() || !Number.isFinite(Date.parse(p.updatedAt)) || !Number.isFinite(Date.parse(p.expiresAt)) || Date.parse(p.expiresAt) <= Date.parse(p.updatedAt)) throw new Error(`Zone ${i + 1} invalide : coordonnées, pays, rayon (300–10 000 m), source et dates requis.`);
+ const id = String(p.id ?? i).slice(0, 100);
+ if (ids.has(id)) throw new Error('Identifiants de zones en double.'); ids.add(id);
+ return { id, lat: c[1], lon: c[0], radiusM: p.radiusM, country: p.country, name: String(p.name || 'Zone de vigilance').slice(0, 120), source: p.source.slice(0, 150), updatedAt: p.updatedAt, expiresAt: p.expiresAt };
+ });
+}
+export function nearbyZones(zones, position, country, now = Date.now()) {
+ if (!position || position.source !== 'gps' || !Number.isFinite(position.accuracy) || position.accuracy > 100 || !Number.isFinite(position.timestamp) || Math.abs(now - position.timestamp) > 15000) return [];
+ return zones.filter(z => z.country === country && Date.parse(z.updatedAt) <= now && Date.parse(z.expiresAt) > now)
+ .map(z => ({ ...z, remaining: Math.max(0, distance(position, z) - z.radiusM) }))
+ .filter(z => z.remaining <= 400).sort((a, b) => a.remaining - b.remaining);
+}
+export function instruction(step) {
+ const m = step.maneuver || {}, ways = { left: 'à gauche', right: 'à droite', 'slight left': 'légèrement à gauche', 'slight right': 'légèrement à droite', 'sharp left': 'franchement à gauche', 'sharp right': 'franchement à droite', straight: 'tout droit', uturn: 'en faisant demi-tour' };
+ if (m.type === 'arrive') return 'Vous arrivez à destination';
+ if (m.type === 'depart') return `Départ${step.name ? ` sur ${step.name}` : ''}`;
+ if (['roundabout', 'rotary'].includes(m.type)) return `Au rond-point, ${m.exit ? `prenez la sortie ${m.exit}` : 'suivez l’itinéraire'}`;
+ return `${m.type === 'merge' ? 'Insérez-vous' : m.type === 'fork' ? 'Restez' : 'Continuez'} ${ways[m.modifier] || 'sur l’itinéraire'}${step.name ? ` sur ${step.name}` : ''}`;
+}
+// Nearest segment projection gives progress even on long rural road segments.
+export function routeProgress(coords, position) {
+ let best = { away: Infinity, travelled: 0 }, total = 0;
+ const scale = Math.cos(position.lat * Math.PI / 180);
+ for (let i = 1; i < coords.length; i++) {
+ const a = { lon: coords[i - 1][0], lat: coords[i - 1][1] }, b = { lon: coords[i][0], lat: coords[i][1] };
+ const dx = (b.lon - a.lon) * scale, dy = b.lat - a.lat;
+ const fraction = Math.max(0, Math.min(1, (((position.lon - a.lon) * scale) * dx + (position.lat - a.lat) * dy) / (dx * dx + dy * dy || 1)));
+ const point = { lon: a.lon + fraction * (b.lon - a.lon), lat: a.lat + fraction * (b.lat - a.lat) };
+ const length = distance(a, b), away = distance(position, point);
+ if (away < best.away) best = { away, travelled: total + fraction * length };
+ total += length;
+ }
+ return { ...best, total, fraction: total ? best.travelled / total : 0 };
+}
diff --git a/ui/index.html b/ui/index.html
index 051079f..0b0f929 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -3,49 +3,107 @@
CARIA · Cockpit
-
+
-
+
+
+