using System;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
///
/// Lecture 360° — item E6 du lot XR-4, et la seule chose que le casque
/// fait mieux que tout le reste.
///
/// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est
/// dedans, il tourne la tête et le monde suit. C'est ce qu'aucune tablette ne
/// sait faire, et c'est pour ça que le plan (§8) dit que la Video 360 « gagne
/// vraiment » là où un Article flottant est moins bon qu'un écran.
///
/// Les deux cas passent par le même shader de skybox, donc le même rendu : une image
/// devient une texture, une vidéo devient une RenderTexture qu'un
/// VideoPlayer alimente image par image.
///
/// ⚠️ Ce shader doit être dans Always Included Shaders, comme
/// Universal Render Pipeline/Unlit : construit au runtime, il n'est référencé
/// par aucune scène et le build l'élimine en silence. Le ciel sort alors magenta sur
/// le casque et correct dans l'éditeur — le piège déjà payé une fois avec glTFast
/// (voir le README de l'overlay).
///
public class SkyboxView : MonoBehaviour
{
/// Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2.
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _skybox;
Material _previousSkybox;
VideoPlayer _player;
RenderTexture _target;
Texture2D _image;
MenuItemPanel _back;
Transform _head;
string _language;
public event Action Closed;
public event Action Interacted;
public static SkyboxView Create(Transform head, string language)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent();
view._head = head;
view._language = language;
if (head != null)
{
var forward = head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
root.transform.position = new Vector3(head.position.x, 0f, head.position.z);
root.transform.rotation = Quaternion.LookRotation(forward.normalized);
}
var selector = root.AddComponent();
selector.Configure(head);
selector.Selected += view.OnSelected;
view.Build();
return view;
}
void Build()
{
_previousSkybox = RenderSettings.skybox;
_skybox = PanoramicSky.CreateMaterial();
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, _language), transform);
PlaceBack();
}
///
/// Le retour ne reste pas affiché. Toute la valeur d'une 360 est d'y être :
/// un panneau planté en permanence dans le décor rappelle qu'on regarde une image,
/// et c'est précisément ce qu'on cherchait à faire oublier.
///
/// Il se montre secondes au début — assez pour qu'on
/// sache qu'il existe et où le retrouver — puis s'efface. Il revient dès que le
/// visiteur baisse les yeux, le geste qu'on fait naturellement pour chercher
/// une commande, ou dès qu'il touche une manette. Sans ce rappel, quelqu'un en
/// borne resterait coincé dans la photo.
///
const float VisibleSeconds = 4f;
/// Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde.
const float LookDownDegrees = 25f;
float _hideAt;
void Update()
{
if (_back == null || _head == null) return;
var wants = LookingDown() || ControllerInUse();
if (wants)
{
if (!_back.gameObject.activeSelf) PlaceBack();
_hideAt = Time.time + VisibleSeconds;
}
_back.gameObject.SetActive(Time.time < _hideAt);
}
bool LookingDown() => Vector3.Angle(_head.forward, Vector3.down) < 90f - LookDownDegrees;
static bool ControllerInUse()
{
var controller = OVRInput.GetActiveController();
return controller == OVRInput.Controller.RTouch
|| controller == OVRInput.Controller.LTouch;
}
///
/// Reposé sous le regard courant à chaque réapparition : en 360 le visiteur tourne
/// sur lui-même, et un panneau laissé à sa position de départ serait dans son dos
/// au moment où il le cherche.
///
void PlaceBack()
{
if (_back == null) return;
var forward = _head != null ? _head.forward : Vector3.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
forward.Normalize();
_back.transform.position =
(_head != null ? _head.position : Vector3.zero) + forward * 2.2f + Vector3.down * 0.8f;
_back.transform.rotation = Quaternion.LookRotation(forward);
_back.gameObject.SetActive(true);
}
public async Task LoadAsync(ConfigurationExport.Resource resource, ApiClient client)
{
if (_skybox == null) return;
if (resource.Type == ConfigurationExport.ResourceKind.Video360)
await PlayVideo(resource, client);
else
await ShowImage(resource, client);
}
async Task ShowImage(ConfigurationExport.Resource resource, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, resource.Url);
if (texture == null || this == null) return;
_image = texture;
_skybox.SetTexture("_MainTex", texture);
RenderSettings.skybox = _skybox;
// Le panneau de retour se montre au début, puis s'efface.
_hideAt = Time.time + VisibleSeconds;
}
async Task PlayVideo(ConfigurationExport.Resource resource, ApiClient client)
{
// Une vidéo 360 pèse des centaines de Mo : elle est lue depuis le cache
// disque, pas gardée en mémoire.
var path = await ContentCache.MediaPathAsync(client, resource.Url);
if (path == null || this == null) return;
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent();
_player.source = VideoSource.Url;
_player.url = new Uri(path).AbsoluteUri;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.Direct;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Skybox] Lecture vidéo impossible : {message}");
_skybox.SetTexture("_MainTex", _target);
RenderSettings.skybox = _skybox;
_hideAt = Time.time + VisibleSeconds;
_player.Play();
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel) || panel.Id != "__back") return;
Interacted?.Invoke();
Closed?.Invoke();
}
///
/// Le ciel est un réglage global : sans cette remise en état, le menu
/// s'afficherait par-dessus la dernière image 360 vue, et le visiteur suivant
/// hériterait du décor du précédent.
///
void OnDestroy()
{
RenderSettings.skybox = _previousSkybox;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
// Une équirectangulaire pèse des dizaines de Mo même compressée : laissée
// derrière, elle s'accumulerait à chaque 360 ouverte jusqu'à ce que le
// système tue l'app — au bout de plusieurs visites, donc jamais en test court.
if (_image != null) Destroy(_image);
}
}
}