From 95d9b07dbdd279b32ca18cb882b91c9d30d4d1b4 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Mon, 6 Jul 2026 17:15:12 +0200 Subject: [PATCH] refactor: call_ui/voice_channel.dart (1123 Zeilen) in 5 thematische part-Dateien unter view/ geteilt (M2, zeichenidentisch verifiziert) --- CHANGES.md | 2 + .../call_ui/view/avatars_controls.dart | 173 +++ lib/features/call_ui/view/call_view.dart | 214 ++++ .../call_ui/view/participant_layout.dart | 424 +++++++ lib/features/call_ui/view/quality_menu.dart | 126 ++ .../call_ui/view/voice_channel_core.dart | 178 +++ lib/features/call_ui/voice_channel.dart | 1114 +---------------- 7 files changed, 1122 insertions(+), 1109 deletions(-) create mode 100644 lib/features/call_ui/view/avatars_controls.dart create mode 100644 lib/features/call_ui/view/call_view.dart create mode 100644 lib/features/call_ui/view/participant_layout.dart create mode 100644 lib/features/call_ui/view/quality_menu.dart create mode 100644 lib/features/call_ui/view/voice_channel_core.dart diff --git a/CHANGES.md b/CHANGES.md index 9539f8a..0fba5a6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1413,3 +1413,5 @@ 2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md 2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md 2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md +2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1 +2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1 diff --git a/lib/features/call_ui/view/avatars_controls.dart b/lib/features/call_ui/view/avatars_controls.dart new file mode 100644 index 0000000..64ba3d0 --- /dev/null +++ b/lib/features/call_ui/view/avatars_controls.dart @@ -0,0 +1,173 @@ +part of '../voice_channel.dart'; + +class _BigAvatar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _BigAvatar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 100, height: 100, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), + shape: BoxShape.circle, + ), + child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))), + ), + ); + } +} + +class _SmallAvatar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _SmallAvatar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 44, height: 44, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), + shape: BoxShape.circle, + ), + child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))), + ), + ); + } +} + +class _CallControls extends StatelessWidget { + final bool isMuted; + final bool isDeafened; + final bool isCameraOff; + final bool isScreenSharing; + final VoidCallback onToggleMic; + final VoidCallback onToggleDeafen; + final VoidCallback onToggleCam; + final VoidCallback onToggleScreen; + final VoidCallback onHangUp; + final PyramidTheme pt; + + const _CallControls({ + required this.isMuted, + required this.isDeafened, + required this.isCameraOff, + required this.isScreenSharing, + required this.onToggleMic, + required this.onToggleDeafen, + required this.onToggleCam, + required this.onToggleScreen, + required this.onHangUp, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), + const SizedBox(width: 16), + _VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen), + const SizedBox(width: 24), + Container(width: 1, height: 32, color: pt.border), + const SizedBox(width: 24), + _VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp), + ], + ), + ), + ); + } +} + +class _NoCall extends StatelessWidget { + final PyramidTheme pt; + final WidgetRef ref; + const _NoCall({required this.pt, required this.ref}); + + @override + Widget build(BuildContext context) { + return Container( + color: pt.bg1, + child: Column( + children: [ + Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), + child: Row( + children: [ + PyrIconBtn( + icon: const Icon(Icons.menu_rounded), + tooltip: 'Toggle sidebar', + onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s), + ), + const SizedBox(width: 8), + Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted), + const SizedBox(width: 8), + Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + ], + ), + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('šƒ', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))), + const SizedBox(height: 12), + Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _FullscreenVideoPage extends StatelessWidget { + final _ParticipantData data; + const _FullscreenVideoPage({required this.data}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack(children: [ + Positioned.fill( + child: data.videoWidget != null + ? _MirrorWrapper( + isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), + isScreenShare: data.isScreenShare, + child: data.videoWidget!, + ) + : _BigAvatar(data: data, pt: PyramidTheme.of(context)), + ), + Positioned( + top: 0, right: 0, + child: SafeArea( + child: IconButton( + icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + ]), + ); + } +} diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart new file mode 100644 index 0000000..d4f06e2 --- /dev/null +++ b/lib/features/call_ui/view/call_view.dart @@ -0,0 +1,214 @@ +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; + + 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, + }); + + @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, + ), + ], + ), + ); + } +} + +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)), + ]), + ); + } +} diff --git a/lib/features/call_ui/view/participant_layout.dart b/lib/features/call_ui/view/participant_layout.dart new file mode 100644 index 0000000..4dc1f19 --- /dev/null +++ b/lib/features/call_ui/view/participant_layout.dart @@ -0,0 +1,424 @@ +part of '../voice_channel.dart'; + +class _ParticipantGrid extends StatelessWidget { + final List<_ParticipantData> participants; + final PyramidTheme pt; + final Function(_ParticipantData) onSelect; + + const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, box) { + final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2); + const spacing = 12.0; + final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols; + + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: spacing, + runSpacing: spacing, + children: participants.map((p) => SizedBox( + width: tileWidth, + height: tileWidth / p.aspectRatio, + child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)), + )).toList(), + ), + ); + }); + } +} + +class _HeroLayout extends StatelessWidget { + final _ParticipantData hero; + final List<_ParticipantData> others; + final bool stripCollapsed; + final VoidCallback onToggleStrip; + final Function(bool) onSetStripCollapsed; + final Function(_ParticipantData) onSelectOther; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + + const _HeroLayout({ + required this.hero, + required this.others, + required this.stripCollapsed, + required this.onToggleStrip, + required this.onSetStripCollapsed, + required this.onSelectOther, + this.onQualityMenu, + required this.pt, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, box) { + final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600; + + final heroView = _HeroSection( + data: hero, + onQualityMenu: onQualityMenu, + pt: pt, + ); + + final strip = _ParticipantStrip( + participants: others, + isVertical: isLandscape, + onSelect: onSelectOther, + pt: pt, + ); + + if (isLandscape) { + return Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity! < -300) onSetStripCollapsed(false); + if (details.primaryVelocity! > 300) onSetStripCollapsed(true); + }, + child: heroView, + ), + ), + ), + if (!stripCollapsed) + SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)), + _CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), + ], + ); + } else { + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity! < -300) onSetStripCollapsed(false); + if (details.primaryVelocity! > 300) onSetStripCollapsed(true); + }, + child: heroView, + ), + ), + ), + _CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), + if (!stripCollapsed) + SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)), + ], + ); + } + }); + } +} + +class _HeroSection extends StatefulWidget { + final _ParticipantData data; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + + const _HeroSection({required this.data, this.onQualityMenu, required this.pt}); + + @override + State<_HeroSection> createState() => _HeroSectionState(); +} + +class _HeroSectionState extends State<_HeroSection> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: () => setState(() => _hovered = !_hovered), + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rLg), + child: Container( + color: Colors.black, + child: Stack( + children: [ + Positioned.fill( + child: widget.data.videoWidget != null + ? _MirrorWrapper( + isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant), + isScreenShare: widget.data.isScreenShare, + child: widget.data.videoWidget!, + ) + : _BigAvatar(data: widget.data, pt: pt), + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: _hovered ? 0 : -60, left: 0, right: 0, + child: _HeroTopBar(data: widget.data, pt: pt), + ), + + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + bottom: _hovered ? 0 : -60, left: 0, right: 0, + child: _HeroBottomBar( + data: widget.data, + onQualityMenu: widget.onQualityMenu, + pt: pt, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _HeroTopBar extends StatelessWidget { + final _ParticipantData data; + final PyramidTheme pt; + const _HeroTopBar({required this.data, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]), + ), + child: Row( + children: [ + if (data.isScreenShare) _LivePill(pt: pt), + const SizedBox(width: 8), + Text( + data.isScreenShare ? "${data.name} prƤsentiert" : data.name, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14), + ), + ], + ), + ); + } +} + +class _HeroBottomBar extends StatelessWidget { + final _ParticipantData data; + final Function(Offset)? onQualityMenu; + final PyramidTheme pt; + const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]), + ), + child: Row( + children: [ + const Spacer(), + if (onQualityMenu != null) + _HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)), + const SizedBox(width: 8), + _HeroActionBtn( + icon: Icons.fullscreen_rounded, + onTap: () => Navigator.of(context).push(MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _FullscreenVideoPage(data: data), + )), + ), + ], + ), + ); + } +} + +class _HeroActionBtn extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + final Function(TapDownDetails)? onTapDown; + const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onTapDown: onTapDown, + child: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)), + child: Icon(icon, color: Colors.white, size: 20), + ), + ); + } +} + +class _LivePill extends StatelessWidget { + final PyramidTheme pt; + const _LivePill({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)), + const SizedBox(width: 4), + const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)), + ]), + ); + } +} + +class _ParticipantStrip extends StatelessWidget { + final List<_ParticipantData> participants; + final bool isVertical; + final Function(_ParticipantData) onSelect; + final PyramidTheme pt; + + const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + scrollDirection: isVertical ? Axis.vertical : Axis.horizontal, + itemCount: participants.length, + separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12), + itemBuilder: (context, i) => SizedBox( + width: isVertical ? double.infinity : 101 * participants[i].aspectRatio, + height: 101, + child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])), + ), + ); + } +} + +class _ParticipantTile extends StatefulWidget { + final _ParticipantData data; + final PyramidTheme pt; + final VoidCallback onTap; + + const _ParticipantTile({required this.data, required this.pt, required this.onTap}); + + @override + State<_ParticipantTile> createState() => _ParticipantTileState(); +} + +class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin { + late AnimationController _speakingCtrl; + + @override + void initState() { + super.initState(); + _speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true); + } + + @override + void dispose() { + _speakingCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final data = widget.data; + + return GestureDetector( + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _speakingCtrl, + builder: (context, _) { + final isSpeaking = data.isSpeaking; + final pulse = isSpeaking ? _speakingCtrl.value : 0.0; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all( + color: isSpeaking + ? pt.accent.withAlpha((150 + pulse * 105).toInt()) + : pt.border, + width: isSpeaking ? 2 : 1, + ), + boxShadow: isSpeaking + ? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(pt.rBase - 1), + child: Stack( + children: [ + Positioned.fill( + child: data.videoWidget != null + ? _MirrorWrapper( + isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), + isScreenShare: data.isScreenShare, + child: data.videoWidget!, + ) + : _SmallAvatar(data: data, pt: pt), + ), + + Positioned( + left: 6, bottom: 6, right: 6, + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)], + Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), + ], + ), + ), + ], + ), + ), + if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _CollapseHandle extends StatelessWidget { + final bool isVertical; + final bool collapsed; + final VoidCallback onToggle; + final PyramidTheme pt; + + const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, + child: Container( + width: isVertical ? 24 : double.infinity, + height: isVertical ? double.infinity : 24, + color: Colors.transparent, + child: Center( + child: Icon( + isVertical + ? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded) + : (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded), + color: pt.fgDim, size: 20, + ), + ), + ), + ); + } +} diff --git a/lib/features/call_ui/view/quality_menu.dart b/lib/features/call_ui/view/quality_menu.dart new file mode 100644 index 0000000..11d5aba --- /dev/null +++ b/lib/features/call_ui/view/quality_menu.dart @@ -0,0 +1,126 @@ +part of '../voice_channel.dart'; + +void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async { + if (call.isScreenSharing) { + await call.stopScreenShare(); + } else { + final source = await ScreenSharePicker.show(context); + if (source != null) await call.startScreenShare(source.id); + } +} + +void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) { + final pt = PyramidTheme.of(context); + showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy), + color: pt.bg2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), + items: [ + const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) + PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)), + const PopupMenuDivider(), + const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')]) + PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)), + ], + ); +} + +void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) { + final pt = PyramidTheme.of(context); + showMenu( + context: context, + position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy), + color: pt.bg2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), + items: [ + const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), + for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) + PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)), + ], + ); +} + +class _QualityItem extends StatelessWidget { + final String label; + final String? hint; + final bool active; + final PyramidTheme pt; + const _QualityItem({required this.label, required this.active, required this.pt, this.hint}); + + @override + Widget build(BuildContext context) { + return Row(children: [ + if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16), + const SizedBox(width: 8), + Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)), + if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))], + ]); + } +} + +class _VoiceCtrlBtn extends StatefulWidget { + final IconData icon; + final PyramidTheme pt; + final bool active; + final bool danger; + final bool isDanger; + final VoidCallback? onTap; + + const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false}); + + @override + State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState(); +} + +class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + Color bg; + Color iconColor; + if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; } + else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; } + else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; } + else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 52, height: 52, + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)), + child: Center(child: Icon(widget.icon, size: 20, color: iconColor)), + ), + ), + ); + } +} + +class _MirrorWrapper extends StatelessWidget { + final Widget child; + final bool isLocal; + final bool isScreenShare; + + const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare}); + + @override + Widget build(BuildContext context) { + if (isLocal && !isScreenShare) { + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(3.14159), + child: child, + ); + } + return child; + } +} diff --git a/lib/features/call_ui/view/voice_channel_core.dart b/lib/features/call_ui/view/voice_channel_core.dart new file mode 100644 index 0000000..6c3e308 --- /dev/null +++ b/lib/features/call_ui/view/voice_channel_core.dart @@ -0,0 +1,178 @@ +part of '../voice_channel.dart'; + +// Unified participant data to handle both LiveKit and VoIP +class _ParticipantData { + final String id; + final String name; + final Widget? videoWidget; + final bool isSpeaking; + final bool isMuted; + final bool hasVideo; + final bool isScreenShare; + final Color avatarColor; + final dynamic originalParticipant; + final double aspectRatio; + + const _ParticipantData({ + required this.id, + required this.name, + this.videoWidget, + this.isSpeaking = false, + this.isMuted = false, + this.hasVideo = false, + this.isScreenShare = false, + this.avatarColor = Colors.grey, + this.originalParticipant, + this.aspectRatio = 16.0 / 9.0, + }); +} + +class VoiceChannelView extends ConsumerWidget { + const VoiceChannelView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pt = PyramidTheme.of(context); + final call = ref.watch(voiceChannelProvider); + final voip = ref.watch(callSignalingProvider); + + if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) { + return _UnifiedCallView( + participants: _getVoipParticipants(voip), + isLiveKit: false, + displayName: voip.currentCall!.room.getLocalizedDisplayname(), + isConnecting: false, + onHangUp: () { + voip.hangup(); + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + onMinimize: () => + ref.read(viewModeProvider.notifier).state = ViewMode.chat, + onToggleMic: voip.toggleMic, + onToggleDeafen: voip.toggleDeafen, + onToggleCam: voip.toggleCamera, + onToggleScreen: () => voip.toggleScreenSharing(context), + isMuted: voip.isMicMuted, + isDeafened: voip.isDeafened, + isCameraOff: voip.isCameraMuted, + isScreenSharing: voip.isScreensharing, + onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), + pt: pt, + ); + } + + if (!call.isActive) { + return _NoCall(pt: pt, ref: ref); + } + + return _UnifiedCallView( + participants: _getLiveKitParticipants(call), + isLiveKit: true, + displayName: call.displayName, + isConnecting: call.isConnecting, + onHangUp: () { + call.hangUp(); + ref.read(viewModeProvider.notifier).state = ViewMode.chat; + }, + onMinimize: () => + ref.read(viewModeProvider.notifier).state = ViewMode.chat, + onToggleMic: call.toggleMute, + onToggleDeafen: call.toggleDeafen, + onToggleCam: call.toggleCamera, + onToggleScreen: () => _handleLiveKitScreenShare(context, call), + isMuted: call.isMuted, + isDeafened: call.isDeafened, + isCameraOff: call.isCameraOff, + isScreenSharing: call.isScreenSharing, + onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), + pt: pt, + ); + } + + List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) { + final room = call.room; + if (room == null) return []; + final all = [ + if (room.localParticipant != null) room.localParticipant!, + ...room.remoteParticipants.values, + ]; + + return all.mapIndexed((i, p) { + final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo); + final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera); + final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?; + final isScreen = screenPub != null; + + double ar = 16.0 / 9.0; + if (videoTrack != null) { + try { + final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions; + if (dim != null) { + final w = (dim.width as num?)?.toDouble() ?? 0; + final h = (dim.height as num?)?.toDouble() ?? 0; + if (w > 0 && h > 0) ar = w / h; + } + } catch (_) {} + } + + return _ParticipantData( + id: p.identity, + name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity, + videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null, + isSpeaking: p.isSpeaking, + isMuted: !p.isMicrophoneEnabled(), + hasVideo: videoTrack != null, + isScreenShare: isScreen, + avatarColor: _avatarColors[i % _avatarColors.length], + originalParticipant: p, + aspectRatio: ar, + ); + }).toList(); + } + + List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) { + final participants = <_ParticipantData>[]; + + double ar(rtc.RTCVideoRenderer r) { + final w = r.videoWidth.toDouble(); + final h = r.videoHeight.toDouble(); + return (w > 0 && h > 0) ? w / h : 16.0 / 9.0; + } + + // Remote + final isRemoteScreen = voip.isRemoteScreensharing; + final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty; + participants.add(_ParticipantData( + id: 'remote', + name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote', + videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, + isMuted: false, + hasVideo: hasRemoteVideo, + isScreenShare: isRemoteScreen, + avatarColor: _avatarColors[1], + aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0, + )); + + // Local + final isLocalScreen = voip.isScreensharing; + final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty; + participants.add(_ParticipantData( + id: 'local', + name: 'Du', + videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, + isMuted: voip.isMicMuted, + hasVideo: hasLocalVideo, + isScreenShare: isLocalScreen, + avatarColor: _avatarColors[0], + aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0, + )); + + return participants; + } + + static const _avatarColors = [ + Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7), + Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444), + Color(0xFF3B82F6), Color(0xFF84CC16), + ]; +} diff --git a/lib/features/call_ui/voice_channel.dart b/lib/features/call_ui/voice_channel.dart index f7bd428..fbf2350 100644 --- a/lib/features/call_ui/voice_channel.dart +++ b/lib/features/call_ui/voice_channel.dart @@ -12,1112 +12,8 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/pyramid_loader.dart'; -// Unified participant data to handle both LiveKit and VoIP -class _ParticipantData { - final String id; - final String name; - final Widget? videoWidget; - final bool isSpeaking; - final bool isMuted; - final bool hasVideo; - final bool isScreenShare; - final Color avatarColor; - final dynamic originalParticipant; - final double aspectRatio; - - const _ParticipantData({ - required this.id, - required this.name, - this.videoWidget, - this.isSpeaking = false, - this.isMuted = false, - this.hasVideo = false, - this.isScreenShare = false, - this.avatarColor = Colors.grey, - this.originalParticipant, - this.aspectRatio = 16.0 / 9.0, - }); -} - -class VoiceChannelView extends ConsumerWidget { - const VoiceChannelView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pt = PyramidTheme.of(context); - final call = ref.watch(voiceChannelProvider); - final voip = ref.watch(callSignalingProvider); - - if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) { - return _UnifiedCallView( - participants: _getVoipParticipants(voip), - isLiveKit: false, - displayName: voip.currentCall!.room.getLocalizedDisplayname(), - isConnecting: false, - onHangUp: () { - voip.hangup(); - ref.read(viewModeProvider.notifier).state = ViewMode.chat; - }, - onMinimize: () => - ref.read(viewModeProvider.notifier).state = ViewMode.chat, - onToggleMic: voip.toggleMic, - onToggleDeafen: voip.toggleDeafen, - onToggleCam: voip.toggleCamera, - onToggleScreen: () => voip.toggleScreenSharing(context), - isMuted: voip.isMicMuted, - isDeafened: voip.isDeafened, - isCameraOff: voip.isCameraMuted, - isScreenSharing: voip.isScreensharing, - onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), - pt: pt, - ); - } - - if (!call.isActive) { - return _NoCall(pt: pt, ref: ref); - } - - return _UnifiedCallView( - participants: _getLiveKitParticipants(call), - isLiveKit: true, - displayName: call.displayName, - isConnecting: call.isConnecting, - onHangUp: () { - call.hangUp(); - ref.read(viewModeProvider.notifier).state = ViewMode.chat; - }, - onMinimize: () => - ref.read(viewModeProvider.notifier).state = ViewMode.chat, - onToggleMic: call.toggleMute, - onToggleDeafen: call.toggleDeafen, - onToggleCam: call.toggleCamera, - onToggleScreen: () => _handleLiveKitScreenShare(context, call), - isMuted: call.isMuted, - isDeafened: call.isDeafened, - isCameraOff: call.isCameraOff, - isScreenSharing: call.isScreenSharing, - onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), - pt: pt, - ); - } - - List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) { - final room = call.room; - if (room == null) return []; - final all = [ - if (room.localParticipant != null) room.localParticipant!, - ...room.remoteParticipants.values, - ]; - - return all.mapIndexed((i, p) { - final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo); - final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera); - final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?; - final isScreen = screenPub != null; - - double ar = 16.0 / 9.0; - if (videoTrack != null) { - try { - final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions; - if (dim != null) { - final w = (dim.width as num?)?.toDouble() ?? 0; - final h = (dim.height as num?)?.toDouble() ?? 0; - if (w > 0 && h > 0) ar = w / h; - } - } catch (_) {} - } - - return _ParticipantData( - id: p.identity, - name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity, - videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null, - isSpeaking: p.isSpeaking, - isMuted: !p.isMicrophoneEnabled(), - hasVideo: videoTrack != null, - isScreenShare: isScreen, - avatarColor: _avatarColors[i % _avatarColors.length], - originalParticipant: p, - aspectRatio: ar, - ); - }).toList(); - } - - List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) { - final participants = <_ParticipantData>[]; - - double ar(rtc.RTCVideoRenderer r) { - final w = r.videoWidth.toDouble(); - final h = r.videoHeight.toDouble(); - return (w > 0 && h > 0) ? w / h : 16.0 / 9.0; - } - - // Remote - final isRemoteScreen = voip.isRemoteScreensharing; - final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty; - participants.add(_ParticipantData( - id: 'remote', - name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote', - videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, - isMuted: false, - hasVideo: hasRemoteVideo, - isScreenShare: isRemoteScreen, - avatarColor: _avatarColors[1], - aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0, - )); - - // Local - final isLocalScreen = voip.isScreensharing; - final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty; - participants.add(_ParticipantData( - id: 'local', - name: 'Du', - videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, - isMuted: voip.isMicMuted, - hasVideo: hasLocalVideo, - isScreenShare: isLocalScreen, - avatarColor: _avatarColors[0], - aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0, - )); - - return participants; - } - - static const _avatarColors = [ - Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7), - Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444), - Color(0xFF3B82F6), Color(0xFF84CC16), - ]; -} - -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; - - 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, - }); - - @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, - ), - ], - ), - ); - } -} - -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)), - ]), - ); - } -} - -class _ParticipantGrid extends StatelessWidget { - final List<_ParticipantData> participants; - final PyramidTheme pt; - final Function(_ParticipantData) onSelect; - - const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect}); - - @override - Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, box) { - final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2); - const spacing = 12.0; - final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols; - - return SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Wrap( - spacing: spacing, - runSpacing: spacing, - children: participants.map((p) => SizedBox( - width: tileWidth, - height: tileWidth / p.aspectRatio, - child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)), - )).toList(), - ), - ); - }); - } -} - -class _HeroLayout extends StatelessWidget { - final _ParticipantData hero; - final List<_ParticipantData> others; - final bool stripCollapsed; - final VoidCallback onToggleStrip; - final Function(bool) onSetStripCollapsed; - final Function(_ParticipantData) onSelectOther; - final Function(Offset)? onQualityMenu; - final PyramidTheme pt; - - const _HeroLayout({ - required this.hero, - required this.others, - required this.stripCollapsed, - required this.onToggleStrip, - required this.onSetStripCollapsed, - required this.onSelectOther, - this.onQualityMenu, - required this.pt, - }); - - @override - Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, box) { - final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600; - - final heroView = _HeroSection( - data: hero, - onQualityMenu: onQualityMenu, - pt: pt, - ); - - final strip = _ParticipantStrip( - participants: others, - isVertical: isLandscape, - onSelect: onSelectOther, - pt: pt, - ); - - if (isLandscape) { - return Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(12), - child: GestureDetector( - onHorizontalDragEnd: (details) { - if (details.primaryVelocity! < -300) onSetStripCollapsed(false); - if (details.primaryVelocity! > 300) onSetStripCollapsed(true); - }, - child: heroView, - ), - ), - ), - if (!stripCollapsed) - SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)), - _CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), - ], - ); - } else { - return Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(12), - child: GestureDetector( - onVerticalDragEnd: (details) { - if (details.primaryVelocity! < -300) onSetStripCollapsed(false); - if (details.primaryVelocity! > 300) onSetStripCollapsed(true); - }, - child: heroView, - ), - ), - ), - _CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt), - if (!stripCollapsed) - SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)), - ], - ); - } - }); - } -} - -class _HeroSection extends StatefulWidget { - final _ParticipantData data; - final Function(Offset)? onQualityMenu; - final PyramidTheme pt; - - const _HeroSection({required this.data, this.onQualityMenu, required this.pt}); - - @override - State<_HeroSection> createState() => _HeroSectionState(); -} - -class _HeroSectionState extends State<_HeroSection> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: () => setState(() => _hovered = !_hovered), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(pt.rLg), - child: Container( - color: Colors.black, - child: Stack( - children: [ - Positioned.fill( - child: widget.data.videoWidget != null - ? _MirrorWrapper( - isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant), - isScreenShare: widget.data.isScreenShare, - child: widget.data.videoWidget!, - ) - : _BigAvatar(data: widget.data, pt: pt), - ), - - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - top: _hovered ? 0 : -60, left: 0, right: 0, - child: _HeroTopBar(data: widget.data, pt: pt), - ), - - AnimatedPositioned( - duration: const Duration(milliseconds: 200), - bottom: _hovered ? 0 : -60, left: 0, right: 0, - child: _HeroBottomBar( - data: widget.data, - onQualityMenu: widget.onQualityMenu, - pt: pt, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _HeroTopBar extends StatelessWidget { - final _ParticipantData data; - final PyramidTheme pt; - const _HeroTopBar({required this.data, required this.pt}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]), - ), - child: Row( - children: [ - if (data.isScreenShare) _LivePill(pt: pt), - const SizedBox(width: 8), - Text( - data.isScreenShare ? "${data.name} prƤsentiert" : data.name, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14), - ), - ], - ), - ); - } -} - -class _HeroBottomBar extends StatelessWidget { - final _ParticipantData data; - final Function(Offset)? onQualityMenu; - final PyramidTheme pt; - const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]), - ), - child: Row( - children: [ - const Spacer(), - if (onQualityMenu != null) - _HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)), - const SizedBox(width: 8), - _HeroActionBtn( - icon: Icons.fullscreen_rounded, - onTap: () => Navigator.of(context).push(MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => _FullscreenVideoPage(data: data), - )), - ), - ], - ), - ); - } -} - -class _HeroActionBtn extends StatelessWidget { - final IconData icon; - final VoidCallback? onTap; - final Function(TapDownDetails)? onTapDown; - const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - onTapDown: onTapDown, - child: Container( - width: 36, height: 36, - decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)), - child: Icon(icon, color: Colors.white, size: 20), - ), - ); - } -} - -class _LivePill extends StatelessWidget { - final PyramidTheme pt; - const _LivePill({required this.pt}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)), - const SizedBox(width: 4), - const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)), - ]), - ); - } -} - -class _ParticipantStrip extends StatelessWidget { - final List<_ParticipantData> participants; - final bool isVertical; - final Function(_ParticipantData) onSelect; - final PyramidTheme pt; - - const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt}); - - @override - Widget build(BuildContext context) { - return ListView.separated( - scrollDirection: isVertical ? Axis.vertical : Axis.horizontal, - itemCount: participants.length, - separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12), - itemBuilder: (context, i) => SizedBox( - width: isVertical ? double.infinity : 101 * participants[i].aspectRatio, - height: 101, - child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])), - ), - ); - } -} - -class _ParticipantTile extends StatefulWidget { - final _ParticipantData data; - final PyramidTheme pt; - final VoidCallback onTap; - - const _ParticipantTile({required this.data, required this.pt, required this.onTap}); - - @override - State<_ParticipantTile> createState() => _ParticipantTileState(); -} - -class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin { - late AnimationController _speakingCtrl; - - @override - void initState() { - super.initState(); - _speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true); - } - - @override - void dispose() { - _speakingCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final data = widget.data; - - return GestureDetector( - onTap: widget.onTap, - child: AnimatedBuilder( - animation: _speakingCtrl, - builder: (context, _) { - final isSpeaking = data.isSpeaking; - final pulse = isSpeaking ? _speakingCtrl.value : 0.0; - - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all( - color: isSpeaking - ? pt.accent.withAlpha((150 + pulse * 105).toInt()) - : pt.border, - width: isSpeaking ? 2 : 1, - ), - boxShadow: isSpeaking - ? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)] - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(pt.rBase - 1), - child: Stack( - children: [ - Positioned.fill( - child: data.videoWidget != null - ? _MirrorWrapper( - isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), - isScreenShare: data.isScreenShare, - child: data.videoWidget!, - ) - : _SmallAvatar(data: data, pt: pt), - ), - - Positioned( - left: 6, bottom: 6, right: 6, - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)], - Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), - ], - ), - ), - ], - ), - ), - if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)), - ], - ), - ), - ); - }, - ), - ); - } -} - -class _CollapseHandle extends StatelessWidget { - final bool isVertical; - final bool collapsed; - final VoidCallback onToggle; - final PyramidTheme pt; - - const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onToggle, - child: Container( - width: isVertical ? 24 : double.infinity, - height: isVertical ? double.infinity : 24, - color: Colors.transparent, - child: Center( - child: Icon( - isVertical - ? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded) - : (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded), - color: pt.fgDim, size: 20, - ), - ), - ), - ); - } -} - -class _BigAvatar extends StatelessWidget { - final _ParticipantData data; - final PyramidTheme pt; - const _BigAvatar({required this.data, required this.pt}); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: 100, height: 100, - decoration: BoxDecoration( - gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), - shape: BoxShape.circle, - ), - child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))), - ), - ); - } -} - -class _SmallAvatar extends StatelessWidget { - final _ParticipantData data; - final PyramidTheme pt; - const _SmallAvatar({required this.data, required this.pt}); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: 44, height: 44, - decoration: BoxDecoration( - gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), - shape: BoxShape.circle, - ), - child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))), - ), - ); - } -} - -class _CallControls extends StatelessWidget { - final bool isMuted; - final bool isDeafened; - final bool isCameraOff; - final bool isScreenSharing; - final VoidCallback onToggleMic; - final VoidCallback onToggleDeafen; - final VoidCallback onToggleCam; - final VoidCallback onToggleScreen; - final VoidCallback onHangUp; - final PyramidTheme pt; - - const _CallControls({ - required this.isMuted, - required this.isDeafened, - required this.isCameraOff, - required this.isScreenSharing, - required this.onToggleMic, - required this.onToggleDeafen, - required this.onToggleCam, - required this.onToggleScreen, - required this.onHangUp, - required this.pt, - }); - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), - const SizedBox(width: 16), - _VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen), - const SizedBox(width: 16), - _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), - const SizedBox(width: 16), - _VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen), - const SizedBox(width: 24), - Container(width: 1, height: 32, color: pt.border), - const SizedBox(width: 24), - _VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp), - ], - ), - ), - ); - } -} - -class _NoCall extends StatelessWidget { - final PyramidTheme pt; - final WidgetRef ref; - const _NoCall({required this.pt, required this.ref}); - - @override - Widget build(BuildContext context) { - return Container( - color: pt.bg1, - child: Column( - children: [ - Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), - child: Row( - children: [ - PyrIconBtn( - icon: const Icon(Icons.menu_rounded), - tooltip: 'Toggle sidebar', - onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s), - ), - const SizedBox(width: 8), - Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted), - const SizedBox(width: 8), - Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), - ], - ), - ), - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('šƒ', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))), - const SizedBox(height: 12), - Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _FullscreenVideoPage extends StatelessWidget { - final _ParticipantData data; - const _FullscreenVideoPage({required this.data}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack(children: [ - Positioned.fill( - child: data.videoWidget != null - ? _MirrorWrapper( - isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant), - isScreenShare: data.isScreenShare, - child: data.videoWidget!, - ) - : _BigAvatar(data: data, pt: PyramidTheme.of(context)), - ), - Positioned( - top: 0, right: 0, - child: SafeArea( - child: IconButton( - icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28), - onPressed: () => Navigator.of(context).pop(), - ), - ), - ), - ]), - ); - } -} - -void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async { - if (call.isScreenSharing) { - await call.stopScreenShare(); - } else { - final source = await ScreenSharePicker.show(context); - if (source != null) await call.startScreenShare(source.id); - } -} - -void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) { - final pt = PyramidTheme.of(context); - showMenu( - context: context, - position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy), - color: pt.bg2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), - items: [ - const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), - for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) - PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)), - const PopupMenuDivider(), - const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), - for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')]) - PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)), - ], - ); -} - -void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) { - final pt = PyramidTheme.of(context); - showMenu( - context: context, - position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy), - color: pt.bg2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)), - items: [ - const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))), - for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')]) - PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)), - ], - ); -} - -class _QualityItem extends StatelessWidget { - final String label; - final String? hint; - final bool active; - final PyramidTheme pt; - const _QualityItem({required this.label, required this.active, required this.pt, this.hint}); - - @override - Widget build(BuildContext context) { - return Row(children: [ - if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16), - const SizedBox(width: 8), - Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)), - if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))], - ]); - } -} - -class _VoiceCtrlBtn extends StatefulWidget { - final IconData icon; - final PyramidTheme pt; - final bool active; - final bool danger; - final bool isDanger; - final VoidCallback? onTap; - - const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false}); - - @override - State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState(); -} - -class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - Color bg; - Color iconColor; - if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; } - else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; } - else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; } - else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; } - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 52, height: 52, - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)), - child: Center(child: Icon(widget.icon, size: 20, color: iconColor)), - ), - ), - ); - } -} - -class _MirrorWrapper extends StatelessWidget { - final Widget child; - final bool isLocal; - final bool isScreenShare; - - const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare}); - - @override - Widget build(BuildContext context) { - if (isLocal && !isScreenShare) { - return Transform( - alignment: Alignment.center, - transform: Matrix4.identity()..rotateY(3.14159), - child: child, - ); - } - return child; - } -} +part 'view/voice_channel_core.dart'; +part 'view/call_view.dart'; +part 'view/participant_layout.dart'; +part 'view/avatars_controls.dart'; +part 'view/quality_menu.dart';