Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest (zwei Testkonten im selben Prozess) nachgestellt und behoben: - Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet). - Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid puffert alle Kandidaten der Gegenseite und reicht sie direkt an die Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen) bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen. - SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler per Completer über die Zonengrenze (sonst ewiges Warten). - Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt, ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes Bild springt nicht mehr bei jedem Sprechwechsel. - „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test. - „Anruf läuft“-Leiste nicht schon beim Klingeln. Co-Authored-By: Claude Opus 5.5 <[email protected]>
228 lines
7.6 KiB
Dart
228 lines
7.6 KiB
Dart
part of '../voice_channel.dart';
|
||
|
||
class _UnifiedCallView extends StatefulWidget {
|
||
final List<_ParticipantData> participants;
|
||
final bool isLiveKit;
|
||
final String displayName;
|
||
final bool isConnecting;
|
||
final VoidCallback onHangUp;
|
||
final VoidCallback onMinimize;
|
||
final VoidCallback onToggleMic;
|
||
final VoidCallback onToggleDeafen;
|
||
final VoidCallback onToggleCam;
|
||
final VoidCallback onToggleScreen;
|
||
final bool isMuted;
|
||
final bool isDeafened;
|
||
final bool isCameraOff;
|
||
final bool isScreenSharing;
|
||
final Function(Offset)? onQualityMenu;
|
||
final PyramidTheme pt;
|
||
final bool isSpeakerOn;
|
||
final VoidCallback? onToggleSpeaker;
|
||
|
||
const _UnifiedCallView({
|
||
required this.participants,
|
||
required this.isLiveKit,
|
||
required this.displayName,
|
||
required this.isConnecting,
|
||
required this.onHangUp,
|
||
required this.onMinimize,
|
||
required this.onToggleMic,
|
||
required this.onToggleDeafen,
|
||
required this.onToggleCam,
|
||
required this.onToggleScreen,
|
||
required this.isMuted,
|
||
required this.isDeafened,
|
||
required this.isCameraOff,
|
||
required this.isScreenSharing,
|
||
this.onQualityMenu,
|
||
required this.pt,
|
||
this.isSpeakerOn = true,
|
||
this.onToggleSpeaker,
|
||
});
|
||
|
||
@override
|
||
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
|
||
}
|
||
|
||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||
String? _heroId;
|
||
bool _isGridView = false;
|
||
bool _stripCollapsed = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_autoSelectHero();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
_autoSelectHero();
|
||
}
|
||
|
||
void _autoSelectHero() {
|
||
if (_isGridView) return;
|
||
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
|
||
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
|
||
_heroId = screenshare.id;
|
||
} else if (_heroId == null && widget.participants.isNotEmpty) {
|
||
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
|
||
if (firstVideo != null) _heroId = firstVideo.id;
|
||
}
|
||
}
|
||
|
||
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
||
int rank(_ParticipantData p) {
|
||
if (p.isScreenShare) return 0;
|
||
if (p.hasVideo) return 1;
|
||
if (p.isSpeaking) return 2;
|
||
return 3;
|
||
}
|
||
return [...all]..sort((a, b) => rank(a).compareTo(rank(b)));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final all = _sorted(widget.participants);
|
||
// 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(
|
||
backgroundColor: pt.bg1,
|
||
body: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const PyramidLoader(size: 80),
|
||
const SizedBox(height: 20),
|
||
Text('Verbinde…',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||
const SizedBox(height: 16),
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(foregroundColor: pt.danger),
|
||
onPressed: widget.onHangUp,
|
||
icon: const Icon(Icons.call_end_rounded, size: 16),
|
||
label: const Text('Abbrechen'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Scaffold(
|
||
backgroundColor: pt.bg1,
|
||
body: Column(
|
||
children: [
|
||
_CallHeader(
|
||
displayName: widget.displayName,
|
||
isGridView: _isGridView,
|
||
onToggleView: () => setState(() => _isGridView = !_isGridView),
|
||
onMinimize: widget.onMinimize,
|
||
pt: pt,
|
||
),
|
||
Expanded(
|
||
child: AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 300),
|
||
child: _isGridView || hero == null
|
||
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
|
||
: _HeroLayout(
|
||
hero: hero,
|
||
others: all.where((p) => p.id != hero.id).toList(),
|
||
stripCollapsed: _stripCollapsed,
|
||
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||
onSelectOther: (p) => setState(() => _heroId = p.id),
|
||
onQualityMenu: widget.onQualityMenu,
|
||
pt: pt,
|
||
),
|
||
),
|
||
),
|
||
_CallControls(
|
||
isMuted: widget.isMuted,
|
||
isDeafened: widget.isDeafened,
|
||
isCameraOff: widget.isCameraOff,
|
||
isScreenSharing: widget.isScreenSharing,
|
||
onToggleMic: widget.onToggleMic,
|
||
onToggleDeafen: widget.onToggleDeafen,
|
||
onToggleCam: widget.onToggleCam,
|
||
onToggleScreen: widget.onToggleScreen,
|
||
onHangUp: widget.onHangUp,
|
||
pt: pt,
|
||
isSpeakerOn: widget.isSpeakerOn,
|
||
onToggleSpeaker: widget.onToggleSpeaker,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CallHeader extends StatelessWidget {
|
||
final String displayName;
|
||
final bool isGridView;
|
||
final VoidCallback onToggleView;
|
||
final VoidCallback onMinimize;
|
||
final PyramidTheme pt;
|
||
|
||
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
height: 52,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||
const SizedBox(width: 8),
|
||
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 8),
|
||
_LiveBadge(pt: pt),
|
||
const Spacer(),
|
||
PyrIconBtn(
|
||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
||
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
|
||
onPressed: onToggleView,
|
||
),
|
||
const SizedBox(width: 4),
|
||
PyrIconBtn(
|
||
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
|
||
tooltip: 'Minimieren',
|
||
onPressed: onMinimize,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LiveBadge extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
const _LiveBadge({required this.pt});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)),
|
||
child: Row(children: [
|
||
Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)),
|
||
const SizedBox(width: 4),
|
||
Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)),
|
||
]),
|
||
);
|
||
}
|
||
}
|