mymuseum-visitapp/lib/Services/meta_glasses_service.dart
Thomas Fransolet d39949a6bd Lunettes Meta, lecteur audio en onglet, et badge de version dans les Parametres
Ce commit boucle le travail en cours sur la branche (assistant lunettes Meta,
passage du lecteur audio flottant a un onglet, ajustements scanner / liste de
configurations / telechargement) et y ajoute le badge de version.

Le badge, en bas de la feuille Parametres, affiche « flavor . version . commit ».
Un APK pose sur une tablette du terrain n'etait rattachable a aucun commit
precis : la version du pubspec ne bougeait pas d'un build a l'autre et rien
n'indiquait le flavor reellement installe. kGitSha suit le meme schema que
kApiBaseUrl, injecte par --dart-define, et kFlavor expose le flavor deja calcule.

package_info_plus etait deja une dependance transitive ; il devient direct,
puisqu'il est desormais importe.

/!\ Un --dart-define modifie n'est PAS pris en compte sans `flutter clean` sur
ce projet : verifie a la sentinelle, le SHA restait absent de libapp.so tant que
le cache Dart n'etait pas vide. Un build de release destine au terrain doit donc
toujours passer par un clean, sinon le badge affiche le SHA du build precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:43:53 +02:00

312 lines
12 KiB
Dart

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<GlassesState> 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<StreamSubscription> _subs = [];
bool get isConnected =>
state.value == GlassesState.connected || state.value == GlassesState.streaming;
// ── 1. Initialisation SDK ─────────────────────────────────────────────────
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> disconnect() async {
await 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;
}
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<bool> 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<String?> 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<String> _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<void> _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();
}
}