// Version: 1.1.0 | Created: 2026-04-01 // LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt // endpoint, then connects a LiveKit Room using that token. // // The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix // access token as Bearer auth. The LiveKit server URL is the same host as the // Matrix server (as configured on chat.m8chat.au). import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:livekit_client/livekit_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_state.dart'; import '../../../core/config/app_config.dart'; part 'livekit_service.g.dart'; /// Failure type for LiveKit connection attempts. sealed class LiveKitFailure { const LiveKitFailure(); } final class LiveKitNotAuthenticated extends LiveKitFailure { const LiveKitNotAuthenticated(); } final class LiveKitJwtFetchFailed extends LiveKitFailure { const LiveKitJwtFetchFailed(this.message); final String message; } final class LiveKitConnectFailed extends LiveKitFailure { const LiveKitConnectFailed(this.message); final String message; } /// Result of a LiveKit connection attempt. sealed class LiveKitResult { const LiveKitResult(); } final class LiveKitConnected extends LiveKitResult { const LiveKitConnected({required this.room}); final Room room; } final class LiveKitFailed extends LiveKitResult { const LiveKitFailed(this.failure); final LiveKitFailure failure; } /// Manages LiveKit room connections for MatrixRTC. class LiveKitService { LiveKitService({required AuthState authState}) : _authState = authState; final AuthState _authState; Room? _activeRoom; Room? get activeRoom => _activeRoom; /// Connect to LiveKit for [matrixRoomId]. /// /// Steps: /// 1. GET `/_matrix/livekit/jwt?roomId={id}&userId={id}` /// 2. Use returned token + LiveKit WS URL to connect a [Room] Future connect(String matrixRoomId) async { final auth = _authState; if (auth is! AuthAuthenticated) { return const LiveKitFailed(LiveKitNotAuthenticated()); } final accessToken = auth.accessToken; final userId = auth.userId; // Step 1 — fetch JWT from Matrix server final jwtResult = await _fetchJwt( accessToken: accessToken, matrixRoomId: matrixRoomId, userId: userId, ); if (jwtResult is _JwtError) { return LiveKitFailed(LiveKitJwtFetchFailed(jwtResult.message)); } final jwt = (jwtResult as _JwtOk).token; final livekitUrl = (jwtResult).url; // Step 2 — connect to LiveKit final room = Room(); try { await room.connect(livekitUrl, jwt); _activeRoom = room; return LiveKitConnected(room: room); } on Exception catch (e) { await room.dispose(); return LiveKitFailed(LiveKitConnectFailed(e.toString())); } } /// Disconnect and dispose the active room. Future disconnect() async { await _activeRoom?.disconnect(); await _activeRoom?.dispose(); _activeRoom = null; } Future<_JwtFetchResult> _fetchJwt({ required String accessToken, required String matrixRoomId, required String userId, }) async { final uri = Uri.parse( AppConfig.livekitJwtUrl, ).replace(queryParameters: {'roomId': matrixRoomId, 'userId': userId}); try { final response = await http.get( uri, headers: {'Authorization': 'Bearer $accessToken'}, ); if (response.statusCode != 200) { return _JwtError( 'JWT endpoint returned ${response.statusCode}: ${response.body}', ); } final json = jsonDecode(response.body) as Map; // The server returns { token: "...", url: "wss://..." } per MSC4143. final token = json['token'] as String?; final url = json['url'] as String?; if (token == null || url == null) { return _JwtError('JWT response missing token or url fields.'); } return _JwtOk(token: token, url: url); } on Exception catch (e) { return _JwtError('Network error fetching JWT: $e'); } } } // Internal result types for JWT fetch — not exposed outside this file. sealed class _JwtFetchResult {} final class _JwtOk extends _JwtFetchResult { _JwtOk({required this.token, required this.url}); final String token; final String url; } final class _JwtError extends _JwtFetchResult { _JwtError(this.message); final String message; } @Riverpod(keepAlive: true) LiveKitService liveKitService(Ref ref) { final authState = ref.watch(authProvider); return LiveKitService(authState: authState); }