using System.Threading.Tasks;
using MyInfoMate.Vr.Menu;
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
///
/// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu
/// servi depuis le cache (E3-E4), menu flottant (E5) et télémétrie (E9).
///
/// C'est le successeur de , qui reste à côté : celui-ci
/// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné
/// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre
/// un problème d'appairage avec un problème de rendu.
///
/// Les quatre types retenus au §8 s'ouvrent : Slider, Map et Event
/// (E8), le Parcours rendu comme une Map, et la Video 360 — celle-ci
/// n'est pas un type de section mais une section Video dont le média est une ressource
/// 360, livrée le 2026-09-12. S'y ajoute la Scène 3D (E7). Choisir une section
/// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une
/// panne.
///
/// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie.
///
public class MenuBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance (première fois seulement)")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
async void Start()
{
_head = Camera.main != null ? Camera.main.transform : null;
if (_head == null)
Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " +
"dans l'inspecteur, puis relancez.");
return;
}
var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName);
if (!paired.Ok)
{
Show(paired.Error);
return;
}
pairing = paired.Value;
}
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
_deviceId = pairing.DeviceId;
_content = new ContentService(client, pairing.ConfigurationId, _deviceId);
var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
return;
}
_telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language);
// XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la
// batterie et la version, et ne sait jamais si un casque est tombé.
var fleet = Kiosk.FleetReporter.Attach(
gameObject, client, pairing.DeviceId, pairing.ConfigurationId);
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
await ApplyAsync(loaded.Export);
if (!loaded.FromCache) return;
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
// et ne se voit que s'il apporte quelque chose.
await RefreshContentAsync();
}
///
/// Pose un contenu : le menu, ses vignettes, le décor, puis le téléchargement du
/// reste. Les trois chemins qui changent de contenu — démarrage, rafraîchissement,
/// changement de configuration — passent tous par ici, sinon l'un des quatre gestes
/// finit par manquer à l'un d'eux.
///
async Task ApplyAsync(ConfigurationExport export)
{
BuildMenu(export);
await _menu.LoadImagesAsync(export, _client);
await ApplyBackdrop(export, _client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(export, _client);
}
///
/// Va voir si le contenu a changé, et ne reconstruit que s'il a changé —
/// RefreshAsync rend null quand l'export est identique à celui en
/// cache, et quand le serveur n'a pas répondu.
///
/// ⚠️ À n'appeler qu'aux moments où personne ne regarde le menu : au
/// démarrage, et entre deux visites. Un menu qui se reconstruit sous les yeux d'un
/// visiteur rejoue la cascade d'apparition et déplace ce qu'il visait.
///
async Task RefreshContentAsync()
{
if (_content == null) return;
var fresh = await _content.RefreshAsync();
if (fresh == null || _menu == null) return;
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
await ApplyAsync(fresh);
}
void BuildMenu(ConfigurationExport export)
{
_export = export;
if (_menu == null)
{
_menu = FloatingMenu.Create(_head);
_menu.SectionChosen += OnSectionChosen;
_kiosk = Kiosk.KioskSession.Attach(gameObject, _head);
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
}
///
/// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
///
void StartNewVisit()
{
CloseSection();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
// Le seul moment où reconstruire le menu ne dérange personne. Sans ça, un
// casque allumé toute la journée montre le contenu qu'il avait au démarrage :
// le battement de FleetReporter ne voit qu'un changement de configuration,
// pas une modification de son contenu.
_ = RefreshContentAsync();
}
async void OnSectionChosen(string sectionId)
{
_kiosk?.NotifyActivity();
_telemetry?.MenuItemTap(sectionId);
_telemetry?.SectionView(sectionId);
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
if (section == null) return;
// Une section Video dont le média est une ressource 360 est le seul cas qui
// ne se feuillette pas : elle devient le ciel.
var immersive = ImmersiveResourceOf(section);
if (immersive != null)
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
await _skybox.LoadAsync(immersive, _client);
return;
}
// Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses
// points posés dessus.
if (section.Type == ConfigurationExport.SectionType.Scene3D)
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
}
return;
}
if (!SectionPages.CanRender(section.Type))
{
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
}
///
/// Retour au menu. La durée passée dans la section part avec le
/// SectionLeave : c'est elle qui dit si un contenu retient, et sans elle
/// les stats ne comptent que des ouvertures.
///
///
/// Le manager a assigné une autre configuration à ce casque : on la charge et on
/// repart du menu, sans redémarrer l'app.
///
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
/// n'appartient plus à sa visite.
///
/// ⚠️ Le deviceId repart avec : c'est lui qui désigne le lien de ce casque,
/// donc son agencement — bento, ordre des sections, sections masquées pour le seul
/// canal VR. Sans lui, le casque bascule sur la configuration nue et perd sa mise
/// en page jusqu'au prochain démarrage.
///
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
_content = new ContentService(_client, configurationId, _deviceId);
var loaded = await _content.LoadAsync();
if (!loaded.Ok)
{
Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}");
return;
}
// Avant l'application : recentrer ne dépend que du menu déjà en place, et
// attendre le chargement des vignettes le laisserait le temps d'une requête
// dans le dos du visiteur.
_menu?.Recenter();
_telemetry?.StartNewSession();
await ApplyAsync(loaded.Export);
}
///
/// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un
/// changement de configuration ne doit pas laisser le panorama de l'ancienne
/// visite, et deux fonds superposés font deux vidéos qui tournent.
///
async Task ApplyBackdrop(ConfigurationExport export, ApiClient client)
{
if (_backdrop != null) Destroy(_backdrop.gameObject);
_backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client);
}
void OpenSection(string sectionId)
{
_openedSectionId = sectionId;
_openedAt = Time.time;
_menu.gameObject.SetActive(false);
}
///
/// La ressource 360 d'une section, ou null. Source porte soit un id de
/// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360
/// téléchargeable, donc pas un cas immersif.
///
ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section)
{
if (section.SourceIsUrl) return null;
var resource = _export?.FindResource(section.Source);
return resource != null && resource.IsImmersive ? resource : null;
}
void CloseSection()
{
if (_view != null) Destroy(_view.gameObject);
_view = null;
if (_skybox != null) Destroy(_skybox.gameObject);
_skybox = null;
// Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et
// son GLB continue d'occuper la mémoire du casque.
if (_model != null) Destroy(_model.gameObject);
_model = null;
// Le message d'une section non gérée resterait affiché par-dessus le menu.
if (_message != null) Destroy(_message.gameObject);
_message = null;
if (_openedSectionId != null)
{
_telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt));
_openedSectionId = null;
}
if (_menu != null) _menu.gameObject.SetActive(true);
}
PagedView _view;
SkyboxView _skybox;
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
ContentService _content;
///
/// Ce casque, gardé pour toute la vie de l'app : il désigne le lien de
/// configuration, donc l'agencement propre au canal VR. Tout
/// construit ici doit le recevoir.
///
string _deviceId;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;
void Show(string message)
{
if (_message != null) Destroy(_message.gameObject);
_message = SceneMessage.Show(message, _head);
}
}
}