2395db50be
Ungenutzte Imports (dart:typed_data, flutter/services.dart), unnötige String-Interpolations-Klammern, führender Unterstrich bei lokaler Funktion, fehlende Blockklammern bei if-Statements und SCREAMING_CASE-Konstanten in clipboard_image.dart auf lowerCamelCase umbenannt. Rein mechanisch, keine Verhaltensänderung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1124 lines
39 KiB
Dart
1124 lines
39 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:livekit_client/livekit_client.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:pyramid/core/app_state.dart';
|
|
import 'package:pyramid/widgets/screen_share_picker.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
import 'package:pyramid/core/livekit_call_manager.dart';
|
|
import 'package:pyramid/core/voip_manager.dart';
|
|
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(callStateProvider);
|
|
final voip = ref.watch(voipStateProvider);
|
|
|
|
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(LiveKitCallManager call) {
|
|
final room = call.room;
|
|
if (room == null) return [];
|
|
final all = <Participant>[
|
|
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(PyramidVoipManager 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, LiveKitCallManager 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, LiveKitCallManager 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: <PopupMenuEntry>[
|
|
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, PyramidVoipManager 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: <PopupMenuEntry>[
|
|
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;
|
|
}
|
|
}
|