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, ), ), ), ); } }