part of '../voice_channel.dart'; /// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes /// Bild (Bildschirm/Kamera) mit Leiste. enum _CallLayout { bubbles, grid, hero } class _UnifiedCallView extends StatefulWidget { final List<_ParticipantData> participants; final Client? client; final DateTime startedAt; 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.client, required this.startedAt, 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> { /// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren /// und für den nächsten Anruf erhalten. static _CallLayout _preferred = _CallLayout.bubbles; _CallLayout _mode = _preferred; String? _heroId; bool _stripCollapsed = false; Set _knownScreens = {}; @override void initState() { super.initState(); _followScreenShares(); } @override void didUpdateWidget(_UnifiedCallView oldWidget) { super.didUpdateWidget(oldWidget); _followScreenShares(); } /// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild /// (Person weg, Kamera aus), zurück zu den Bubbles. void _followScreenShares() { final screens = widget.participants .where((p) => p.isScreenShare) .map((p) => p.id) .toSet(); final fresh = screens.difference(_knownScreens); if (fresh.isNotEmpty) { _mode = _CallLayout.hero; _heroId = fresh.first; } _knownScreens = screens; if (_mode == _CallLayout.hero && !widget.participants.any((p) => p.id == _heroId && p.hasVideo)) { _mode = _preferred; _heroId = null; } } void _showLarge(_ParticipantData p) { if (!p.hasVideo) return; setState(() { _mode = _CallLayout.hero; _heroId = p.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 = _mode == _CallLayout.hero ? widget.participants.firstWhereOrNull((p) => p.id == _heroId) : 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, startedAt: widget.startedAt, count: widget.participants.length, layout: _mode, onToggleView: () => setState(() { _mode = _mode == _CallLayout.bubbles ? _CallLayout.grid : _CallLayout.bubbles; _preferred = _mode; _heroId = null; }), onMinimize: widget.onMinimize, pt: pt, ), Expanded( child: AnimatedSwitcher( duration: motionDuration(context, 300), child: hero == null ? (_mode == _CallLayout.grid ? _ParticipantGrid( key: const ValueKey('grid'), participants: all, pt: pt, onSelect: _showLarge) : _BubbleStage( key: const ValueKey('bubbles'), participants: widget.participants, client: widget.client, pt: pt, onSelect: _showLarge)) : _HeroLayout( key: const ValueKey('hero'), hero: hero, others: all.where((p) => p.id != hero.id).toList(), stripCollapsed: _stripCollapsed, onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed), onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v), onSelectOther: (p) => p.hasVideo ? setState(() => _heroId = p.id) : null, 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 DateTime startedAt; final int count; final _CallLayout layout; final VoidCallback onToggleView; final VoidCallback onMinimize; final PyramidTheme pt; const _CallHeader({ required this.displayName, required this.startedAt, required this.count, required this.layout, required this.onToggleView, required this.onMinimize, required this.pt, }); @override Widget build(BuildContext context) { final toBubbles = layout != _CallLayout.bubbles; return Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), child: Row( children: [ Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 2), Row(children: [ _LiveBadge(pt: pt), const SizedBox(width: 8), _CallDuration(startedAt: startedAt, pt: pt), Text(count == 1 ? ' · nur du' : ' · $count Personen', style: TextStyle(color: pt.fgMuted, fontSize: 12)), ]), ], ), ), PyrIconBtn( icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg), tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht', onPressed: onToggleView, ), const SizedBox(width: 4), PyrIconBtn( icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg), tooltip: 'Minimieren', onPressed: onMinimize, ), ], ), ); } } /// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde. class _CallDuration extends StatefulWidget { final DateTime startedAt; final PyramidTheme pt; const _CallDuration({required this.startedAt, required this.pt}); @override State<_CallDuration> createState() => _CallDurationState(); } class _CallDurationState extends State<_CallDuration> { late final Timer _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {})); @override void initState() { super.initState(); _timer; // starten } @override void dispose() { _timer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final d = DateTime.now().difference(widget.startedAt); String two(int n) => n.toString().padLeft(2, '0'); final text = d.inHours > 0 ? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}' : '${d.inMinutes}:${two(d.inSeconds % 60)}'; return Text(text, style: TextStyle( color: widget.pt.fgMuted, fontSize: 12, fontFeatures: const [FontFeature.tabularFigures()])); } } 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)), ]), ); } }