import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart'; import 'package:path_provider/path_provider.dart'; enum GlassesState { disconnected, connecting, connected, streaming } /// Gère la connexion aux lunettes Ray-Ban Meta via le SDK DAT. /// /// Cycle de vie intentionnel : /// 1. initialize() — permissions runtime + écoute des streams d'état (au démarrage app) /// 2. connect() — enregistrement DAT + permission caméra Meta AI /// Le téléphone est connecté aux lunettes, micro HFP actif. /// PAS de stream vidéo encore. /// 3. startStream() — démarre le stream vidéo (uniquement quand caméra nécessaire) /// 4. stopStream() — arrête la session device (coûteux, cf. note ci-dessous) /// 5. disconnect() — déconnexion complète /// /// ⚠️ Depuis DAT 0.9.1, stopStreamSession() ferme toute la session device : les /// lunettes jouent leur tonalité de fin et le prochain startStreamSession() est /// une reconnexion complète, nettement plus lente qu'un simple ré-attach. On ne /// cycle donc PLUS le stream à chaque capture photo — il reste ouvert jusqu'à un /// stopStream()/disconnect() explicite. /// /// Le SDK DAT n'expose ni micro, ni haut-parleur, ni bouton des lunettes : tout /// le pipeline vocal (wake word, STT, TTS, routing HFP/A2DP) vit hors de ce /// service, dans AudioRoutingChannel et l'orchestrateur vocal. class MetaGlassesService { MetaGlassesService._(); static final MetaGlassesService instance = MetaGlassesService._(); final ValueNotifier state = ValueNotifier(GlassesState.disconnected); /// Appelé avec le chemin de la photo après capturePhoto(). void Function(String photoPath)? onPhotoCaptured; /// Texture Flutter du stream en cours — requise par [grabFrame] et par un /// éventuel widget Texture d'aperçu. null hors streaming. int? get textureId => _textureId; int? _textureId; RegistrationState _registration = RegistrationState.unavailable; final List _subs = []; bool get isConnected => state.value == GlassesState.connected || state.value == GlassesState.streaming; // ── 1. Initialisation SDK ───────────────────────────────────────────────── Future initialize() async { if (!Platform.isAndroid) return; if (!kEnableGlasses) { debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche'); return; } try { final granted = await MetaWearablesDat.requestAndroidPermissions(); if (!granted) { debugPrint('[MetaGlassesService] Permissions Bluetooth refusees'); return; } _registration = await MetaWearablesDat.getRegistrationState(); debugPrint('[MetaGlassesService] Registration initiale: $_registration'); } catch (e) { debugPrint('[MetaGlassesService] Init error: $e'); return; } _subs.add(MetaWearablesDat.registrationStateStream().listen(_onRegistrationState)); _subs.add(MetaWearablesDat.streamSessionStateStream().listen(_onStreamState)); _subs.add(MetaWearablesDat.streamSessionErrorStream().listen(_onStreamError)); _subs.add(MetaWearablesDat.activeDeviceStream().listen(_onActiveDevice)); if (_registration == RegistrationState.registered) { state.value = GlassesState.connected; } debugPrint('[MetaGlassesService] Initialized'); } /// À brancher sur le deep link entrant : c'est le retour de l'app Meta AI qui /// termine l'enregistrement (ou la déconnexion). Future handleDeepLink(String url) async { if (!Platform.isAndroid || !kEnableGlasses) return; try { final handled = await MetaWearablesDat.handleUrl(url); debugPrint('[MetaGlassesService] handleUrl($url) -> $handled'); if (handled) await MetaWearablesDat.restartActiveDeviceMonitoring(); } catch (e) { debugPrint('[MetaGlassesService] handleUrl error: $e'); } } // ── 2. Connexion (sans stream vidéo) ────────────────────────────────────── /// Connecte aux lunettes. Le micro des lunettes devient actif pour le wake /// word via le routing Bluetooth système, hors SDK. /// PAS de stream vidéo — utiliser [startStream] séparément. Future connect() async { if (!Platform.isAndroid) return; if (!kEnableGlasses) { debugPrint('[MetaGlassesService] Desactive (ENABLE_GLASSES absent) : SDK non touche'); return; } state.value = GlassesState.connecting; try { if (_registration != RegistrationState.registered) { // Ouvre Meta AI ; la registration se termine au retour du deep link, // traité par handleDeepLink(). await MetaWearablesDat.startRegistration(); } await _ensureCameraPermission(); await MetaWearablesDat.restartActiveDeviceMonitoring(); debugPrint('[MetaGlassesService] Connect demande (no stream yet)'); } catch (e) { debugPrint('[MetaGlassesService] connect error: $e'); state.value = GlassesState.disconnected; } } // ── 3. Stream vidéo (sur demande) ───────────────────────────────────────── /// Démarre le stream vidéo continu (nécessaire pour capturePhoto). /// Appeler uniquement quand la caméra est requise. Future startStream() async { if (!isConnected) return; try { _textureId = await MetaWearablesDat.startStreamSession( null, fps: 24, streamQuality: StreamQuality.medium, ); debugPrint('[MetaGlassesService] Stream started (texture=$_textureId)'); } on PlatformException catch (e) { debugPrint('[MetaGlassesService] startStreamSession error: ${e.code} ${e.message}'); } } Future stopStream() async { try { await MetaWearablesDat.stopStreamSession(null); } catch (e) { debugPrint('[MetaGlassesService] stopStreamSession error: $e'); } _textureId = null; if (state.value == GlassesState.streaming) { state.value = GlassesState.connected; } debugPrint('[MetaGlassesService] Stream stopped'); } // ── 4. Déconnexion ──────────────────────────────────────────────────────── Future disconnect() async { await stopStream(); await AudioRoutingChannel.restoreDefaultOutput(); state.value = GlassesState.disconnected; } // ── Capture photo ───────────────────────────────────────────────────────── Future requestPhotoCapture() async { if (!isConnected) { debugPrint('[MetaGlassesService] Not connected — capture ignored'); onPhotoCaptured?.call(''); return; } if (!await ensureStreaming()) { debugPrint('[MetaGlassesService] Stream not ready — capture aborted'); onPhotoCaptured?.call(''); return; } try { final photo = await MetaWearablesDat.capturePhoto(null); final path = await _writeTempFile(photo.bytes, photo.fileExtension, 'photo'); debugPrint('[MetaGlassesService] Photo captured: $path'); onPhotoCaptured?.call(path); } on PlatformException catch (e) { debugPrint('[MetaGlassesService] capturePhoto error: ${e.code} ${e.details}'); onPhotoCaptured?.call(''); } catch (e) { debugPrint('[MetaGlassesService] capturePhoto error: $e'); onPhotoCaptured?.call(''); } // Le stream reste volontairement ouvert : le refermer coûterait une // reconnexion complète à la capture suivante (cf. note de classe). } /// Démarre le stream si besoin et attend qu'il soit réellement actif. /// Retourne true si le stream est prêt pour une capture. Future ensureStreaming() async { if (!isConnected) return false; if (state.value == GlassesState.streaming) return true; await startStream(); final sw = Stopwatch()..start(); while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) { await Future.delayed(const Duration(milliseconds: 200)); } return state.value == GlassesState.streaming; } /// Capture un seul frame depuis la texture du stream (doit être déjà actif). /// Rastérisation Dart pure, sans passer par les lunettes — bien plus rapide /// que capturePhoto, mais indisponible en arrière-plan (pas d'accès GPU). /// Retourne le chemin d'un PNG temporaire, ou null. Future grabFrame() async { final texture = _textureId; if (texture == null || state.value != GlassesState.streaming) return null; final frame = await MetaWearablesDat.captureStreamFrame( texture, format: FrameFormat.png, ); if (frame == null) return null; return _writeTempFile(frame.bytes, 'png', 'qr_frame'); } Future _writeTempFile(Uint8List bytes, String extension, String prefix) async { final dir = await getTemporaryDirectory(); final file = File( '${dir.path}/${prefix}_${DateTime.now().millisecondsSinceEpoch}.$extension', ); await file.writeAsBytes(bytes); return file.path; } // ── Callbacks SDK ───────────────────────────────────────────────────────── Future _ensureCameraPermission() async { try { if (await MetaWearablesDat.getCameraPermissionStatus()) return; final granted = await MetaWearablesDat.requestCameraPermission(); debugPrint('[MetaGlassesService] Camera permission granted: $granted'); } catch (e) { debugPrint('[MetaGlassesService] Camera permission error: $e'); } } void _onRegistrationState(RegistrationState s) { debugPrint('[MetaGlassesService] Registration: $s'); _registration = s; switch (s) { case RegistrationState.registered: if (state.value == GlassesState.connecting) { state.value = GlassesState.connected; } case RegistrationState.unavailable: state.value = GlassesState.disconnected; _textureId = null; case RegistrationState.available: case RegistrationState.registering: break; } } void _onActiveDevice(bool available) { debugPrint('[MetaGlassesService] Active device available: $available'); if (available) { // A2DP + HFP sont gérés par Android ; on ne force pas // MODE_IN_COMMUNICATION ici, ça dégraderait le TTS (HFP 8 kHz). if (state.value == GlassesState.disconnected && _registration == RegistrationState.registered) { state.value = GlassesState.connected; } } else if (state.value != GlassesState.connecting) { state.value = GlassesState.disconnected; _textureId = null; } } void _onStreamState(StreamSessionState s) { debugPrint('[MetaGlassesService] Stream state: $s'); switch (s) { case StreamSessionState.streaming: state.value = GlassesState.streaming; case StreamSessionState.stopped: // Terminal : la texture native est désenregistrée avec l'état. _textureId = null; if (state.value == GlassesState.streaming) { state.value = GlassesState.connected; } case StreamSessionState.paused: // Le SDK entre et sort de paused seul (thermique). Ne pas détruire la // session : la texture reste valide, les frames reprendront dessus. if (state.value == GlassesState.streaming) { state.value = GlassesState.connected; } case StreamSessionState.stopping: case StreamSessionState.waitingForDevice: case StreamSessionState.starting: break; } } void _onStreamError(StreamSessionError e) { debugPrint('[MetaGlassesService] Stream error: $e'); } void dispose() { for (final s in _subs) { s.cancel(); } _subs.clear(); state.dispose(); } }