feat: Call-Robustheit + Standalone-Voice-Channel per Klick beitretbar (M3)

Mit zwei echten Test-Accounts (zwei GUI-Instanzen via PYRAMID_PROFILE_DIR)
durchgeklickt: Beitreten, Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitaetswechsel (Full HD 60fps), sauberes Verlassen.

- rooms_list.dart: Voice-Channel in der normalen Raumliste war per Klick
  nicht beitretbar (oeffnete nur Chat) -> _toggleVoiceChannel wie Space-Liste.
- livekit_call_manager.dart: _callGeneration entwertet laufenden connect
  bei Auflegen/Wechsel (keine Geisterverbindung mit offenem Mikro);
  Fehlerpfade setzen error sichtbar statt still zu verschwinden.
- rooms_panel_core.dart: raeumt activeVoiceRoomIdProvider auf, wenn der
  Voice-Service inaktiv wird, + Fehler-SnackBar.
- test/live_call_setup_test.dart: gegateter Setup-Helfer fuer den Testraum.

UNGETESTET: echtes 2-Peer-Audio, Netz-Abbruch mitten im Call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-06 22:50:58 +02:00
parent ffd70caffd
commit f8f5332d90
7 changed files with 407 additions and 10 deletions
+43 -7
View File
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
context: context, builder: (_) => const CreateJoinDialog()),
),
if (!isCollapsed)
...filtered.map((room) => _RoomItem(
room: room,
isActive: activeRoomId == room.id,
pt: pt,
onTap: () {
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: pt,
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste)
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
onTap: () {
if (isVoice) {
_toggleVoiceChannel(ref, room);
} else {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
)),
}
},
);
}),
],
);
}
}
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: room.client,
matrixRoomId: room.id,
);
}
class _SectionHeader extends StatefulWidget {
final String title;
final int count;
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final _filterCtrl = TextEditingController();
String _filter = '';
final Map<String, bool> _collapsed = {};
String? _lastShownCallError;
@override
void dispose() {
@@ -37,6 +38,30 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
// Voice-Zustand mit der UI synchron halten: Bei unerwartetem Abbruch
// oder fehlgeschlagenem Beitritt wird der Service inaktiv, aber
// activeVoiceRoomIdProvider zeigte vorher weiter auf den Kanal das
// Kanal-Highlight und der eigene (optimistische) Teilnehmer-Eintrag
// blieben hängen. Außerdem den Fehler sichtbar machen (kein stiller
// Fehlerpfad, CLAUDE.md).
ref.listen<VoiceChannelService>(voiceChannelProvider, (_, call) {
if (!call.isActive && ref.read(activeVoiceRoomIdProvider) != null) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
}
final err = call.error;
if (err == null) {
_lastShownCallError = null;
} else if (err != _lastShownCallError) {
_lastShownCallError = err;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Voice: $err')),
);
});
}
});
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(voiceChannelProvider);
@@ -73,6 +73,12 @@ class LiveKitCallManager extends VoiceChannelService {
Timer? _statsTimer;
/// Entwertet laufende `startCall`-Verbindungsaufbauten: Wer während des
/// (mehrere Sekunden dauernden) `connect` auflegt oder den Kanal wechselt,
/// darf KEINE Geisterverbindung zurücklassen vorher blieb in dem Fall
/// das Mikro im alten Kanal offen, während die UI „nicht verbunden" zeigte.
int _callGeneration = 0;
@override
bool get isActive => _room != null || isConnecting;
@override
@@ -93,6 +99,7 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners();
void _cleanUpAfterDisconnect() {
_callGeneration++;
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -105,7 +112,9 @@ class LiveKitCallManager extends VoiceChannelService {
_room = null;
_displayName = '';
isConnecting = false;
error = null;
// `error` bewusst NICHT löschen: Ein unerwarteter Abbruch/Join-Fehler
// muss für die UI sichtbar bleiben (kein stiller Fehlerpfad). Manuelles
// Auflegen (hangUp) setzt error selbst zurück.
isMuted = false;
isCameraOff = false;
isScreenSharing = false;
@@ -162,6 +171,7 @@ class LiveKitCallManager extends VoiceChannelService {
String? matrixRoomId,
}) async {
if (isActive) return;
final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
@@ -223,6 +233,17 @@ class LiveKitCallManager extends VoiceChannelService {
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
);
if (gen != _callGeneration) {
// Während des Verbindens wurde aufgelegt oder der Kanal gewechselt
// diese Verbindung gehört niemandem mehr: sofort wieder trennen,
// sonst bleibt das Mikro unsichtbar im alten Kanal offen.
room.removeListener(_onRoomChanged);
try {
await room.disconnect();
} catch (_) {}
return;
}
_room = room;
isConnecting = false;
notifyListeners();
@@ -263,7 +284,11 @@ class LiveKitCallManager extends VoiceChannelService {
..on<TrackUnpublishedEvent>((_) => notifyListeners())
..on<ParticipantConnectedEvent>((_) => notifyListeners())
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
..on<RoomDisconnectedEvent>((_) {
..on<RoomDisconnectedEvent>((event) {
// Kommt nur bei UNERWARTETEN Abbrüchen an (manuelles hangUp
// entsorgt den Listener vorher) Grund sichtbar machen statt
// still aus dem Kanal zu verschwinden.
error = 'Verbindung getrennt (${event.reason?.name ?? 'unbekannt'})';
_cleanUpAfterDisconnect();
});
@@ -318,9 +343,16 @@ class LiveKitCallManager extends VoiceChannelService {
_startStatsLogging();
} catch (e) {
debugPrint('[VOIP] Start call error: $e');
if (gen != _callGeneration) {
// Inzwischen wurde aufgelegt/neu verbunden der Zustand gehört
// schon dem neuen Aufbau, hier nichts mehr anfassen.
return;
}
isConnecting = false;
error = e.toString();
_cleanUpAfterDisconnect();
// NACH dem Aufräumen setzen, damit der Join-Fehler sichtbar bleibt.
error = 'Beitritt fehlgeschlagen: $e';
notifyListeners();
}
}
@@ -556,6 +588,8 @@ class LiveKitCallManager extends VoiceChannelService {
@override
Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++;
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;