mymuseum-visitapp/lib/Services/meta_glasses_service.dart

242 lines
9.9 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.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() — init SDK, écoute les streams d'état (au démarrage app)
/// 2. connect() — enregistrement DAT + permission caméra + HFP audio routing
/// 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 le stream, reste connecté
/// 5. disconnect() — déconnexion complète
class MetaGlassesService {
MetaGlassesService._();
static final MetaGlassesService instance = MetaGlassesService._();
final ValueNotifier<GlassesState> state = ValueNotifier(GlassesState.disconnected);
/// Appelé avec le chemin de la photo après capturePhoto().
void Function(String photoPath)? onPhotoCaptured;
bool get isConnected =>
state.value == GlassesState.connected || state.value == GlassesState.streaming;
// ── 1. Initialisation SDK ─────────────────────────────────────────────────
Future<void> initialize() async {
if (!Platform.isAndroid) return;
try {
final ok = await Wearables.instance.initialize();
if (!ok) {
debugPrint('[MetaGlassesService] SDK init failed');
return;
}
} catch (e) {
// ALREADY_INITIALIZED = hot restart, le SDK natif garde son état → OK
if (e.toString().contains('ALREADY_INITIALIZED')) {
debugPrint('[MetaGlassesService] Already initialized (hot restart) — continuing');
} else {
debugPrint('[MetaGlassesService] Init error: $e');
return;
}
}
Wearables.instance.registrationStateStream.listen(_onRegistrationState);
Wearables.instance.streamStateStream.listen(_onStreamState);
Wearables.instance.devicesStream.listen((devices) {
debugPrint('[MetaGlassesService] Devices: $devices');
if (devices.isNotEmpty) _onDeviceConnected();
});
debugPrint('[MetaGlassesService] Initialized');
}
// ── 2. Connexion (sans stream vidéo) ──────────────────────────────────────
/// Connecte aux lunettes et active le routing audio HFP.
/// Le micro des lunettes devient actif pour le wake word.
/// PAS de stream vidéo — utiliser [startStream] séparément.
Future<void> connect() async {
if (!Platform.isAndroid) return;
state.value = GlassesState.connecting;
await Permission.camera.request();
await Wearables.instance.startRegistration();
await _ensureCameraPermission();
debugPrint('[MetaGlassesService] Connected (no stream yet)');
}
// ── 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<void> startStream() async {
if (!isConnected) return;
await Wearables.instance.startStream(videoQuality: 'MEDIUM', frameRate: 24);
debugPrint('[MetaGlassesService] Stream started');
}
Future<void> stopStream() async {
await Wearables.instance.stopStream();
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected;
}
debugPrint('[MetaGlassesService] Stream stopped');
}
// ── 4. Déconnexion ────────────────────────────────────────────────────────
Future<void> disconnect() async {
await Wearables.instance.stopStream();
await AudioRoutingChannel.restoreDefaultOutput();
state.value = GlassesState.disconnected;
}
// ── Capture photo ─────────────────────────────────────────────────────────
Future<void> requestPhotoCapture() async {
if (!isConnected) {
debugPrint('[MetaGlassesService] Not connected — capture ignored');
onPhotoCaptured?.call('');
return;
}
bool streamStartedByUs = false;
if (state.value != GlassesState.streaming) {
await startStream();
streamStartedByUs = true;
// Attend que le stream soit réellement actif (max 4s)
final sw = Stopwatch()..start();
while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) {
await Future.delayed(const Duration(milliseconds: 200));
}
if (state.value != GlassesState.streaming) {
debugPrint('[MetaGlassesService] Stream not ready after 8s — capture aborted');
onPhotoCaptured?.call('');
return;
}
// Délai de stabilisation si le stream vient juste de démarrer
await Future.delayed(const Duration(milliseconds: 1500));
}
try {
final photo = await Wearables.instance.capturePhoto();
debugPrint('[MetaGlassesService] Photo captured: ${photo.path}');
onPhotoCaptured?.call(photo.path);
} catch (e) {
debugPrint('[MetaGlassesService] capturePhoto error: $e');
onPhotoCaptured?.call('');
} finally {
if (streamStartedByUs) {
await stopStream();
debugPrint('[MetaGlassesService] Stream stopped after photo');
}
}
}
/// Démarre le stream et attend qu'il soit actif (max 4s).
/// Retourne true si le stream est prêt.
Future<bool> ensureStreaming() async {
if (!isConnected) return false;
if (state.value == GlassesState.streaming) return true;
final alreadyStarted = state.value == GlassesState.streaming;
await startStream();
final sw = Stopwatch()..start();
while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) {
await Future.delayed(const Duration(milliseconds: 200));
}
if (state.value != GlassesState.streaming) return false;
// Si on vient de passer à streaming via 'started' (pas 'streaming'), attendre un peu
// que le SDK soit vraiment prêt pour capturePhoto()
if (!alreadyStarted) await Future.delayed(const Duration(milliseconds: 1500));
return true;
}
/// Capture un seul frame depuis le stream (doit être déjà actif).
/// Retourne le chemin du fichier temporaire, ou null si pas de frame en 2s.
Future<String?> grabFrame() async {
if (state.value != GlassesState.streaming) return null;
final completer = Completer<Uint8List?>();
StreamSubscription? sub;
sub = Wearables.instance.videoFramesStream.listen((frame) {
if (!completer.isCompleted) completer.complete(frame);
sub?.cancel();
}, onError: (_) {
if (!completer.isCompleted) completer.complete(null);
});
Future.delayed(const Duration(seconds: 2), () {
if (!completer.isCompleted) completer.complete(null);
sub?.cancel();
});
final bytes = await completer.future;
if (bytes == null) return null;
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/qr_frame_${DateTime.now().millisecondsSinceEpoch}.jpg');
await file.writeAsBytes(bytes);
return file.path;
}
// ── Audio HFP (micro lunettes) ────────────────────────────────────────────
/// Appelé quand les lunettes sont détectées.
/// On ne force PAS MODE_IN_COMMUNICATION ici — ça dégraderait le TTS (HFP 8kHz).
/// Android route automatiquement le micro HFP vers SpeechRecognizer quand connecté.
/// A2DP reste actif pour la sortie TTS haute qualité.
void _onDeviceConnected() {
debugPrint('[MetaGlassesService] Glasses connected — A2DP + HFP active (no forced routing)');
}
// ── Callbacks SDK ─────────────────────────────────────────────────────────
Future<void> _ensureCameraPermission() async {
try {
final status = await Wearables.instance.checkCameraPermission();
debugPrint('[MetaGlassesService] Camera permission: $status');
if (status.toLowerCase() == 'granted') return;
await Wearables.instance.requestCameraPermission();
} catch (e) {
debugPrint('[MetaGlassesService] Camera permission error: $e');
}
}
void _onRegistrationState(RegistrationState s) {
debugPrint('[MetaGlassesService] Registration: ${s.state} error=${s.error}');
switch (s.state.toLowerCase()) {
case 'registered':
case 'available':
if (state.value == GlassesState.connecting) {
state.value = GlassesState.connected;
}
case 'unregistered':
case 'unavailable':
state.value = GlassesState.disconnected;
}
}
void _onStreamState(String s) {
debugPrint('[MetaGlassesService] Stream state: $s');
final lower = s.toLowerCase();
// 'streaming' = flux vidéo actif, capturePhoto() fonctionne immédiatement
// 'started' = stream initialisé — capturePhoto() peut fonctionner après un court délai
if (lower.contains('streaming') || lower == 'started') {
state.value = GlassesState.streaming;
} else if (lower == 'stopped' || lower == 'closed') {
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected;
}
}
}
void dispose() => state.dispose();
}