Files
pyramid/lib/features/call_ui/mini_call_widget.dart

473 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
class MiniCallWidget extends ConsumerStatefulWidget {
final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
const MiniCallWidget({super.key, required this.call});
@override
ConsumerState<MiniCallWidget> createState() => _MiniCallWidgetState();
}
class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
int _elapsed = 0;
Timer? _timer;
bool _streamCollapsed = false;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _elapsed++);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String _fmt(int s) {
final m = s ~/ 60;
final sec = s % 60;
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVoip = widget.call is CallSignalingService;
final call = widget.call;
final String displayName = isVoip
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
: call.displayName;
final bool isConnecting = isVoip ? false : call.isConnecting;
final bool isMuted = isVoip ? call.isMicMuted : call.isMuted;
final bool isCameraOff = isVoip ? call.isCameraMuted : call.isCameraOff;
final bool isDeafened = isVoip ? call.isDeafened : call.isDeafened;
final bool isStreaming = isVoip ? call.isScreensharing : call.isScreenSharing;
// Show stream preview if camera is on or screensharing
final showPreview = !isCameraOff || isStreaming;
// Im Querformat auf Mobilgeräten ist die Höhe knapp — Vorschau kompakter.
final compact = MediaQuery.sizeOf(context).height < 500;
final previewHeight = _streamCollapsed ? 28.0 : (compact ? 84.0 : 140.0);
return Container(
decoration: BoxDecoration(
color: pt.bg2,
border: Border(top: BorderSide(color: pt.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stream Mini Preview (Mockup style)
if (showPreview)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: previewHeight,
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.black,
border: Border(bottom: BorderSide(color: Colors.black26)),
),
child: Stack(
children: [
// Stream Content
if (!_streamCollapsed)
Positioned.fill(
child: isVoip
? rtc.RTCVideoView(
// When we're screensharing show our local stream,
// otherwise show the remote participant's stream
isStreaming ? call.localRenderer : call.remoteRenderer,
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
: _LiveKitPreview(manager: call as VoiceChannelService),
),
// Top bar of preview
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black.withAlpha(200), Colors.transparent],
),
),
child: Row(
children: [
const _LiveDot(),
const SizedBox(width: 6),
Expanded(
child: Text(
isStreaming ? 'STREAMING' : 'CAMERA ON',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5),
),
),
GestureDetector(
onTap: () => setState(() => _streamCollapsed = !_streamCollapsed),
child: Icon(
_streamCollapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded,
size: 16,
color: Colors.white70,
),
),
],
),
),
],
),
),
// Connected info row
GestureDetector(
onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
pt.online.withAlpha(46),
Colors.transparent,
],
end: Alignment.centerRight,
),
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const _LiveDot(),
const SizedBox(width: 6),
Text(
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
style: TextStyle(
color: pt.online,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 2),
Text(
displayName,
style: TextStyle(
color: pt.fg,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
Text(
_fmt(_elapsed),
style: TextStyle(
color: pt.fgDim,
fontSize: 10,
fontFamily: 'monospace',
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ActionIcon(
icon: Icons.open_in_new_rounded,
pt: pt,
onTap: () => ref.read(viewModeProvider.notifier).state = ViewMode.voice,
),
_ActionIcon(
icon: Icons.call_end_rounded,
pt: pt,
danger: true,
onTap: () {
if (isVoip) {
call.hangup();
} else {
call.hangUp();
}
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
),
],
),
],
),
),
),
// Controls
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
_ControlBtn(
icon: isMuted ? Icons.mic_off_rounded : Icons.mic_none_rounded,
active: !isMuted,
danger: isMuted,
pt: pt,
onTap: () {
if (isVoip) {
call.toggleMic();
} else {
call.toggleMute();
}
},
),
const SizedBox(width: 4),
_ControlBtn(
icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded,
active: !isDeafened,
danger: isDeafened,
pt: pt,
onTap: () => call.toggleDeafen(),
),
const SizedBox(width: 4),
_ControlBtn(
icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded,
active: !isCameraOff,
pt: pt,
onTap: () => call.toggleCamera(),
),
const SizedBox(width: 4),
_ControlBtn(
icon: Icons.screen_share_rounded,
active: isStreaming,
pt: pt,
onTap: () async {
if (isVoip) {
(call as CallSignalingService).toggleScreenSharing(context);
} else {
final lk = call as VoiceChannelService;
if (lk.isScreenSharing) {
await lk.stopScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) {
await lk.startScreenShare(source.id);
}
}
}
},
),
],
),
),
],
),
);
}
}
class _ActionIcon extends StatelessWidget {
final IconData icon;
final PyramidTheme pt;
final VoidCallback onTap;
final bool danger;
const _ActionIcon({required this.icon, required this.pt, required this.onTap, this.danger = false});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(icon, size: 18),
onPressed: onTap,
color: danger ? pt.danger : pt.fgDim,
visualDensity: VisualDensity.compact,
style: danger ? IconButton.styleFrom(
hoverColor: pt.danger.withAlpha(30),
) : null,
);
}
}
class _ControlBtn extends StatefulWidget {
final IconData icon;
final bool active;
final bool danger;
final PyramidTheme pt;
final VoidCallback onTap;
const _ControlBtn({required this.icon, required this.active, required this.pt, required this.onTap, this.danger = false});
@override
State<_ControlBtn> createState() => _ControlBtnState();
}
class _ControlBtnState extends State<_ControlBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Expanded(
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: pt.durationFast,
height: 32,
decoration: BoxDecoration(
color: widget.active
? (widget.danger ? pt.danger : pt.accent)
: (_hovered ? pt.bgHover : pt.bg3),
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Icon(
widget.icon,
size: 16,
color: widget.active ? (widget.danger ? Colors.white : pt.accentFg) : pt.fg,
),
),
),
),
);
}
}
class _LiveKitPreview extends StatelessWidget {
final VoiceChannelService manager;
const _LiveKitPreview({required this.manager});
@override
Widget build(BuildContext context) {
final room = manager.room;
if (room == null) return const SizedBox();
// Find first available video track (screenshare preferred)
VideoTrack? bestTrack;
// Check remote participants
for (final p in room.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.source == TrackSource.screenShareVideo) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack != null) break;
}
// Fallback to camera if no screen share
if (bestTrack == null) {
for (final p in room.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.source == TrackSource.camera) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack != null) break;
}
}
// Fallback to local if still nothing
if (bestTrack == null && room.localParticipant != null) {
for (final pub in room.localParticipant!.videoTrackPublications) {
final t = pub.track;
if (t is VideoTrack) {
bestTrack = t;
break;
}
}
}
if (bestTrack == null) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.indigo.shade900, Colors.blue.shade900],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Center(
child: Icon(Icons.podcasts_rounded, color: Colors.white24, size: 40),
),
);
}
return VideoTrackRenderer(
bestTrack,
fit: VideoViewFit.cover,
);
}
}
class _LiveDot extends StatefulWidget {
const _LiveDot();
@override
State<_LiveDot> createState() => _LiveDotState();
}
class _LiveDotState extends State<_LiveDot> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) => Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: pt.online,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: pt.online.withAlpha((_ctrl.value * 150).toInt()),
blurRadius: 4 * _ctrl.value,
spreadRadius: 2 * _ctrl.value,
),
],
),
),
);
}
}