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:
+25
@@ -1455,3 +1455,28 @@
|
|||||||
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||||
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
|
||||||
|
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||||
|
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||||
|
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
|
||||||
|
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||||
|
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
|
||||||
|
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||||
|
|||||||
+76
@@ -13,6 +13,82 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt
|
||||||
|
|
||||||
|
**Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten
|
||||||
|
Test-Logins (pyramidtest1 GUI-Fenster links, pyramidtest2 rechts, zwei
|
||||||
|
getrennte Profile über `PYRAMID_PROFILE_DIR`) am laufenden Windows-Build
|
||||||
|
verifiziert, nicht nur analysiert.
|
||||||
|
|
||||||
|
**(C) 60-FPS-Streaming (`docs/STREAMING_60FPS.md`, Commit ffd70ca):**
|
||||||
|
- Neue Presets `hd60` (720p·60, 5 Mbit/s) und `fhd60` (1080p·60, 8 Mbit/s)
|
||||||
|
in `qualityPresets` + `screenShareEncodings`; im SENDEN-Menü als
|
||||||
|
„HD 60fps"/„Full HD 60fps" ergänzt. Preset-Auflösung UND -FPS werden
|
||||||
|
jetzt bis in `ScreenShareCaptureOptions.params` durchgereicht (vorher
|
||||||
|
blieb der LiveKit-Default 1080p/15 aktiv, egal welches Preset). 60-FPS-
|
||||||
|
Modi publishen ohne Simulcast mit
|
||||||
|
`DegradationPreference.maintainFramerate` (unter Last Auflösung statt
|
||||||
|
Frames opfern). `contentHint='motion'` ist in flutter_webrtc 1.4.1 aus
|
||||||
|
Dart NICHT setzbar (Pub-Cache geprüft) – im Code ehrlich dokumentiert.
|
||||||
|
4 Vertragstests für die Preset-Tabellen (grün).
|
||||||
|
- **Real getestet:** pyramidtest1 teilt Bildschirm → pyramidtest2 sieht den
|
||||||
|
Stream (und umgekehrt, beide Richtungen). Über das SENDEN-Menü live auf
|
||||||
|
„Full HD 60fps" umgeschaltet: Screenshare-Track wird mit dem neuen
|
||||||
|
Encoding neu published, kein Absturz, Empfänger bekommt weiter Bild.
|
||||||
|
- **UNGETESTET (ehrlich):** subjektives „flüssiges 60-FPS-Gefühl" –
|
||||||
|
Sender und Empfänger liefen auf DEMSELBEN PC (Selbst-Streaming =
|
||||||
|
Hall-of-Mirrors), das taugt nicht zur Ruckel-Beurteilung. Ob
|
||||||
|
`sender.parameters.encodings` real `maxFramerate==60` trägt, ist nur am
|
||||||
|
Preset-Vertrag belegt, nicht am Live-RTP-Report. Braucht zwei echte
|
||||||
|
Geräte + Netz.
|
||||||
|
|
||||||
|
**(B) Call-Robustheit (Commit siehe git log):**
|
||||||
|
- **Bug gefunden + gefixt:** Standalone-Voice-Channels (nicht in einem
|
||||||
|
Space, sondern in der normalen Raumliste) waren per Klick GAR NICHT
|
||||||
|
beitretbar – `rooms_list.dart` öffnete für sie nur die Chat-Ansicht.
|
||||||
|
Jetzt tritt ein Klick dem Kanal bei/verlässt ihn (Toggle), exakt wie in
|
||||||
|
der Space-Liste (`_toggleVoiceChannel`). Real verifiziert: beide
|
||||||
|
Test-User beigetreten, Teilnehmerliste zeigt beide, Zähler „2".
|
||||||
|
- **Zustands-Härtung `livekit_call_manager.dart`:** (1) `_callGeneration`
|
||||||
|
entwertet einen noch laufenden `connect`, wenn zwischendurch aufgelegt/
|
||||||
|
gewechselt wird – vorher konnte eine Geisterverbindung mit offenem Mikro
|
||||||
|
im alten Kanal zurückbleiben, während die UI „nicht verbunden" zeigte.
|
||||||
|
(2) Fehlerpfade setzen `error` sichtbar (unerwarteter
|
||||||
|
`RoomDisconnectedEvent` mit Grund, fehlgeschlagener Beitritt) statt
|
||||||
|
still aus dem Kanal zu verschwinden – CLAUDE.md „keine stillen
|
||||||
|
Fehlerpfade". (3) `rooms_panel_core.dart` hört auf den Voice-Service und
|
||||||
|
räumt `activeVoiceRoomIdProvider` auf, sobald der Service inaktiv wird
|
||||||
|
(Kanal-Highlight/eigener Teilnehmer-Eintrag blieben sonst hängen),
|
||||||
|
und zeigt den Fehler als SnackBar.
|
||||||
|
- **Real getestet:** Beitreten (beide), Teilnehmerliste, Screenshare
|
||||||
|
bidirektional, Qualitätswechsel live, sauberes Verlassen (pyramidtest1
|
||||||
|
legt auf → bei test1 Voice-Widget weg + Kanal nicht mehr aktiv, bei
|
||||||
|
test2 Teilnehmerzähler auf 1, test1-Stream verschwunden).
|
||||||
|
- **Setup-Helfer `test/live_call_setup_test.dart`** (gegated wie die
|
||||||
|
Encryption-Tests): legt den Voice-Testraum an und bringt beide Test-User
|
||||||
|
rein, damit die zwei GUI-Instanzen sofort joinen können.
|
||||||
|
- **UNGETESTET (ehrlich):** echtes 2-Peer-AUDIO (beide auf einem PC =
|
||||||
|
Rückkopplung, nicht sinnvoll prüfbar). VerbindungsABBRUCH mitten im Call
|
||||||
|
(Netz weg → `RoomDisconnectedEvent`-Pfad mit meiner Fehlermeldung) nur
|
||||||
|
im Code abgesichert, nicht live simuliert – braucht kontrollierten
|
||||||
|
Netzausfall. M3-Punkt „Voice-Channel-Logik robust" daher nur annotiert,
|
||||||
|
nicht abgehakt.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** auth_log-Check des Beobachtungslaufs (Bernds
|
||||||
|
echtes Profil neu gestartet 22:49:22 `loggedIn`, keine Re-Migration) –
|
||||||
|
Einträge seit 2026-07-06 22:49 prüfen. Für Bernd: 60-FPS-Streaming auf zwei
|
||||||
|
ECHTEN Geräten übers Netz gegentesten (Ruckeln?), echtes 2-Peer-Audio.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) `PYRAMID_PROFILE_DIR` leitet DB/Media um, aber NICHT
|
||||||
|
flutter_secure_storage – zwei Profile teilen sich denselben
|
||||||
|
`db_cipher_key`. Für reine Login-Tests unkritisch (Schlüssel wird nur
|
||||||
|
gelesen). (2) Der SENDEN-Qualitäts-Zahnrad taucht erst beim Hovern über den
|
||||||
|
Hero-Videobereich auf (Bottom-Bar slidet ein). (3) Eine per
|
||||||
|
`flutter build` neu gebaute exe lässt sich nicht überschreiben, solange die
|
||||||
|
Beobachtungs-exe läuft – vorher beenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-06 – Nacht-Session (A): SQLCipher mit ECHTEM Login live verifiziert (Test-Account, 2 neue E2E-Tests grün)
|
## 2026-07-06 – Nacht-Session (A): SQLCipher mit ECHTEM Login live verifiziert (Test-Account, 2 neue E2E-Tests grün)
|
||||||
|
|
||||||
**Erledigt:** Bernds Fokus (A) für die Nacht – die Verschlüsselung nicht nur
|
**Erledigt:** Bernds Fokus (A) für die Nacht – die Verschlüsselung nicht nur
|
||||||
|
|||||||
+24
@@ -258,6 +258,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||||
|
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
|
||||||
|
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
|
||||||
|
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
|
||||||
|
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
|
||||||
|
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
|
||||||
|
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
|
||||||
|
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
|
||||||
|
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
|
||||||
|
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
|
||||||
|
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
|
||||||
|
Code abgesichert). Nicht abgehakt.
|
||||||
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||||
@@ -271,7 +283,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||||
|
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
|
||||||
|
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
|
||||||
|
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
|
||||||
|
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
|
||||||
|
durchgereicht), degradationPreference (60-FPS-Modi
|
||||||
|
maintainFramerate, kein Simulcast). contentHint='motion' in
|
||||||
|
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
|
||||||
|
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
|
||||||
|
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
|
||||||
|
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
|
||||||
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||||
|
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
|
||||||
|
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
|
||||||
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||||
|
|
||||||
|
|||||||
@@ -137,20 +137,56 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
context: context, builder: (_) => const CreateJoinDialog()),
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
),
|
),
|
||||||
if (!isCollapsed)
|
if (!isCollapsed)
|
||||||
...filtered.map((room) => _RoomItem(
|
...filtered.map((room) {
|
||||||
room: room,
|
final isVoice = _isVoiceRoom(room);
|
||||||
isActive: activeRoomId == room.id,
|
return _RoomItem(
|
||||||
pt: pt,
|
room: room,
|
||||||
onTap: () {
|
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(activeRoomIdProvider.notifier).state = room.id;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
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 {
|
class _SectionHeader extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
final _filterCtrl = TextEditingController();
|
final _filterCtrl = TextEditingController();
|
||||||
String _filter = '';
|
String _filter = '';
|
||||||
final Map<String, bool> _collapsed = {};
|
final Map<String, bool> _collapsed = {};
|
||||||
|
String? _lastShownCallError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -37,6 +38,30 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(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 roomsAsync = ref.watch(roomListProvider);
|
||||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||||
final call = ref.watch(voiceChannelProvider);
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
|
|
||||||
Timer? _statsTimer;
|
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
|
@override
|
||||||
bool get isActive => _room != null || isConnecting;
|
bool get isActive => _room != null || isConnecting;
|
||||||
@override
|
@override
|
||||||
@@ -93,6 +99,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
void _onRoomChanged() => notifyListeners();
|
void _onRoomChanged() => notifyListeners();
|
||||||
|
|
||||||
void _cleanUpAfterDisconnect() {
|
void _cleanUpAfterDisconnect() {
|
||||||
|
_callGeneration++;
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
@@ -105,7 +112,9 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
_room = null;
|
_room = null;
|
||||||
_displayName = '';
|
_displayName = '';
|
||||||
isConnecting = false;
|
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;
|
isMuted = false;
|
||||||
isCameraOff = false;
|
isCameraOff = false;
|
||||||
isScreenSharing = false;
|
isScreenSharing = false;
|
||||||
@@ -162,6 +171,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
String? matrixRoomId,
|
String? matrixRoomId,
|
||||||
}) async {
|
}) async {
|
||||||
if (isActive) return;
|
if (isActive) return;
|
||||||
|
final gen = ++_callGeneration;
|
||||||
|
|
||||||
final url = 'wss://livekit.steggi-matrix.work';
|
final url = 'wss://livekit.steggi-matrix.work';
|
||||||
|
|
||||||
@@ -223,6 +233,17 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
|
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;
|
_room = room;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -263,7 +284,11 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||||
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||||
..on<ParticipantDisconnectedEvent>((_) => 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();
|
_cleanUpAfterDisconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,9 +343,16 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
_startStatsLogging();
|
_startStatsLogging();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VOIP] Start call error: $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;
|
isConnecting = false;
|
||||||
error = e.toString();
|
|
||||||
_cleanUpAfterDisconnect();
|
_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
|
@override
|
||||||
Future<void> hangUp() async {
|
Future<void> hangUp() async {
|
||||||
|
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||||
|
_callGeneration++;
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||||
|
|
||||||
|
/// Setup-Helfer für die Call-Tests mit den Autopilot-Test-Accounts (gegated
|
||||||
|
/// wie `live_encryption_e2e_test.dart`, läuft NIE im normalen `flutter test`):
|
||||||
|
/// stellt sicher, dass ein Voice-Channel-Raum („Pyramid Autopilot Voice",
|
||||||
|
/// `io.pyramid.voice_channel` + Presence-PowerLevel 0 wie im
|
||||||
|
/// create_join_dialog) existiert und BEIDE Test-Nutzer Mitglied sind.
|
||||||
|
/// Danach können zwei GUI-Instanzen (PYRAMID_PROFILE_DIR) den Kanal joinen.
|
||||||
|
///
|
||||||
|
/// Kein Logout am Ende nötig/gewollt für die GUI-Sessions – die Skript-Geräte
|
||||||
|
/// melden sich selbst ab, die GUI-Tokens bleiben unberührt.
|
||||||
|
|
||||||
|
const _kVoiceRoomName = 'Pyramid Autopilot Voice';
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$profile\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _findSqliteDll() {
|
||||||
|
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Client> _login(String name, String user, String pass, Uri hs) async {
|
||||||
|
// Wegwerf-Client: In-Memory-DB reicht (die DLL aus dem App-Build laden,
|
||||||
|
// der flutter_tester bringt selbst keine sqlite3.dll mit).
|
||||||
|
final dll = _findSqliteDll()!;
|
||||||
|
final factory = createDatabaseFactoryFfi(
|
||||||
|
ffiInit: () {
|
||||||
|
sqlite_open.open.overrideFor(
|
||||||
|
sqlite_open.OperatingSystem.windows,
|
||||||
|
() => DynamicLibrary.open(dll),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
noIsolate: true,
|
||||||
|
);
|
||||||
|
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||||
|
final sdkDb = await MatrixSdkDatabase.init(name, database: db);
|
||||||
|
final client = Client(name, database: sdkDb);
|
||||||
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||||
|
await client.checkHomeserver(hs);
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: name,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final acc = _loadAccounts();
|
||||||
|
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
|
||||||
|
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
|
||||||
|
: acc == null
|
||||||
|
? 'Test-Account-Datei fehlt'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test('Voice-Channel-Testraum existiert und beide Test-Nutzer sind drin',
|
||||||
|
() async {
|
||||||
|
final hs = Uri.parse(acc!['homeserver']!);
|
||||||
|
final c1 = await _login('AutopilotCallSetup1', acc['user1']!, acc['pass1']!, hs);
|
||||||
|
final c2 = await _login('AutopilotCallSetup2', acc['user2']!, acc['pass2']!, hs);
|
||||||
|
try {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] beide Logins ok, erster Sync…');
|
||||||
|
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] c1 sync ok, suche Raum…');
|
||||||
|
|
||||||
|
Room? voiceRoom;
|
||||||
|
for (final r in c1.rooms) {
|
||||||
|
if (r.membership == Membership.join &&
|
||||||
|
r.name == _kVoiceRoomName &&
|
||||||
|
r.getState(EventTypes.RoomCreate)?.content['type'] ==
|
||||||
|
'io.pyramid.voice_channel') {
|
||||||
|
voiceRoom = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceRoom == null) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum fehlt, lege an…');
|
||||||
|
final roomId = await c1.createRoom(
|
||||||
|
name: _kVoiceRoomName,
|
||||||
|
visibility: Visibility.private,
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||||
|
);
|
||||||
|
// Presence muss für normale Mitglieder schreibbar sein – exakt wie
|
||||||
|
// in create_join_dialog.dart (Continuwuity ignoriert
|
||||||
|
// power_level_content_override beim createRoom).
|
||||||
|
final plRaw =
|
||||||
|
await c1.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||||
|
final pl = Map<String, dynamic>.from(plRaw as Map);
|
||||||
|
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||||
|
events['io.pyramid.voice.presence'] = 0;
|
||||||
|
pl['events'] = events;
|
||||||
|
await c1.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum $roomId angelegt, warte auf Sync…');
|
||||||
|
await c1.waitForRoomInSync(roomId, join: true)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
voiceRoom = c1.getRoomById(roomId);
|
||||||
|
}
|
||||||
|
expect(voiceRoom, isNotNull);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] Raum da: ${voiceRoom!.id}, hole user2 rein…');
|
||||||
|
|
||||||
|
// user2 einladen (falls noch nicht Mitglied) und beitreten lassen.
|
||||||
|
final u2 = c2.userID!;
|
||||||
|
await c2.oneShotSync().timeout(const Duration(seconds: 60));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[setup] c2 sync ok…');
|
||||||
|
final r2 = c2.getRoomById(voiceRoom.id);
|
||||||
|
if (r2 == null || r2.membership != Membership.join) {
|
||||||
|
if (r2 == null || r2.membership != Membership.invite) {
|
||||||
|
try {
|
||||||
|
await voiceRoom.invite(u2);
|
||||||
|
} on MatrixException catch (e) {
|
||||||
|
// "already in the room" o. ä. ist ok.
|
||||||
|
if (e.error != MatrixError.M_FORBIDDEN) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await c2.joinRoom(voiceRoom.id);
|
||||||
|
await c2.waitForRoomInSync(voiceRoom.id, join: true)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
final members = await voiceRoom.requestParticipants();
|
||||||
|
final joined = members
|
||||||
|
.where((m) => m.membership == Membership.join)
|
||||||
|
.map((m) => m.id.toLowerCase())
|
||||||
|
.toSet();
|
||||||
|
expect(joined.contains(acc['user1']!.toLowerCase()), isTrue);
|
||||||
|
expect(joined.contains(u2.toLowerCase()), isTrue,
|
||||||
|
reason: 'user2 ist dem Voice-Raum nicht beigetreten');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Voice-Testraum bereit: ${voiceRoom.id}');
|
||||||
|
} finally {
|
||||||
|
// Nur die Skript-Geräte abmelden – GUI-Sessions bleiben unberührt.
|
||||||
|
try {
|
||||||
|
await c1.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await c2.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
await c1.dispose(closeDatabase: false);
|
||||||
|
await c2.dispose(closeDatabase: false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user