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 createState() => _MiniCallWidgetState(); } class _MiniCallWidgetState extends ConsumerState { 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, ), ], ), ), ); } }