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
|
||||
|
||||
**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);
|
||||
|
||||
// ── 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.
|
||||
await tester.runAsync(() async {
|
||||
final r = client.getRoomById(inviteRoomId);
|
||||
@@ -197,11 +230,12 @@ class _RestUser {
|
||||
'preset': 'private_chat',
|
||||
}))['room_id'] as String;
|
||||
|
||||
Future<void> sendText(String roomId, String text) => _call(
|
||||
Future<String> sendText(String roomId, String text) async =>
|
||||
(await _call(
|
||||
'PUT',
|
||||
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||
{'msgtype': 'm.text', 'body': text},
|
||||
);
|
||||
))['event_id'] as String;
|
||||
|
||||
Future<void> invite(String roomId, String userId) =>
|
||||
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||
|
||||
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
|
||||
|
||||
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).
|
||||
/// Auf dem PC ohne Bedeutung.
|
||||
bool get isSpeakerOn => true;
|
||||
|
||||
@@ -20,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
final Client client;
|
||||
late final VoIP voip;
|
||||
late final Zone _sdkZone;
|
||||
@override
|
||||
CallSession? currentCall;
|
||||
|
||||
@@ -81,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
PyramidVoipManager(this.client) {
|
||||
_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
|
||||
try {
|
||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||
@@ -89,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_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 {
|
||||
if (_renderersInitialized) return;
|
||||
try {
|
||||
@@ -134,13 +172,87 @@ class PyramidVoipManager extends CallSignalingService
|
||||
currentCall = call;
|
||||
_isCameraMuted = call.type == CallType.kVoice;
|
||||
_speakerOn = true;
|
||||
_isDeafened = false;
|
||||
_isMicMuted = false;
|
||||
_setupCallListeners(call);
|
||||
_updateInternalStates();
|
||||
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;
|
||||
String? _listenedCallId;
|
||||
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) {
|
||||
debugPrint('[VOIP] State: $state');
|
||||
_updateInternalStates();
|
||||
@@ -151,11 +263,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
call.setLocalVideoMuted(false);
|
||||
}
|
||||
_applySpeaker();
|
||||
_startLevelPolling();
|
||||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||
CallForeground.callStarted('voip',
|
||||
title: call.room.getLocalizedDisplayname());
|
||||
}
|
||||
if (state == CallState.kEnded) CallForeground.callEnded('voip');
|
||||
if (state == CallState.kEnded) {
|
||||
_stopLevelPolling();
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
_updateRenderers();
|
||||
});
|
||||
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||
@@ -167,7 +283,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = Timer.periodic(
|
||||
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();
|
||||
@@ -184,96 +308,65 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
|
||||
Future<void> _updateRenderers() async {
|
||||
if (currentCall == null || _isUpdating) return;
|
||||
final call = currentCall;
|
||||
if (call == null || _isUpdating) return;
|
||||
_isUpdating = true;
|
||||
|
||||
try {
|
||||
await _ensureRenderers();
|
||||
final dynamic c = currentCall;
|
||||
rtc.MediaStream? remote;
|
||||
rtc.MediaStream? local;
|
||||
|
||||
rtc.MediaStream? extract(dynamic obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj is rtc.MediaStream) return obj;
|
||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
||||
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||
final s = w?.stream;
|
||||
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||
// Strom stehen und markiert ihn nur).
|
||||
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> feeds = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (respectMute && w.videoMuted) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
_isScreensharing = localScreenFound;
|
||||
_isRemoteScreensharing = remoteScreenFound;
|
||||
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||
final localScreen = video(call.localScreenSharingStream);
|
||||
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
||||
}
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
||||
}
|
||||
if (local == null) {
|
||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
||||
var changed = false;
|
||||
final remoteSharing = remoteScreen != null;
|
||||
final localSharing = call.localScreenSharingStream != null;
|
||||
if (remoteSharing != _isRemoteScreensharing ||
|
||||
localSharing != _isScreensharing) {
|
||||
changed = true;
|
||||
}
|
||||
_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.
|
||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remote.id;
|
||||
_remoteAudioStream = remote;
|
||||
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remoteUser.id;
|
||||
_remoteAudioStream = remoteUser;
|
||||
applyOutputVolume();
|
||||
}
|
||||
|
||||
// Final check for tracks before assigning
|
||||
bool changed = false;
|
||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
||||
remoteRenderer.srcObject = remote;
|
||||
changed = true;
|
||||
}
|
||||
} else if (remoteRenderer.srcObject != null) {
|
||||
remoteRenderer.srcObject = null;
|
||||
changed = true;
|
||||
}
|
||||
// Bildschirm hat Vorrang vor der Kamera.
|
||||
final remoteVideo =
|
||||
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||
final localVideo =
|
||||
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||
|
||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
||||
if (localRenderer.srcObject?.id != local.id) {
|
||||
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
||||
localRenderer.srcObject = local;
|
||||
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||
remoteRenderer.srcObject = remoteVideo;
|
||||
changed = true;
|
||||
}
|
||||
} else if (localRenderer.srcObject != null) {
|
||||
localRenderer.srcObject = null;
|
||||
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||
localRenderer.srcObject = localVideo;
|
||||
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
|
||||
Future<void> handleCallEnded(CallSession session) async {
|
||||
if (currentCall?.callId == session.callId) {
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = null;
|
||||
_stopLevelPolling();
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
_remoteAudioStream = null;
|
||||
@@ -354,7 +500,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
@override
|
||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
await _inSdkZone(action);
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Action error: $e');
|
||||
@@ -364,16 +510,24 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleMic() async {
|
||||
if (currentCall == null) return;
|
||||
_isMicMuted = !_isMicMuted;
|
||||
Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
|
||||
|
||||
Future<void> _setMicMuted(bool muted) async {
|
||||
final c = currentCall;
|
||||
if (c == null) return;
|
||||
_isMicMuted = muted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.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
|
||||
@@ -382,7 +536,8 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
||||
final c = currentCall!;
|
||||
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||
await _updateRenderers();
|
||||
@@ -422,8 +577,31 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
@override
|
||||
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;
|
||||
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
|
||||
@@ -445,14 +623,15 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
_speakerOn = true;
|
||||
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;
|
||||
|
||||
_setupCallListeners(call);
|
||||
|
||||
if (video) {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await call.setLocalVideoMuted(false);
|
||||
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
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) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -151,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: false,
|
||||
isSpeaking: voip.isRemoteSpeaking,
|
||||
hasVideo: hasRemoteVideo,
|
||||
isScreenShare: isRemoteScreen,
|
||||
avatarColor: _avatarColors[1],
|
||||
@@ -165,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
name: 'Du',
|
||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: voip.isMicMuted,
|
||||
isSpeaking: voip.isLocalSpeaking,
|
||||
hasVideo: hasLocalVideo,
|
||||
isScreenShare: isLocalScreen,
|
||||
avatarColor: _avatarColors[0],
|
||||
|
||||
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
|
||||
rows.add(_ReadMarker(pt: pt));
|
||||
}
|
||||
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
|
||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||
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
|
||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||
final String? jumpEventId;
|
||||
@@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget {
|
||||
this.showStatusIndicator = false,
|
||||
this.readEventIds = const {},
|
||||
this.fullyReadId,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
@@ -155,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
// Inline unread divider after the first event when it's the fullyRead
|
||||
// marker and there are more events in this group.
|
||||
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) ──
|
||||
@@ -174,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
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.
|
||||
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).
|
||||
class _InlineUnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _InlineUnreadDivider({required this.pt});
|
||||
final bool fading;
|
||||
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
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),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
||||
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
|
||||
// (not the last event) so MessageGroup can render an inline divider.
|
||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||
final passedFullyReadId =
|
||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
||||
final passedFullyReadId = (hasUnread &&
|
||||
groupContainsFullyRead &&
|
||||
group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
itemFullyReadIds.add(passedFullyReadId);
|
||||
@@ -292,6 +293,9 @@ class _MessageList extends StatelessWidget {
|
||||
showStatusIndicator: showStatus,
|
||||
readEventIds: readEventIds,
|
||||
fullyReadId: itemFullyReadIds[i],
|
||||
unreadFading: unreadFading,
|
||||
unreadDividerKey:
|
||||
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||
jumpEventId: jumpEventId,
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
|
||||
@@ -305,7 +305,10 @@ class _ActiveCallBar extends ConsumerWidget {
|
||||
final lk = ref.watch(voiceChannelProvider);
|
||||
final voipCall = voip.currentCall;
|
||||
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();
|
||||
} else if (lk.isActive || lk.isConnecting) {
|
||||
name = lk.displayName;
|
||||
|
||||
@@ -131,8 +131,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
// 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;
|
||||
|
||||
@@ -78,7 +78,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> 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<ScreenSharePicker> 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<ScreenSharePicker> 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(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pyramid
|
||||
description: A flexible Matrix client with resizable panel layout.
|
||||
publish_to: none
|
||||
version: 0.1.39+40
|
||||
version: 0.1.41+42
|
||||
environment:
|
||||
sdk: ">=3.11.1 <4.0.0"
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ if ($Ui) {
|
||||
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
|
||||
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 {
|
||||
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user