Files
pyramid/lib/features/call_ui/view/call_view.dart
T
Bernd SteckmeisterandClaude Opus 5.5 441cc6ef54 fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07):
- Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache:
  Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ
  mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag
  verwies auf eine nicht mehr existierende Klasse. Neu: CallService
  (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst
  (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android
  16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau
  'Media projections require a foreground service ...'.
- Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv,
  Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit
  Berechtigung, sonst schmalere Variante statt Dienst-Abbruch.
- Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt
  Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste.
- LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog.
- Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück".
- Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose:
  das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:29 +02:00

221 lines
7.2 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);
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.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)),
]),
);
}
}