Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5204bb81bb | ||
|
|
f46cc22aae | ||
|
|
2bbb45fe19 | ||
|
|
064d730f33 | ||
|
|
8074928505 | ||
|
|
5485b6d449 |
+93
@@ -13,6 +13,99 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-08 (Teil 2) – Anrufe im Live-Test nachgestellt: Teilen, Verbindung, Sprech-Ring → 0.1.40
|
||||||
|
|
||||||
|
**Erledigt (8074928, Version 064d730, Gitea-Vorabversion v0.1.40):**
|
||||||
|
- **Neuer Live-Anruftest** `integration_test/live_call_test.dart`: App
|
||||||
|
(pyramidtest1) + zweiter Matrix-Client (pyramidtest2, kurzlebiges Gerät)
|
||||||
|
im selben Prozess, eigener unverschlüsselter Raum. Teil 1: Gegenseite ruft
|
||||||
|
an, App nimmt über den echten Knopf an; Ton beide Richtungen (Bytes),
|
||||||
|
Bildschirm der Gegenseite erscheint in der App, App teilt über den echten
|
||||||
|
Quellen-Auswähler. Teil 2: App ruft an. In `scripts/test.ps1 -Ui`.
|
||||||
|
- **Bildschirm teilen** wurde nie angezeigt: `_updateRenderers` rief ein
|
||||||
|
SDK-`getFeeds()` auf, das es nicht gibt (Fehler geschluckt). Jetzt
|
||||||
|
typisierte Zugriffe, `stopped` beachtet.
|
||||||
|
- **Verbindungsaufbau:** matrix-SDK 6.2 verwirft Kandidaten, die vor der
|
||||||
|
fertig verarbeiteten Einladung (eigener Listener je Sync, nicht
|
||||||
|
serialisiert) bzw. während `answer()` eintreffen; Windows-
|
||||||
|
`getRemoteDescription` wirft statt null. Pyramid puffert Kandidaten der
|
||||||
|
Gegenseite (`_bufferRemoteCandidates`) und reicht sie direkt an die
|
||||||
|
PeerConnection nach. Vorher im Test NIE verbunden, jetzt 0,5 s / 2,7 s.
|
||||||
|
- **SDK-Zone** in `voip_manager.dart`: unbehandelte SDK/Plugin-Fehler
|
||||||
|
werden protokolliert; `_inSdkZone` reicht Ergebnis/Fehler per Completer
|
||||||
|
über die Zonengrenze (sonst würde der Aufrufer bei Fehlern ewig warten).
|
||||||
|
- **Sprech-Ring** bei 1:1-Anrufen (audioLevel aus getStats, Hysterese);
|
||||||
|
großes Bild springt nicht mehr beim Sprechen.
|
||||||
|
- **Trenner** mitten in einer Gruppe (Inline-Variante) blieb stehen → nur mit
|
||||||
|
Ungelesenem, blendet aus, trägt den Sprungschlüssel; Live-Test Teil 3 in
|
||||||
|
`live_receipts_invite_test.dart`.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Bernd testet 0.1.40 + Testinstanz (übernimmt
|
||||||
|
`app-neu` beim nächsten Start). PC→Handy-Ton: Transport ist belegt (Bytes
|
||||||
|
fließen), offen ist, ob das PC-Mikrofon Ton liefert (Messung: alle Geräte
|
||||||
|
still; Voicemeeter) → Sprech-Ring / Mikrofontest prüfen.
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) `what:`-Texte in Warte-Helfern werden beim Aufruf
|
||||||
|
ausgewertet, nicht beim Timeout – Zustände per Callback ausgeben.
|
||||||
|
(2) SDK-Mitglieder-Cache: `getParticipants()`/`getState(RoomMember)` sind
|
||||||
|
ohne Nachladen leer – nicht darauf warten. (3) Dart: Fehler überqueren
|
||||||
|
Error-Zone-Grenzen nicht – Completer verwenden. (4) Analyzer-Warnungen
|
||||||
|
beginnen ohne Einrückung („warning - …“) – beim grep beachten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-08 – Bernds Anruf-/Chat-Test mit PC-Testinstanz → Test-Version 0.1.39
|
||||||
|
|
||||||
|
**Erledigt (je ein Commit, alles gepusht):**
|
||||||
|
- **PC-Testinstanz** (a6461fe + außerhalb des Repos): `C:\Users\nordm\pyramid-testinstanz\`
|
||||||
|
(Release-Build, `Pyramid-Testinstanz.cmd`, Desktop-Verknüpfung), Profil
|
||||||
|
`profile2` = pyramidtest2, Fenstertitel „Pyramid - TESTINSTANZ“,
|
||||||
|
Doppelstart-Schutz, übernimmt bereitgelegte Updates (`app-neu`) beim
|
||||||
|
nächsten Start. DM pyramidtest2 ↔ Bernd angelegt.
|
||||||
|
- **Haken live / Einladungen / Trenner** (c817de9): ✓✓ zeichnet nach jedem
|
||||||
|
Sync neu, wenn sich Lesepositionen ändern (Raum-Update kommt im SDK VOR der
|
||||||
|
Bestätigung); Einladung im Chat annehmen, Verlauf baut sich bei
|
||||||
|
invite→join neu auf; Trenner blendet 6 s nach Erreichen der neuesten
|
||||||
|
Nachrichten aus, auch ohne Scrollen. Live-Test mit beiden Testkonten
|
||||||
|
(`integration_test/live_receipts_invite_test.dart`, Gegenprobe rot ohne
|
||||||
|
Fix), in `scripts/test.ps1 -Ui`.
|
||||||
|
- **Anrufe** (441cc6e): Bildschirm teilen auf Android 14+ stürzte ab
|
||||||
|
(kein mediaProjection-Vordergrunddienst; Manifest verwies auf nicht
|
||||||
|
existierende flutter_webrtc-Klasse) → `CallService.kt` +
|
||||||
|
`lib/core/call_foreground.dart` (Zustimmung → Dienst, wartet auf echten
|
||||||
|
startForeground → Mitschnitt). Emulator-Test Android 16
|
||||||
|
(`integration_test/android_screen_capture_test.dart`): mit Fix läuft der
|
||||||
|
Mitschnitt, Gegenprobe wirft die erwartete SecurityException. Dienst hält
|
||||||
|
während jedes Anrufs das Mikrofon im Hintergrund aktiv + „Anruf läuft“-
|
||||||
|
Benachrichtigung. Handy: Lautsprecher statt Hörmuschel (SDK schaltet
|
||||||
|
Sprachanrufe auf Hörmuschel) + Umschalter. „Anruf läuft · Zurück“-Leiste im
|
||||||
|
Chat. LiveKit-Teilen am Handy ohne PC-Quellen-Auswähler. Mikrofontest mit
|
||||||
|
Pegel in den Einstellungen.
|
||||||
|
- **Version 0.1.39+40** (775cf71) als Gitea-Vorabversion.
|
||||||
|
|
||||||
|
**Befund PC-Mikrofon:** Lokale Pegelmessung über WebRTC lieferte auf ALLEN
|
||||||
|
10 Aufnahmegeräten (Voicemeeter-Ausgänge, Steam, Razer Barracuda X) nur
|
||||||
|
digitale Stille (audioLevel 1/32767). Entweder war das Headset aus, oder das
|
||||||
|
Mikrofon läuft über Voicemeeter und Pyramid nutzt das falsche Gerät – das
|
||||||
|
erklärt „Handy hört den PC nicht“. Bernd prüft mit dem neuen Mikrofontest.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Bernd testet 0.1.39 + Testinstanz: Teilen am
|
||||||
|
Handy (System-Dialog „Gesamten Bildschirm teilen“), Ton beide Richtungen
|
||||||
|
(PC-Mikrofon im Mikrofontest wählen), Teilen vom PC, Verbindungsdauer
|
||||||
|
außerhalb des Heimnetzes (ICE/TURN ist ok: TURN-Daten werden alle 4 h
|
||||||
|
erneuert; Verzögerung vermutlich SDK-Kandidaten-Bündelung 2 s + verschlüsselte
|
||||||
|
Signalisierung – erst messen, dann ändern).
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Git-Bash schreibt `/sdcard/...` in Windows-Pfade um →
|
||||||
|
`MSYS_NO_PATHCONV=1` für adb. (2) Android 16 schlägt beim Teilen „eine App“
|
||||||
|
vor und wechselt in diese App – das sah wie „App verkleinert sich“ aus.
|
||||||
|
(3) Ein Dienst mit Typ microphone ohne RECORD_AUDIO-Berechtigung wird KOMPLETT
|
||||||
|
verweigert – Typen nur mit Berechtigung anfordern. (4) Python-urllib wird von
|
||||||
|
Cloudflare ohne User-Agent abgewiesen (Aufräumskript). (5) Testkonten:
|
||||||
|
Schlüssel in test-accounts.txt heißen user1/pass1/user2/pass2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
||||||
|
|
||||||
**Erledigt (je ein Commit, alles gepusht):**
|
**Erledigt (je ein Commit, alles gepusht):**
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
|
||||||
|
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||||
|
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||||
|
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||||||
|
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||||||
|
///
|
||||||
|
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||||||
|
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||||||
|
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||||||
|
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||||||
|
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_call_test.dart -d windows
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = _loadAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||||
|
(tester) async {
|
||||||
|
Logs().level = Level.verbose;
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final me = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
|
||||||
|
final partner = (await tester.runAsync(
|
||||||
|
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
))!;
|
||||||
|
String? roomId;
|
||||||
|
addTearDown(() => tester.runAsync(() async {
|
||||||
|
await partner.dispose(roomId);
|
||||||
|
final r = roomId == null ? null : me.getRoomById(roomId);
|
||||||
|
await r?.leave().catchError((_) {});
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||||||
|
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||||||
|
invite: [me.userID!],
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
name: 'Anruftest (automatisch)',
|
||||||
|
));
|
||||||
|
_log('Raum angelegt: $roomId');
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||||
|
what: 'Einladung kommt bei der App an',
|
||||||
|
);
|
||||||
|
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||||||
|
_log('App ist beigetreten');
|
||||||
|
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||||
|
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
final voip = container.read(callSignalingProvider);
|
||||||
|
|
||||||
|
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||||
|
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||||
|
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||||||
|
_log('Teil 1: Gegenseite ruft an');
|
||||||
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
|
partner.client.getRoomById(roomId!)!,
|
||||||
|
CallType.kVoice,
|
||||||
|
)));
|
||||||
|
final answerBtn = find.byWidgetPredicate(
|
||||||
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||||
|
what: 'Annehmen-Knopf erscheint');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||||||
|
await tester.tap(answerBtn.first);
|
||||||
|
await _waitConnected(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
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||||
|
);
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.isRemoteScreensharing &&
|
||||||
|
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final appVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
_log('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||||||
|
expect(appVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||||
|
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||||
|
|
||||||
|
// App teilt ihren Bildschirm über den echten Auswähler
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||||
|
what: 'Teilen-Knopf in der Anrufleiste',
|
||||||
|
);
|
||||||
|
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||||||
|
final tile = find.byWidgetPredicate(
|
||||||
|
(w) => w.runtimeType.toString() == '_SourceTile',
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||||
|
await tester.tap(tile.first);
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() {
|
||||||
|
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||||
|
return voip.isScreensharing &&
|
||||||
|
s != null &&
|
||||||
|
s.getVideoTracks().isNotEmpty;
|
||||||
|
},
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final partnerVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
_log('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||||||
|
expect(partnerVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der App kommt nicht an');
|
||||||
|
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 1)');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||||||
|
_log('Teil 2: App ruft an');
|
||||||
|
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||||
|
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||||
|
await _waitConnected(tester, voip, partner);
|
||||||
|
await _checkAudio(tester, voip, partner);
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 2)');
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitConnected(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
_Partner partner,
|
||||||
|
) async {
|
||||||
|
final t0 = DateTime.now();
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.currentCall?.state == CallState.kConnected &&
|
||||||
|
partner.delegate.call?.state == CallState.kConnected,
|
||||||
|
timeout: const Duration(seconds: 60),
|
||||||
|
what: 'Anruf verbunden',
|
||||||
|
every5s: () {
|
||||||
|
final a = voip.currentCall;
|
||||||
|
final b = partner.delegate.call;
|
||||||
|
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||||||
|
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAudio(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
_Partner partner,
|
||||||
|
) async {
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
final appAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
final partnerAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
_log('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||||||
|
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||||
|
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||||
|
expect(partnerAudio.received, greaterThan(0),
|
||||||
|
reason: 'Gegenseite empfängt keinen Ton der App');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Rtp {
|
||||||
|
_Rtp(this.sent, this.received);
|
||||||
|
final num sent;
|
||||||
|
final num received;
|
||||||
|
@override
|
||||||
|
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||||||
|
num sent = 0;
|
||||||
|
num received = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != kind) continue;
|
||||||
|
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||||||
|
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||||||
|
}
|
||||||
|
return _Rtp(sent, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||||||
|
class _Partner {
|
||||||
|
_Partner(this.client, this.delegate, this.voip, this.zone) {
|
||||||
|
_sub = client.onCallEvents.stream.listen((events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (list != null) _buffer.addAll(list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
final pc = delegate.call?.pc;
|
||||||
|
if (!fixCandidates || pc == null) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
for (final c in List.of(_buffer)) {
|
||||||
|
final cand = c['candidate'] as String?;
|
||||||
|
if (cand == null || !_applied.add(cand)) continue;
|
||||||
|
await pc.addCandidate(RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final Client client;
|
||||||
|
final _PartnerDelegate delegate;
|
||||||
|
final VoIP voip;
|
||||||
|
final Zone zone;
|
||||||
|
|
||||||
|
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
||||||
|
Future<T> run<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
zone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
||||||
|
bool fixCandidates = false;
|
||||||
|
final _buffer = <Map<String, dynamic>>[];
|
||||||
|
final _applied = <String>{};
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
static Future<_Partner> start(String user, String pass) async {
|
||||||
|
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||||
|
final client = Client(
|
||||||
|
'pyramid-call-partner',
|
||||||
|
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
||||||
|
);
|
||||||
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||||
|
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
||||||
|
);
|
||||||
|
final delegate = _PartnerDelegate();
|
||||||
|
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
||||||
|
// im Testrahmen zu verlieren.
|
||||||
|
final zone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, z, e, s) =>
|
||||||
|
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final voip = zone.run(() => VoIP(client, delegate));
|
||||||
|
return _Partner(client, delegate, voip, zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose(String? roomId) async {
|
||||||
|
_timer?.cancel();
|
||||||
|
await _sub?.cancel();
|
||||||
|
try {
|
||||||
|
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
||||||
|
} catch (_) {}
|
||||||
|
if (roomId != null) {
|
||||||
|
try {
|
||||||
|
await client.leaveRoom(roomId);
|
||||||
|
await client.forgetRoom(roomId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.backgroundSync = false;
|
||||||
|
await client.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PartnerDelegate implements WebRTCDelegate {
|
||||||
|
CallSession? call;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RTCPeerConnection> createPeerConnection(
|
||||||
|
Map<String, dynamic> configuration, [
|
||||||
|
Map<String, dynamic> constraints = const {},
|
||||||
|
]) =>
|
||||||
|
rtc.createPeerConnection(configuration, constraints);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleNewCall(CallSession session) async {
|
||||||
|
call = session;
|
||||||
|
if (session.direction == CallDirection.kIncoming) {
|
||||||
|
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
||||||
|
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird (CallSession.onCandidatesReceived vs. answer()).
|
||||||
|
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
||||||
|
try {
|
||||||
|
await session.answer();
|
||||||
|
} catch (e) {
|
||||||
|
_log('Gegenseite: Annehmen fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleCallEnded(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleMissedCall(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> playRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> stopRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> registerListeners(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
bool get isWeb => false;
|
||||||
|
@override
|
||||||
|
bool get canHandleNewCall => true;
|
||||||
|
@override
|
||||||
|
EncryptionKeyProvider? get keyProvider => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
||||||
|
class _PartnerMedia extends MediaDevices {
|
||||||
|
_PartnerMedia(this._d);
|
||||||
|
final MediaDevices _d;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
||||||
|
final sources = await rtc.desktopCapturer
|
||||||
|
.getSources(types: [rtc.SourceType.Screen]);
|
||||||
|
return _d.getDisplayMedia({
|
||||||
|
'video': {
|
||||||
|
'mandatory': {
|
||||||
|
'chromeMediaSource': 'desktop',
|
||||||
|
'chromeMediaSourceId': sources.first.id,
|
||||||
|
},
|
||||||
|
'optional': [],
|
||||||
|
},
|
||||||
|
'audio': false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
||||||
|
@override
|
||||||
|
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
||||||
|
_d.getSupportedConstraints();
|
||||||
|
@override
|
||||||
|
set ondevicechange(Function(dynamic event)? callback) =>
|
||||||
|
_d.ondevicechange = callback;
|
||||||
|
@override
|
||||||
|
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
||||||
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
Future<List<dynamic>> getSources() => _d.getSources();
|
||||||
|
@override
|
||||||
|
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
||||||
|
_d.selectAudioOutput(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String m) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
bool Function() condition, {
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
String what = '',
|
||||||
|
void Function()? onTimeout,
|
||||||
|
void Function()? every5s,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
var next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (condition()) return;
|
||||||
|
if (every5s != null && DateTime.now().isAfter(next)) {
|
||||||
|
every5s();
|
||||||
|
next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log('ZEITÜBERSCHREITUNG: $what');
|
||||||
|
onTimeout?.call();
|
||||||
|
fail('Zeitüberschreitung: $what');
|
||||||
|
}
|
||||||
@@ -121,6 +121,39 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(find.text('Annehmen'), findsNothing);
|
expect(find.text('Annehmen'), findsNothing);
|
||||||
|
|
||||||
|
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||||
|
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||||
|
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final ids = (await tester.runAsync(() async => [
|
||||||
|
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 2'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 3'),
|
||||||
|
]))!;
|
||||||
|
final room = client.getRoomById(inviteRoomId)!;
|
||||||
|
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
|
||||||
|
final tEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
|
||||||
|
final fEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Neue Nachrichten'),
|
||||||
|
timeout: const Duration(seconds: 20));
|
||||||
|
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text('Neue Nachrichten'),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
);
|
||||||
|
|
||||||
// Aufräumen: Testraum verlassen + vergessen.
|
// Aufräumen: Testraum verlassen + vergessen.
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
final r = client.getRoomById(inviteRoomId);
|
final r = client.getRoomById(inviteRoomId);
|
||||||
@@ -197,11 +230,12 @@ class _RestUser {
|
|||||||
'preset': 'private_chat',
|
'preset': 'private_chat',
|
||||||
}))['room_id'] as String;
|
}))['room_id'] as String;
|
||||||
|
|
||||||
Future<void> sendText(String roomId, String text) => _call(
|
Future<String> sendText(String roomId, String text) async =>
|
||||||
|
(await _call(
|
||||||
'PUT',
|
'PUT',
|
||||||
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||||
{'msgtype': 'm.text', 'body': text},
|
{'msgtype': 'm.text', 'body': text},
|
||||||
);
|
))['event_id'] as String;
|
||||||
|
|
||||||
Future<void> invite(String roomId, String userId) =>
|
Future<void> invite(String roomId, String userId) =>
|
||||||
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> changeQuality(String key);
|
Future<void> changeQuality(String key);
|
||||||
|
|
||||||
|
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
|
||||||
|
/// grünen Sprech-Ring.)
|
||||||
|
bool get isLocalSpeaking => false;
|
||||||
|
bool get isRemoteSpeaking => false;
|
||||||
|
|
||||||
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
/// Auf dem PC ohne Bedeutung.
|
/// Auf dem PC ohne Bedeutung.
|
||||||
bool get isSpeakerOn => true;
|
bool get isSpeakerOn => true;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
final Client client;
|
final Client client;
|
||||||
late final VoIP voip;
|
late final VoIP voip;
|
||||||
|
late final Zone _sdkZone;
|
||||||
@override
|
@override
|
||||||
CallSession? currentCall;
|
CallSession? currentCall;
|
||||||
|
|
||||||
@@ -81,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
PyramidVoipManager(this.client) {
|
PyramidVoipManager(this.client) {
|
||||||
_instance = this;
|
_instance = this;
|
||||||
voip = VoIP(client, this);
|
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
|
||||||
|
// _bufferRemoteCandidates).
|
||||||
|
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
|
||||||
|
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
|
||||||
|
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
|
||||||
|
// unter Windows, getRemoteDescription wirft statt null), werden
|
||||||
|
// protokolliert statt als „unbehandelt“ durchzuschlagen.
|
||||||
|
_sdkZone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, zone, error, stack) {
|
||||||
|
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
voip = _sdkZone.run(() => VoIP(client, this));
|
||||||
// Suppress speakerphone call — not supported on Windows/Desktop
|
// Suppress speakerphone call — not supported on Windows/Desktop
|
||||||
try {
|
try {
|
||||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||||
@@ -89,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_ensureRenderers();
|
_ensureRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
|
||||||
|
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
|
||||||
|
/// überqueren, und der Aufrufer würde ewig warten.
|
||||||
|
Future<T> _inSdkZone<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
_sdkZone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_callEventSub?.cancel();
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_renderRefreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ensureRenderers() async {
|
Future<void> _ensureRenderers() async {
|
||||||
if (_renderersInitialized) return;
|
if (_renderersInitialized) return;
|
||||||
try {
|
try {
|
||||||
@@ -134,13 +172,87 @@ 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();
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
|
||||||
|
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
|
||||||
|
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
|
||||||
|
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
|
||||||
|
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
|
||||||
|
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
|
||||||
|
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
|
||||||
|
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
|
||||||
|
// WebRTC.
|
||||||
|
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
|
||||||
|
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
|
||||||
|
final _appliedCandidates = <String>{};
|
||||||
|
|
||||||
|
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callId = e.content.tryGet<String>('call_id');
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (callId == null || list == null) continue;
|
||||||
|
final party = e.content.tryGet<String>('party_id');
|
||||||
|
(_candidateBuffer[callId] ??= []).addAll(
|
||||||
|
list.map((c) => {...c, '_party': party}),
|
||||||
|
);
|
||||||
|
// Nur die letzten Anrufe behalten.
|
||||||
|
while (_candidateBuffer.length > 8) {
|
||||||
|
_candidateBuffer.remove(_candidateBuffer.keys.first);
|
||||||
|
}
|
||||||
|
final call = currentCall;
|
||||||
|
if (call != null && call.callId == callId) {
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyBufferedCandidates(CallSession call) async {
|
||||||
|
final pc = call.pc;
|
||||||
|
final list = _candidateBuffer[call.callId];
|
||||||
|
if (pc == null || list == null || list.isEmpty) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final c in List.of(list)) {
|
||||||
|
final cand = c['candidate'];
|
||||||
|
if (cand is! String || cand.isEmpty) continue;
|
||||||
|
final party = c['_party'];
|
||||||
|
final remoteParty = call.remotePartyId;
|
||||||
|
if (remoteParty != null && party != null && party != remoteParty) continue;
|
||||||
|
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
|
||||||
|
try {
|
||||||
|
await pc.addCandidate(rtc.RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Kandidat nachreichen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _renderRefreshTimer;
|
Timer? _renderRefreshTimer;
|
||||||
|
String? _listenedCallId;
|
||||||
void _setupCallListeners(CallSession call) {
|
void _setupCallListeners(CallSession call) {
|
||||||
|
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
|
||||||
|
// Listener nur einmal je Anruf anhängen.
|
||||||
|
if (_listenedCallId == call.callId) return;
|
||||||
|
_listenedCallId = call.callId;
|
||||||
call.onCallStateChanged.stream.listen((state) {
|
call.onCallStateChanged.stream.listen((state) {
|
||||||
debugPrint('[VOIP] State: $state');
|
debugPrint('[VOIP] State: $state');
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
@@ -151,11 +263,15 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
call.setLocalVideoMuted(false);
|
call.setLocalVideoMuted(false);
|
||||||
}
|
}
|
||||||
_applySpeaker();
|
_applySpeaker();
|
||||||
|
_startLevelPolling();
|
||||||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
CallForeground.callStarted('voip',
|
CallForeground.callStarted('voip',
|
||||||
title: call.room.getLocalizedDisplayname());
|
title: call.room.getLocalizedDisplayname());
|
||||||
}
|
}
|
||||||
if (state == CallState.kEnded) CallForeground.callEnded('voip');
|
if (state == CallState.kEnded) {
|
||||||
|
_stopLevelPolling();
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
|
}
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
});
|
});
|
||||||
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||||
@@ -167,7 +283,15 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = Timer.periodic(
|
_renderRefreshTimer = Timer.periodic(
|
||||||
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
||||||
(_) => _updateRenderers(),
|
(_) {
|
||||||
|
_updateRenderers();
|
||||||
|
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
|
||||||
|
// Gegenseite kennt (nach Annehmen bzw. Antwort).
|
||||||
|
final c = currentCall;
|
||||||
|
if (c != null && c.state != CallState.kConnected) {
|
||||||
|
_applyBufferedCandidates(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
@@ -184,96 +308,65 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateRenderers() async {
|
Future<void> _updateRenderers() async {
|
||||||
if (currentCall == null || _isUpdating) return;
|
final call = currentCall;
|
||||||
|
if (call == null || _isUpdating) return;
|
||||||
_isUpdating = true;
|
_isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
final dynamic c = currentCall;
|
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||||
rtc.MediaStream? remote;
|
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||||
rtc.MediaStream? local;
|
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||||
|
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||||
rtc.MediaStream? extract(dynamic obj) {
|
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||||
if (obj == null) return null;
|
final s = w?.stream;
|
||||||
if (obj is rtc.MediaStream) return obj;
|
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
// Strom stehen und markiert ihn nur).
|
||||||
|
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (respectMute && w.videoMuted) return null;
|
||||||
final List<dynamic> feeds = [];
|
return s;
|
||||||
try {
|
|
||||||
final dynamic sdkFeeds = c.getFeeds();
|
|
||||||
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
bool remoteScreenFound = false;
|
|
||||||
bool localScreenFound = false;
|
|
||||||
|
|
||||||
for (final dynamic f in feeds) {
|
|
||||||
final s = extract(f);
|
|
||||||
if (s == null) continue;
|
|
||||||
|
|
||||||
bool isLocal = false;
|
|
||||||
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
|
|
||||||
|
|
||||||
String purpose = 'm.usermedia';
|
|
||||||
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
|
|
||||||
|
|
||||||
if (isLocal) {
|
|
||||||
if (local == null || purpose == 'm.screenshare') {
|
|
||||||
local = s;
|
|
||||||
if (purpose == 'm.screenshare') localScreenFound = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (remote == null || purpose == 'm.screenshare') {
|
|
||||||
remote = s;
|
|
||||||
if (purpose == 'm.screenshare') remoteScreenFound = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_isScreensharing = localScreenFound;
|
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||||
_isRemoteScreensharing = remoteScreenFound;
|
final localScreen = video(call.localScreenSharingStream);
|
||||||
|
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||||
|
|
||||||
if (remote == null) {
|
var changed = false;
|
||||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
final remoteSharing = remoteScreen != null;
|
||||||
}
|
final localSharing = call.localScreenSharingStream != null;
|
||||||
if (remote == null) {
|
if (remoteSharing != _isRemoteScreensharing ||
|
||||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
localSharing != _isScreensharing) {
|
||||||
}
|
changed = true;
|
||||||
if (local == null) {
|
|
||||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
_isRemoteScreensharing = remoteSharing;
|
||||||
|
_isScreensharing = localSharing;
|
||||||
|
|
||||||
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
|
if (_isDeafened) _applyDeafen(call);
|
||||||
|
|
||||||
|
// 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 (remote != null && remote.id != _volumeAppliedStreamId) {
|
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||||
_volumeAppliedStreamId = remote.id;
|
_volumeAppliedStreamId = remoteUser.id;
|
||||||
_remoteAudioStream = remote;
|
_remoteAudioStream = remoteUser;
|
||||||
applyOutputVolume();
|
applyOutputVolume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final check for tracks before assigning
|
// Bildschirm hat Vorrang vor der Kamera.
|
||||||
bool changed = false;
|
final remoteVideo =
|
||||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
final localVideo =
|
||||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||||
remoteRenderer.srcObject = remote;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
} else if (remoteRenderer.srcObject != null) {
|
|
||||||
remoteRenderer.srcObject = null;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||||
if (localRenderer.srcObject?.id != local.id) {
|
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||||
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
remoteRenderer.srcObject = remoteVideo;
|
||||||
localRenderer.srcObject = local;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
} else if (localRenderer.srcObject != null) {
|
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||||
localRenderer.srcObject = null;
|
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||||
|
localRenderer.srcObject = localVideo;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,11 +378,64 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
|
||||||
|
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
|
||||||
|
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
|
||||||
|
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
|
||||||
|
Timer? _levelTimer;
|
||||||
|
bool _localSpeaking = false;
|
||||||
|
bool _remoteSpeaking = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLocalSpeaking => _localSpeaking;
|
||||||
|
@override
|
||||||
|
bool get isRemoteSpeaking => _remoteSpeaking;
|
||||||
|
|
||||||
|
void _startLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
(_) => _pollLevels(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = null;
|
||||||
|
_localSpeaking = false;
|
||||||
|
_remoteSpeaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollLevels() async {
|
||||||
|
final pc = currentCall?.pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
try {
|
||||||
|
double local = 0;
|
||||||
|
double remote = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != 'audio') continue;
|
||||||
|
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
|
||||||
|
if (r.type == 'media-source' && level > local) local = level;
|
||||||
|
if (r.type == 'inbound-rtp' && level > remote) remote = level;
|
||||||
|
}
|
||||||
|
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
|
||||||
|
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
|
||||||
|
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
|
||||||
|
final rs = speaking(_remoteSpeaking, remote);
|
||||||
|
if (ls != _localSpeaking || rs != _remoteSpeaking) {
|
||||||
|
_localSpeaking = ls;
|
||||||
|
_remoteSpeaking = rs;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> handleCallEnded(CallSession session) async {
|
Future<void> handleCallEnded(CallSession session) async {
|
||||||
if (currentCall?.callId == session.callId) {
|
if (currentCall?.callId == session.callId) {
|
||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = null;
|
_renderRefreshTimer = null;
|
||||||
|
_stopLevelPolling();
|
||||||
localRenderer.srcObject = null;
|
localRenderer.srcObject = null;
|
||||||
remoteRenderer.srcObject = null;
|
remoteRenderer.srcObject = null;
|
||||||
_remoteAudioStream = null;
|
_remoteAudioStream = null;
|
||||||
@@ -354,7 +500,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
@override
|
@override
|
||||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||||
try {
|
try {
|
||||||
await action();
|
await _inSdkZone(action);
|
||||||
await Future.delayed(const Duration(milliseconds: 1000));
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VOIP] Action error: $e');
|
debugPrint('[VOIP] Action error: $e');
|
||||||
@@ -364,16 +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 {
|
||||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
await _inSdkZone(() => c.setMicrophoneMuted(muted));
|
||||||
} 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
|
||||||
@@ -382,7 +536,8 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_isCameraMuted = !_isCameraMuted;
|
_isCameraMuted = !_isCameraMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
final c = currentCall!;
|
||||||
|
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
@@ -422,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
|
||||||
@@ -445,14 +623,15 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
_speakerOn = true;
|
_speakerOn = true;
|
||||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
final call = await _inSdkZone(
|
||||||
|
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
|
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
|
|
||||||
if (video) {
|
if (video) {
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
await call.setLocalVideoMuted(false);
|
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final all = _sorted(widget.participants);
|
final all = _sorted(widget.participants);
|
||||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
|
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
|
||||||
|
// das große Bild bei jedem Sprechwechsel hin und her.
|
||||||
|
final stable = [...widget.participants]..sort((a, b) {
|
||||||
|
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
|
||||||
|
return r(a).compareTo(r(b));
|
||||||
|
});
|
||||||
|
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
|
||||||
|
(stable.isNotEmpty ? stable.first : null);
|
||||||
|
|
||||||
if (widget.isConnecting) {
|
if (widget.isConnecting) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
|
isSpeaking: voip.isRemoteSpeaking,
|
||||||
hasVideo: hasRemoteVideo,
|
hasVideo: hasRemoteVideo,
|
||||||
isScreenShare: isRemoteScreen,
|
isScreenShare: isRemoteScreen,
|
||||||
avatarColor: _avatarColors[1],
|
avatarColor: _avatarColors[1],
|
||||||
@@ -165,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
name: 'Du',
|
name: 'Du',
|
||||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: voip.isMicMuted,
|
isMuted: voip.isMicMuted,
|
||||||
|
isSpeaking: voip.isLocalSpeaking,
|
||||||
hasVideo: hasLocalVideo,
|
hasVideo: hasLocalVideo,
|
||||||
isScreenShare: isLocalScreen,
|
isScreenShare: isLocalScreen,
|
||||||
avatarColor: _avatarColors[0],
|
avatarColor: _avatarColors[0],
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
|
|||||||
rows.add(_ReadMarker(pt: pt));
|
rows.add(_ReadMarker(pt: pt));
|
||||||
}
|
}
|
||||||
if (widget.fullyReadId == event.eventId && !isLast) {
|
if (widget.fullyReadId == event.eventId && !isLast) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class MessageGroup extends StatefulWidget {
|
|||||||
/// When non-null and pointing to a non-last event in this group, an inline
|
/// When non-null and pointing to a non-last event in this group, an inline
|
||||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||||
final String? fullyReadId;
|
final String? fullyReadId;
|
||||||
|
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
|
||||||
|
final bool unreadFading;
|
||||||
|
final GlobalKey? unreadDividerKey;
|
||||||
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
||||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||||
final String? jumpEventId;
|
final String? jumpEventId;
|
||||||
@@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget {
|
|||||||
this.showStatusIndicator = false,
|
this.showStatusIndicator = false,
|
||||||
this.readEventIds = const {},
|
this.readEventIds = const {},
|
||||||
this.fullyReadId,
|
this.fullyReadId,
|
||||||
|
this.unreadFading = false,
|
||||||
|
this.unreadDividerKey,
|
||||||
this.jumpEventId,
|
this.jumpEventId,
|
||||||
this.jumpKey,
|
this.jumpKey,
|
||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
@@ -155,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
// Inline unread divider after the first event when it's the fullyRead
|
// Inline unread divider after the first event when it's the fullyRead
|
||||||
// marker and there are more events in this group.
|
// marker and there are more events in this group.
|
||||||
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Continuation event rows (siblings, same indentation) ──
|
// ── Continuation event rows (siblings, same indentation) ──
|
||||||
@@ -174,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||||
// Inline divider after this event if it's the fullyRead and not the last.
|
// Inline divider after this event if it's the fullyRead and not the last.
|
||||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
|
|||||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||||
class _InlineUnreadDivider extends StatelessWidget {
|
class _InlineUnreadDivider extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _InlineUnreadDivider({required this.pt});
|
final bool fading;
|
||||||
|
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
|
||||||
|
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
|
||||||
|
// stehen – Bernd 2026-10-08).
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: fading ? 0.0 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 700),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
|||||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,8 +216,9 @@ class _MessageList extends StatelessWidget {
|
|||||||
// Pass fullyReadId to the group only when the event is mid-group
|
// Pass fullyReadId to the group only when the event is mid-group
|
||||||
// (not the last event) so MessageGroup can render an inline divider.
|
// (not the last event) so MessageGroup can render an inline divider.
|
||||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||||
final passedFullyReadId =
|
final passedFullyReadId = (hasUnread &&
|
||||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
groupContainsFullyRead &&
|
||||||
|
group.last.eventId != fullyReadId)
|
||||||
? fullyReadId
|
? fullyReadId
|
||||||
: null;
|
: null;
|
||||||
itemFullyReadIds.add(passedFullyReadId);
|
itemFullyReadIds.add(passedFullyReadId);
|
||||||
@@ -292,6 +293,9 @@ class _MessageList extends StatelessWidget {
|
|||||||
showStatusIndicator: showStatus,
|
showStatusIndicator: showStatus,
|
||||||
readEventIds: readEventIds,
|
readEventIds: readEventIds,
|
||||||
fullyReadId: itemFullyReadIds[i],
|
fullyReadId: itemFullyReadIds[i],
|
||||||
|
unreadFading: unreadFading,
|
||||||
|
unreadDividerKey:
|
||||||
|
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||||
jumpEventId: jumpEventId,
|
jumpEventId: jumpEventId,
|
||||||
jumpKey: jumpKey,
|
jumpKey: jumpKey,
|
||||||
highlightEventId: highlightEventId,
|
highlightEventId: highlightEventId,
|
||||||
|
|||||||
@@ -305,7 +305,10 @@ class _ActiveCallBar extends ConsumerWidget {
|
|||||||
final lk = ref.watch(voiceChannelProvider);
|
final lk = ref.watch(voiceChannelProvider);
|
||||||
final voipCall = voip.currentCall;
|
final voipCall = voip.currentCall;
|
||||||
final String? name;
|
final String? name;
|
||||||
if (voipCall != null && voipCall.state != CallState.kEnded) {
|
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
|
||||||
|
if (voipCall != null &&
|
||||||
|
voipCall.state != CallState.kEnded &&
|
||||||
|
voipCall.state != CallState.kRinging) {
|
||||||
name = voipCall.room.getLocalizedDisplayname();
|
name = voipCall.room.getLocalizedDisplayname();
|
||||||
} else if (lk.isActive || lk.isConnecting) {
|
} else if (lk.isActive || lk.isConnecting) {
|
||||||
name = lk.displayName;
|
name = lk.displayName;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pyramid
|
name: pyramid
|
||||||
description: A flexible Matrix client with resizable panel layout.
|
description: A flexible Matrix client with resizable panel layout.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.39+40
|
version: 0.1.41+42
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.11.1 <4.0.0"
|
sdk: ">=3.11.1 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ if ($Ui) {
|
|||||||
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
|
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
|
||||||
flutter test integration_test/live_receipts_invite_test.dart -d windows
|
flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
}
|
}
|
||||||
|
# Nutzt kurz Mikrofon + Bildschirm (nur lokal zwischen zwei Testkonten).
|
||||||
|
Invoke-Step "Live: 1:1-Anruf, Ton + Bildschirm teilen (2 Testkonten)" {
|
||||||
|
flutter test integration_test/live_call_test.dart -d windows
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user