242 lines
9.9 KiB
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();
|
|
}
|