425 lines
14 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|