From f46cc22aaee533d29c74d2ea6e8d4093c5ee963f Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Thu, 8 Oct 2026 16:20:48 +0200 Subject: [PATCH] fix(calls): Taub wirkt, Stumm-Knopf zeigt echten Zustand, Mini-Leiste erst nach Annehmen, HDR-Hinweis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bernds Test 2026-10-08: - Taub-Knopf setzte bei 1:1-Anrufen nur ein Häkchen. Jetzt wie Discord: Ton der Gegenseite aus (Empfangsspuren deaktiviert, auch nach Neuverhandlung) und selbst stumm; beim Aufheben Mikro-Zustand von vorher. - Stumm: schlug nur das Senden der Status-Info fehl, schaltete Pyramid den Knopf blind zurück, obwohl das Mikro aus war – danach zeigte der Knopf das Gegenteil. Jetzt wird der echte SDK-Zustand übernommen. - Mini-Anrufleiste erschien schon beim Klingeln (vor dem Annehmen). - Bildschirm-Auswähler: deutsch, Hinweis zur bekannten WebRTC-Grenze bei HDR unter Windows (blasses Bild; Fenster teilen oder Win+Alt+B). - Live-Anruftest drückt Stumm und Taub über die echten Knöpfe und prüft, was bei der Gegenseite ankommt. Co-Authored-By: Claude Opus 5.5 --- integration_test/live_call_test.dart | 47 ++++++++++++++++++ lib/features/call_signaling/voip_manager.dart | 48 ++++++++++++++++--- .../rooms/panel/rooms_panel_core.dart | 5 +- lib/widgets/screen_share_picker.dart | 35 +++++++++++--- 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/integration_test/live_call_test.dart b/integration_test/live_call_test.dart index 6c63cd9..dfa1916 100644 --- a/integration_test/live_call_test.dart +++ b/integration_test/live_call_test.dart @@ -95,6 +95,53 @@ void main() { await _checkAudio(tester, voip, partner); + // Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus. + bool partnerSeesMuted() => + partner.delegate.call?.remoteUserMediaStream?.audioMuted == true; + await tester.tap(find.byIcon(Icons.mic_rounded).first); + await _pumpUntil(tester, partnerSeesMuted, + timeout: const Duration(seconds: 15), + what: 'Gegenseite sieht: App ist stumm'); + expect(voip.isMicMuted, isTrue); + expect( + voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled, + isFalse, + reason: 'Mikrofonspur ist trotz Stumm noch an', + ); + await tester.tap(find.byIcon(Icons.mic_off_rounded).first); + await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted, + timeout: const Duration(seconds: 15), + what: 'Stumm wieder aufgehoben'); + _log('Stumm/Entstummen über den Knopf: ok'); + + // Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück. + await tester.tap(find.byIcon(Icons.headphones_rounded).first); + await _pumpUntil( + tester, + () => voip.isDeafened && partnerSeesMuted(), + timeout: const Duration(seconds: 15), + what: 'Taub: selbst stumm', + ); + final remoteAudio = voip.currentCall!.getRemoteStreams + .expand((w) => w.stream?.getAudioTracks() ?? const []); + expect(remoteAudio, isNotEmpty); + expect(remoteAudio.every((t) => !t.enabled), isTrue, + reason: 'Ton der Gegenseite ist trotz Taub noch an'); + await tester.tap(find.byIcon(Icons.headset_off_rounded).first); + await _pumpUntil( + tester, + () => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted, + timeout: const Duration(seconds: 15), + what: 'Taub aufgehoben, Mikro wieder an', + ); + expect( + voip.currentCall!.getRemoteStreams + .expand((w) => w.stream?.getAudioTracks() ?? const []) + .every((t) => t.enabled), + isTrue, + ); + _log('Taub/Hören über den Knopf: ok'); + // Gegenseite teilt Bildschirm → App zeigt ihn await tester.runAsync( () => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)), diff --git a/lib/features/call_signaling/voip_manager.dart b/lib/features/call_signaling/voip_manager.dart index 6cc750c..a2a09bc 100644 --- a/lib/features/call_signaling/voip_manager.dart +++ b/lib/features/call_signaling/voip_manager.dart @@ -172,6 +172,8 @@ class PyramidVoipManager extends CallSignalingService currentCall = call; _isCameraMuted = call.type == CallType.kVoice; _speakerOn = true; + _isDeafened = false; + _isMicMuted = false; _setupCallListeners(call); _updateInternalStates(); notifyListeners(); @@ -341,6 +343,8 @@ class PyramidVoipManager extends CallSignalingService _isRemoteScreensharing = remoteSharing; _isScreensharing = localSharing; + if (_isDeafened) _applyDeafen(call); + // Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite – // nur einmal pro Stream, _updateRenderers läuft auch per Timer. if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) { @@ -506,17 +510,24 @@ class PyramidVoipManager extends CallSignalingService } @override - Future toggleMic() async { - if (currentCall == null) return; - _isMicMuted = !_isMicMuted; + Future toggleMic() => _setMicMuted(!_isMicMuted); + + Future _setMicMuted(bool muted) async { + final c = currentCall; + if (c == null) return; + _isMicMuted = muted; notifyListeners(); try { - final c = currentCall!; - await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted)); + await _inSdkZone(() => c.setMicrophoneMuted(muted)); } catch (e) { - _isMicMuted = !_isMicMuted; - notifyListeners(); + // Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt + // nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher + // wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das + // Gegenteil des echten Zustands (Bernd 2026-10-08). + debugPrint('[VOIP] Stummschalten: $e'); } + _isMicMuted = c.isMicrophoneMuted; + notifyListeners(); } @override @@ -566,8 +577,31 @@ class PyramidVoipManager extends CallSignalingService @override Future toggleDeafen() async { + // Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm; + // beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher + // setzte der Knopf nur ein Häkchen ohne Wirkung.) _isDeafened = !_isDeafened; notifyListeners(); + final c = currentCall; + if (c == null) return; + _applyDeafen(c); + if (_isDeafened) { + _micMutedBeforeDeafen = _isMicMuted; + if (!_isMicMuted) await _setMicMuted(true); + } else if (!_micMutedBeforeDeafen && _isMicMuted) { + await _setMicMuted(false); + } + } + + bool _micMutedBeforeDeafen = false; + + /// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm). + void _applyDeafen(CallSession call) { + for (final w in call.getRemoteStreams) { + for (final t in w.stream?.getAudioTracks() ?? const []) { + if (t.enabled == _isDeafened) t.enabled = !_isDeafened; + } + } } @override diff --git a/lib/features/rooms/panel/rooms_panel_core.dart b/lib/features/rooms/panel/rooms_panel_core.dart index 2f9a78d..ec41055 100644 --- a/lib/features/rooms/panel/rooms_panel_core.dart +++ b/lib/features/rooms/panel/rooms_panel_core.dart @@ -131,8 +131,11 @@ class _RoomsPanelState extends ConsumerState { // Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und // Konto-Balken nebeneinander statt übereinander. Builder(builder: (context) { + // Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es + // das Annehmen-Fenster (Bernd 2026-10-08). final hasVoipCall = voip.currentCall != null && - voip.currentCall!.state != CallState.kEnded; + voip.currentCall!.state != CallState.kEnded && + voip.currentCall!.state != CallState.kRinging; final hasCallDock = call.isActive || hasVoipCall; final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500; diff --git a/lib/widgets/screen_share_picker.dart b/lib/widgets/screen_share_picker.dart index 07116d9..cb74867 100644 --- a/lib/widgets/screen_share_picker.dart +++ b/lib/widgets/screen_share_picker.dart @@ -78,7 +78,7 @@ class _ScreenSharePickerState extends State with SingleTicker padding: const EdgeInsets.fromLTRB(24, 20, 16, 8), child: Row( children: [ - Text('Share your screen', + Text('Bildschirm teilen', style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), const Spacer(), IconButton( @@ -97,8 +97,8 @@ class _ScreenSharePickerState extends State with SingleTicker unselectedLabelColor: pt.fgMuted, dividerColor: pt.border, tabs: const [ - Tab(text: 'Screens'), - Tab(text: 'Windows'), + Tab(text: 'Bildschirme'), + Tab(text: 'Fenster'), ], ), @@ -124,11 +124,34 @@ class _ScreenSharePickerState extends State with SingleTicker border: Border(top: BorderSide(color: pt.border)), ), child: Row( - mainAxisAlignment: MainAxisAlignment.end, children: [ + // Bekannte WebRTC-Grenze unter Windows: Bei eingeschaltetem + // HDR liefert die Bildschirmaufnahme (DXGI) ein blasses, + // zu helles Bild – Zoom/Discord haben dasselbe. Abhilfe: + // Fenster teilen oder HDR kurz umschalten (Bernd 2026-10-08). + if (Theme.of(context).platform == TargetPlatform.windows) + Expanded( + child: Row( + children: [ + Icon(Icons.hdr_on_rounded, size: 18, color: pt.fgDim), + const SizedBox(width: 8), + Expanded( + child: Text( + 'HDR an? Dann wirkt ein geteilter Bildschirm blass. ' + 'Besser ein Fenster teilen oder HDR kurz umschalten ' + '(Win + Alt + B).', + style: TextStyle(color: pt.fgDim, fontSize: 11.5), + ), + ), + ], + ), + ) + else + const Spacer(), + const SizedBox(width: 12), TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel', style: TextStyle(color: pt.fgMuted)), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ], ), @@ -149,7 +172,7 @@ class _SourceGrid extends StatelessWidget { @override Widget build(BuildContext context) { if (sources.isEmpty) { - return Center(child: Text('No sources found', style: TextStyle(color: pt.fgDim))); + return Center(child: Text('Keine Quellen gefunden', style: TextStyle(color: pt.fgDim))); } return GridView.builder(