fix(calls): Taub wirkt, Stumm-Knopf zeigt echten Zustand, Mini-Leiste erst nach Annehmen, HDR-Hinweis

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 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-08 16:20:48 +02:00
co-authored by Claude Opus 5.5
parent 2bbb45fe19
commit f46cc22aae
4 changed files with 121 additions and 14 deletions
+47
View File
@@ -95,6 +95,53 @@ void main() {
await _checkAudio(tester, voip, partner); 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 <MediaStreamTrack>[]);
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 <MediaStreamTrack>[])
.every((t) => t.enabled),
isTrue,
);
_log('Taub/Hören über den Knopf: ok');
// Gegenseite teilt Bildschirm → App zeigt ihn // Gegenseite teilt Bildschirm → App zeigt ihn
await tester.runAsync( await tester.runAsync(
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)), () => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
+41 -7
View File
@@ -172,6 +172,8 @@ class PyramidVoipManager extends CallSignalingService
currentCall = call; currentCall = call;
_isCameraMuted = call.type == CallType.kVoice; _isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true; _speakerOn = true;
_isDeafened = false;
_isMicMuted = false;
_setupCallListeners(call); _setupCallListeners(call);
_updateInternalStates(); _updateInternalStates();
notifyListeners(); notifyListeners();
@@ -341,6 +343,8 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = remoteSharing; _isRemoteScreensharing = remoteSharing;
_isScreensharing = localSharing; _isScreensharing = localSharing;
if (_isDeafened) _applyDeafen(call);
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite – // Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
// nur einmal pro Stream, _updateRenderers läuft auch per Timer. // nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) { if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
@@ -506,17 +510,24 @@ class PyramidVoipManager extends CallSignalingService
} }
@override @override
Future<void> toggleMic() async { Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
if (currentCall == null) return;
_isMicMuted = !_isMicMuted; Future<void> _setMicMuted(bool muted) async {
final c = currentCall;
if (c == null) return;
_isMicMuted = muted;
notifyListeners(); notifyListeners();
try { try {
final c = currentCall!; await _inSdkZone(() => c.setMicrophoneMuted(muted));
await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted));
} catch (e) { } catch (e) {
_isMicMuted = !_isMicMuted; // Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
notifyListeners(); // 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 @override
@@ -566,8 +577,31 @@ class PyramidVoipManager extends CallSignalingService
@override @override
Future<void> toggleDeafen() async { Future<void> 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; _isDeafened = !_isDeafened;
notifyListeners(); 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 <MediaStreamTrack>[]) {
if (t.enabled == _isDeafened) t.enabled = !_isDeafened;
}
}
} }
@override @override
@@ -131,8 +131,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und // Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander. // Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) { 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 && 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 hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact = final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500; MediaQuery.sizeOf(context).height < 500;
+29 -6
View File
@@ -78,7 +78,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
padding: const EdgeInsets.fromLTRB(24, 20, 16, 8), padding: const EdgeInsets.fromLTRB(24, 20, 16, 8),
child: Row( child: Row(
children: [ children: [
Text('Share your screen', Text('Bildschirm teilen',
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
const Spacer(), const Spacer(),
IconButton( IconButton(
@@ -97,8 +97,8 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
unselectedLabelColor: pt.fgMuted, unselectedLabelColor: pt.fgMuted,
dividerColor: pt.border, dividerColor: pt.border,
tabs: const [ tabs: const [
Tab(text: 'Screens'), Tab(text: 'Bildschirme'),
Tab(text: 'Windows'), Tab(text: 'Fenster'),
], ],
), ),
@@ -124,11 +124,34 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
border: Border(top: BorderSide(color: pt.border)), border: Border(top: BorderSide(color: pt.border)),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ 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( TextButton(
onPressed: () => Navigator.pop(context), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (sources.isEmpty) { 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( return GridView.builder(