vr-app/unity-overlay/Assets/Scripts/Net/ContentPreloader.cs
Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

142 lines
5.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Préchargement des médias d'une visite — la seconde moitié de l'item <b>E4</b>.
///
/// <b>Le cache disque seul ne suffit pas.</b> Il se remplissait à la demande : une
/// borne branchée dans une salle sans wifi avait son JSON, ses titres, son menu — et
/// pas une image. Le contenu n'arrivait que si le visiteur ouvrait la section
/// pendant que le réseau était là, ce qui est exactement l'inverse de ce qu'on
/// promet. C'est l'argument n°1 qui a fait choisir Unity contre WebXR (§10 du plan) ;
/// sans cette classe, il n'était pas tenu.
///
/// Trois règles, toutes dictées par l'exploitation :
/// <list type="bullet">
/// <item><b>Après le menu, jamais avant.</b> Le visiteur voit son menu en une
/// seconde ; le téléchargement se fait derrière lui. Bloquer le démarrage sur
/// des gigaoctets de vidéo 360 ferait d'une borne un écran d'attente.</item>
/// <item><b>Un par un.</b> Dix téléchargements parallèles sur le wifi d'un musée se
/// gênent, et une 360 de 2 Go n'a rien à gagner à partager la bande passante
/// avec neuf vignettes.</item>
/// <item><b>Un échec n'arrête rien.</b> Chaque média manquant sera retenté au
/// prochain démarrage ; les autres sont déjà là.</item>
/// </list>
/// </summary>
public static class ContentPreloader
{
public struct Progress
{
public int Done;
public int Total;
public int Failed;
/// <summary>Médias déjà sur le disque au démarrage — jamais retéléchargés.</summary>
public int AlreadyCached;
}
/// <summary>
/// Télécharge tout ce que le casque saura afficher hors ligne. Rend le bilan, qui
/// n'a d'autre usage que le journal : rien de tout ceci ne doit se voir.
/// </summary>
public static async Task<Progress> RunAsync(
ConfigurationExport export, ApiClient client, Action<Progress> advanced = null)
{
var urls = MediaUrlsOf(export);
var progress = new Progress { Total = urls.Count };
if (urls.Count == 0) return progress;
Debug.Log($"[Preload] {urls.Count} médias à mettre en cache.");
foreach (var url in urls)
{
if (ContentCache.CachedPath(url) != null)
{
progress.AlreadyCached++;
progress.Done++;
}
else if (await ContentCache.MediaPathAsync(client, url) != null)
{
progress.Done++;
}
else
{
// Volontairement silencieux côté visiteur : ContentCache a déjà
// journalisé la cause, et un média manquant n'invalide pas la visite.
progress.Failed++;
}
advanced?.Invoke(progress);
}
Debug.Log($"[Preload] Terminé — {progress.Done}/{progress.Total} disponibles " +
$"hors ligne ({progress.AlreadyCached} déjà en cache, " +
$"{progress.Failed} en échec), {ContentCache.SizeBytes() / 1048576} Mo sur le disque.");
return progress;
}
/// <summary>
/// Toutes les URL téléchargeables de la configuration, sans doublon et dans un
/// ordre utile : <b>les vignettes du menu d'abord</b>, parce que ce sont elles
/// qu'on voit en premier, et les gros médias ensuite.
///
/// ⚠️ Les types <c>ImageUrl</c> / <c>VideoUrl</c> sont <b>écartés</b> : ce sont des
/// liens externes (YouTube, Vimeo), qui ne se mettent pas en cache et ne
/// s'affichent pas hors ligne. Les PDF et les JSON aussi — le casque ne les rend
/// pas (§8 du plan), les télécharger ne ferait que remplir le disque.
/// </summary>
public static List<string> MediaUrlsOf(ConfigurationExport export)
{
var urls = new List<string>();
var seen = new HashSet<string>();
void Add(string url)
{
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return;
if (seen.Add(url)) urls.Add(url);
}
if (export?.Sections != null)
foreach (var section in export.Sections)
{
Add(section.ImageSource);
Add(section.Model3DSource);
if (section.Points != null)
foreach (var point in section.Points)
Add(point.ImageUrl);
}
if (export?.Resources != null)
foreach (var resource in export.Resources)
if (IsDownloadable(resource.Type))
Add(resource.Url);
return urls;
}
static bool IsDownloadable(ConfigurationExport.ResourceKind kind)
{
switch (kind)
{
case ConfigurationExport.ResourceKind.Image:
case ConfigurationExport.ResourceKind.Video:
case ConfigurationExport.ResourceKind.Audio:
case ConfigurationExport.ResourceKind.Image360:
case ConfigurationExport.ResourceKind.Video360:
case ConfigurationExport.ResourceKind.Model3D:
return true;
default:
return false;
}
}
}
}