Files
pyramid/lib/features/call_ui/view/participant_layout.dart
T

425 lines
14 KiB
Dart

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