402 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			402 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:go_router/go_router.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/pods/chat/call.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/chat/call_participant_tile.dart';
 | 
						|
import 'package:island/widgets/content/sheet.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
import 'package:livekit_client/livekit_client.dart';
 | 
						|
 | 
						|
class CallControlsBar extends HookConsumerWidget {
 | 
						|
  const CallControlsBar({super.key});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final callState = ref.watch(callNotifierProvider);
 | 
						|
    final callNotifier = ref.read(callNotifierProvider.notifier);
 | 
						|
 | 
						|
    return Container(
 | 
						|
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
 | 
						|
      child: Wrap(
 | 
						|
        alignment: WrapAlignment.center,
 | 
						|
        runSpacing: 16,
 | 
						|
        spacing: 16,
 | 
						|
        children: [
 | 
						|
          _buildCircularButtonWithDropdown(
 | 
						|
            context: context,
 | 
						|
            ref: ref,
 | 
						|
            icon:
 | 
						|
                callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
 | 
						|
            onPressed: () => callNotifier.toggleCamera(),
 | 
						|
            backgroundColor: const Color(0xFF424242),
 | 
						|
            hasDropdown: true,
 | 
						|
            deviceType: 'videoinput',
 | 
						|
          ),
 | 
						|
          _buildCircularButton(
 | 
						|
            icon:
 | 
						|
                callState.isScreenSharing
 | 
						|
                    ? Icons.stop_screen_share
 | 
						|
                    : Icons.screen_share,
 | 
						|
            onPressed: () => callNotifier.toggleScreenShare(context),
 | 
						|
            backgroundColor: const Color(0xFF424242),
 | 
						|
          ),
 | 
						|
          _buildCircularButtonWithDropdown(
 | 
						|
            context: context,
 | 
						|
            ref: ref,
 | 
						|
            icon: callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
 | 
						|
            onPressed: () => callNotifier.toggleMicrophone(),
 | 
						|
            backgroundColor: const Color(0xFF424242),
 | 
						|
            hasDropdown: true,
 | 
						|
            deviceType: 'audioinput',
 | 
						|
          ),
 | 
						|
          _buildCircularButton(
 | 
						|
            icon:
 | 
						|
                callState.isSpeakerphone
 | 
						|
                    ? Symbols.mobile_speaker
 | 
						|
                    : Symbols.ear_sound,
 | 
						|
            onPressed: () => callNotifier.toggleSpeakerphone(),
 | 
						|
            backgroundColor: const Color(0xFF424242),
 | 
						|
          ),
 | 
						|
          _buildCircularButton(
 | 
						|
            icon: Icons.call_end,
 | 
						|
            onPressed:
 | 
						|
                () => showModalBottomSheet(
 | 
						|
                  context: context,
 | 
						|
                  isScrollControlled: true,
 | 
						|
                  useRootNavigator: true,
 | 
						|
                  builder:
 | 
						|
                      (innerContext) => Column(
 | 
						|
                        mainAxisSize: MainAxisSize.min,
 | 
						|
                        children: [
 | 
						|
                          ListTile(
 | 
						|
                            leading: const Icon(Symbols.logout, fill: 1),
 | 
						|
                            title: Text('callLeave').tr(),
 | 
						|
                            onTap: () {
 | 
						|
                              callNotifier.disconnect();
 | 
						|
                              Navigator.of(context).pop();
 | 
						|
                              Navigator.of(innerContext).pop();
 | 
						|
                            },
 | 
						|
                          ),
 | 
						|
                          ListTile(
 | 
						|
                            leading: const Icon(Symbols.call_end, fill: 1),
 | 
						|
                            iconColor: Colors.red,
 | 
						|
                            title: Text('callEnd').tr(),
 | 
						|
                            onTap: () async {
 | 
						|
                              callNotifier.disconnect();
 | 
						|
                              final apiClient = ref.watch(apiClientProvider);
 | 
						|
                              try {
 | 
						|
                                showLoadingModal(context);
 | 
						|
                                await apiClient.delete(
 | 
						|
                                  '/sphere/chat/realtime/${callNotifier.roomId}',
 | 
						|
                                );
 | 
						|
                                callNotifier.dispose();
 | 
						|
                                if (context.mounted) {
 | 
						|
                                  Navigator.of(context).pop();
 | 
						|
                                  Navigator.of(innerContext).pop();
 | 
						|
                                }
 | 
						|
                              } catch (err) {
 | 
						|
                                showErrorAlert(err);
 | 
						|
                              } finally {
 | 
						|
                                if (context.mounted) hideLoadingModal(context);
 | 
						|
                              }
 | 
						|
                            },
 | 
						|
                          ),
 | 
						|
                          Gap(MediaQuery.of(context).padding.bottom),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                ),
 | 
						|
            backgroundColor: const Color(0xFFE53E3E),
 | 
						|
            iconColor: Colors.white,
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildCircularButton({
 | 
						|
    required IconData icon,
 | 
						|
    required VoidCallback onPressed,
 | 
						|
    required Color backgroundColor,
 | 
						|
    Color? iconColor,
 | 
						|
  }) {
 | 
						|
    return Container(
 | 
						|
      width: 56,
 | 
						|
      height: 56,
 | 
						|
      decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
 | 
						|
      child: IconButton(
 | 
						|
        icon: Icon(icon, color: iconColor ?? Colors.white, size: 24),
 | 
						|
        onPressed: onPressed,
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Widget _buildCircularButtonWithDropdown({
 | 
						|
    required BuildContext context,
 | 
						|
    required WidgetRef ref,
 | 
						|
    required IconData icon,
 | 
						|
    required VoidCallback onPressed,
 | 
						|
    required Color backgroundColor,
 | 
						|
    required bool hasDropdown,
 | 
						|
    Color? iconColor,
 | 
						|
    String? deviceType, // 'videoinput' or 'audioinput'
 | 
						|
  }) {
 | 
						|
    return Stack(
 | 
						|
      children: [
 | 
						|
        Container(
 | 
						|
          width: 56,
 | 
						|
          height: 56,
 | 
						|
          decoration: BoxDecoration(
 | 
						|
            color: backgroundColor,
 | 
						|
            shape: BoxShape.circle,
 | 
						|
          ),
 | 
						|
          child: IconButton(
 | 
						|
            icon: Icon(icon, color: iconColor ?? Colors.white, size: 24),
 | 
						|
            onPressed: onPressed,
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
        if (hasDropdown && deviceType != null)
 | 
						|
          Positioned(
 | 
						|
            bottom: 4,
 | 
						|
            right: 4,
 | 
						|
            child: GestureDetector(
 | 
						|
              onTap: () => _showDeviceSelectionDialog(context, ref, deviceType),
 | 
						|
              child: Container(
 | 
						|
                width: 16,
 | 
						|
                height: 16,
 | 
						|
                decoration: BoxDecoration(
 | 
						|
                  color: backgroundColor.withOpacity(0.8),
 | 
						|
                  shape: BoxShape.circle,
 | 
						|
                  border: Border.all(
 | 
						|
                    color: Colors.white.withOpacity(0.3),
 | 
						|
                    width: 0.5,
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
                child: Icon(
 | 
						|
                  Icons.arrow_drop_down,
 | 
						|
                  color: Colors.white,
 | 
						|
                  size: 12,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _showDeviceSelectionDialog(
 | 
						|
    BuildContext context,
 | 
						|
    WidgetRef ref,
 | 
						|
    String deviceType,
 | 
						|
  ) async {
 | 
						|
    try {
 | 
						|
      final devices = await Hardware.instance.enumerateDevices(
 | 
						|
        type: deviceType,
 | 
						|
      );
 | 
						|
 | 
						|
      if (!context.mounted) return;
 | 
						|
 | 
						|
      showModalBottomSheet(
 | 
						|
        context: context,
 | 
						|
        builder: (BuildContext dialogContext) {
 | 
						|
          return SheetScaffold(
 | 
						|
            titleText:
 | 
						|
                deviceType == 'videoinput'
 | 
						|
                    ? 'selectCamera'.tr()
 | 
						|
                    : 'selectMicrophone'.tr(),
 | 
						|
            child: ListView.builder(
 | 
						|
              itemCount: devices.length,
 | 
						|
              itemBuilder: (context, index) {
 | 
						|
                final device = devices[index];
 | 
						|
                return ListTile(
 | 
						|
                  title: Text(
 | 
						|
                    device.label.isNotEmpty
 | 
						|
                        ? device.label
 | 
						|
                        : '${'device'.tr()} ${index + 1}',
 | 
						|
                  ),
 | 
						|
                  onTap: () {
 | 
						|
                    Navigator.of(dialogContext).pop();
 | 
						|
                    _switchDevice(context, ref, device, deviceType);
 | 
						|
                  },
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          );
 | 
						|
        },
 | 
						|
      );
 | 
						|
    } catch (e) {
 | 
						|
      showErrorAlert(e);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> _switchDevice(
 | 
						|
    BuildContext context,
 | 
						|
    WidgetRef ref,
 | 
						|
    MediaDevice device,
 | 
						|
    String deviceType,
 | 
						|
  ) async {
 | 
						|
    try {
 | 
						|
      final callNotifier = ref.read(callNotifierProvider.notifier);
 | 
						|
 | 
						|
      if (deviceType == 'videoinput') {
 | 
						|
        // Switch camera device
 | 
						|
        final localParticipant = callNotifier.room?.localParticipant;
 | 
						|
        final videoTrack =
 | 
						|
            localParticipant?.videoTrackPublications.firstOrNull?.track;
 | 
						|
 | 
						|
        if (videoTrack is LocalVideoTrack) {
 | 
						|
          await videoTrack.switchCamera(device.deviceId);
 | 
						|
        }
 | 
						|
      } else if (deviceType == 'audioinput') {
 | 
						|
        // Switch microphone device
 | 
						|
        final localParticipant = callNotifier.room?.localParticipant;
 | 
						|
        final audioTrack =
 | 
						|
            localParticipant?.audioTrackPublications.firstOrNull?.track;
 | 
						|
 | 
						|
        if (audioTrack is LocalAudioTrack) {
 | 
						|
          // For audio devices, we need to restart the track with new device
 | 
						|
          await audioTrack.restartTrack(
 | 
						|
            AudioCaptureOptions(deviceId: device.deviceId),
 | 
						|
          );
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (context.mounted) {
 | 
						|
        showSnackBar(
 | 
						|
          'switchedTo'.tr(
 | 
						|
            args: [device.label.isNotEmpty ? device.label : 'device'],
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      }
 | 
						|
    } catch (err) {
 | 
						|
      showErrorAlert(err);
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class CallOverlayBar extends HookConsumerWidget {
 | 
						|
  const CallOverlayBar({super.key});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final callState = ref.watch(callNotifierProvider);
 | 
						|
    final callNotifier = ref.read(callNotifierProvider.notifier);
 | 
						|
    // Only show if connected and not on the call screen
 | 
						|
    if (!callState.isConnected) return const SizedBox.shrink();
 | 
						|
 | 
						|
    final lastSpeaker =
 | 
						|
        callNotifier.participants
 | 
						|
                .where(
 | 
						|
                  (element) => element.remoteParticipant.lastSpokeAt != null,
 | 
						|
                )
 | 
						|
                .isEmpty
 | 
						|
            ? callNotifier.participants.first
 | 
						|
            : callNotifier.participants
 | 
						|
                .where(
 | 
						|
                  (element) => element.remoteParticipant.lastSpokeAt != null,
 | 
						|
                )
 | 
						|
                .fold(
 | 
						|
                  callNotifier.participants.first,
 | 
						|
                  (value, element) =>
 | 
						|
                      element.remoteParticipant.lastSpokeAt != null &&
 | 
						|
                              (value.remoteParticipant.lastSpokeAt == null ||
 | 
						|
                                  element.remoteParticipant.lastSpokeAt!
 | 
						|
                                          .compareTo(
 | 
						|
                                            value
 | 
						|
                                                .remoteParticipant
 | 
						|
                                                .lastSpokeAt!,
 | 
						|
                                          ) >
 | 
						|
                                      0)
 | 
						|
                          ? element
 | 
						|
                          : value,
 | 
						|
                );
 | 
						|
 | 
						|
    final actionButtonStyle = ButtonStyle(
 | 
						|
      minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
 | 
						|
    );
 | 
						|
 | 
						|
    return GestureDetector(
 | 
						|
      child: Card(
 | 
						|
        margin: EdgeInsets.zero,
 | 
						|
        child: Row(
 | 
						|
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
						|
          children: [
 | 
						|
            Expanded(
 | 
						|
              child: Row(
 | 
						|
                children: [
 | 
						|
                  Builder(
 | 
						|
                    builder: (context) {
 | 
						|
                      if (callNotifier.localParticipant == null) {
 | 
						|
                        return CircularProgressIndicator().center();
 | 
						|
                      }
 | 
						|
                      return SizedBox(
 | 
						|
                        width: 40,
 | 
						|
                        height: 40,
 | 
						|
                        child:
 | 
						|
                            SpeakingRippleAvatar(
 | 
						|
                              live: lastSpeaker,
 | 
						|
                              size: 36,
 | 
						|
                            ).center(),
 | 
						|
                      );
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  const Gap(8),
 | 
						|
                  Column(
 | 
						|
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                    children: [
 | 
						|
                      Text('@${lastSpeaker.participant.identity}').bold(),
 | 
						|
                      Text(
 | 
						|
                        formatDuration(callState.duration),
 | 
						|
                        style: Theme.of(context).textTheme.bodySmall,
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
            IconButton(
 | 
						|
              icon: Icon(
 | 
						|
                callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
 | 
						|
              ),
 | 
						|
              onPressed: () {
 | 
						|
                callNotifier.toggleMicrophone();
 | 
						|
              },
 | 
						|
              style: actionButtonStyle,
 | 
						|
            ),
 | 
						|
            IconButton(
 | 
						|
              icon: Icon(
 | 
						|
                callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
 | 
						|
              ),
 | 
						|
              onPressed: () {
 | 
						|
                callNotifier.toggleCamera();
 | 
						|
              },
 | 
						|
              style: actionButtonStyle,
 | 
						|
            ),
 | 
						|
            IconButton(
 | 
						|
              icon: Icon(
 | 
						|
                callState.isScreenSharing
 | 
						|
                    ? Icons.stop_screen_share
 | 
						|
                    : Icons.screen_share,
 | 
						|
              ),
 | 
						|
              onPressed: () {
 | 
						|
                callNotifier.toggleScreenShare(context);
 | 
						|
              },
 | 
						|
              style: actionButtonStyle,
 | 
						|
            ),
 | 
						|
          ],
 | 
						|
        ).padding(all: 16),
 | 
						|
      ),
 | 
						|
      onTap: () {
 | 
						|
        context.pushNamed(
 | 
						|
          'chatCall',
 | 
						|
          pathParameters: {'id': callNotifier.roomId!},
 | 
						|
        );
 | 
						|
      },
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |