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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user