431 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			431 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:async';
 | 
						|
import 'dart:io';
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_webrtc/flutter_webrtc.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/chat/call_button.dart';
 | 
						|
import 'package:livekit_client/livekit_client.dart' as lk;
 | 
						|
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
						|
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/models/chat.dart';
 | 
						|
import 'package:wakelock_plus/wakelock_plus.dart';
 | 
						|
import 'package:island/talker.dart';
 | 
						|
 | 
						|
part 'call.g.dart';
 | 
						|
part 'call.freezed.dart';
 | 
						|
 | 
						|
String formatDuration(Duration duration) {
 | 
						|
  String negativeSign = duration.isNegative ? '-' : '';
 | 
						|
  String twoDigits(int n) => n.toString().padLeft(2, "0");
 | 
						|
  String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs());
 | 
						|
  String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
 | 
						|
  return "$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
 | 
						|
}
 | 
						|
 | 
						|
@freezed
 | 
						|
sealed class CallState with _$CallState {
 | 
						|
  const factory CallState({
 | 
						|
    required bool isConnected,
 | 
						|
    required bool isMicrophoneEnabled,
 | 
						|
    required bool isCameraEnabled,
 | 
						|
    required bool isScreenSharing,
 | 
						|
    required bool isSpeakerphone,
 | 
						|
    @Default(Duration(seconds: 0)) Duration duration,
 | 
						|
    String? error,
 | 
						|
  }) = _CallState;
 | 
						|
}
 | 
						|
 | 
						|
@freezed
 | 
						|
sealed class CallParticipantLive with _$CallParticipantLive {
 | 
						|
  const CallParticipantLive._();
 | 
						|
 | 
						|
  const factory CallParticipantLive({
 | 
						|
    required CallParticipant participant,
 | 
						|
    required lk.Participant remoteParticipant,
 | 
						|
  }) = _CallParticipantLive;
 | 
						|
 | 
						|
  bool get isSpeaking => remoteParticipant.isSpeaking;
 | 
						|
  bool get isMuted =>
 | 
						|
      remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled();
 | 
						|
  bool get isScreenSharing => remoteParticipant.isScreenShareEnabled();
 | 
						|
  bool get isScreenSharingWithAudio =>
 | 
						|
      remoteParticipant.isScreenShareAudioEnabled();
 | 
						|
 | 
						|
  bool get hasVideo => remoteParticipant.hasVideo;
 | 
						|
  bool get hasAudio => remoteParticipant.hasAudio;
 | 
						|
}
 | 
						|
 | 
						|
@Riverpod(keepAlive: true)
 | 
						|
class CallNotifier extends _$CallNotifier {
 | 
						|
  lk.Room? _room;
 | 
						|
  lk.LocalParticipant? _localParticipant;
 | 
						|
  List<CallParticipantLive> _participants = [];
 | 
						|
  final Map<String, CallParticipant> _participantInfoByIdentity = {};
 | 
						|
  lk.EventsListener? _roomListener;
 | 
						|
 | 
						|
  List<CallParticipantLive> get participants =>
 | 
						|
      List.unmodifiable(_participants);
 | 
						|
  lk.LocalParticipant? get localParticipant => _localParticipant;
 | 
						|
 | 
						|
  Map<String, double> participantsVolumes = {};
 | 
						|
 | 
						|
  Timer? _durationTimer;
 | 
						|
 | 
						|
  lk.Room? get room => _room;
 | 
						|
 | 
						|
  @override
 | 
						|
  CallState build() {
 | 
						|
    // Subscribe to websocket updates
 | 
						|
    return const CallState(
 | 
						|
      isConnected: false,
 | 
						|
      isMicrophoneEnabled: true,
 | 
						|
      isCameraEnabled: false,
 | 
						|
      isScreenSharing: false,
 | 
						|
      isSpeakerphone: true,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  void _initRoomListeners() {
 | 
						|
    if (_room == null) return;
 | 
						|
    _roomListener?.dispose();
 | 
						|
    _roomListener = _room!.createListener();
 | 
						|
    _room!.addListener(_onRoomChange);
 | 
						|
    _roomListener!
 | 
						|
      ..on<lk.ParticipantConnectedEvent>((e) {
 | 
						|
        _refreshLiveParticipants();
 | 
						|
      })
 | 
						|
      ..on<lk.RoomDisconnectedEvent>((e) {
 | 
						|
        _participants = [];
 | 
						|
        state = state.copyWith();
 | 
						|
      });
 | 
						|
  }
 | 
						|
 | 
						|
  void _onRoomChange() {
 | 
						|
    _refreshLiveParticipants();
 | 
						|
  }
 | 
						|
 | 
						|
  void _refreshLiveParticipants() {
 | 
						|
    if (_room == null) return;
 | 
						|
    final remoteParticipants = _room!.remoteParticipants;
 | 
						|
    _participants = [];
 | 
						|
    // Add local participant first if available
 | 
						|
    if (_localParticipant != null) {
 | 
						|
      final localInfo = _buildParticipant();
 | 
						|
      _participants.add(
 | 
						|
        CallParticipantLive(
 | 
						|
          participant: localInfo,
 | 
						|
          remoteParticipant: _localParticipant!,
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
    // Add remote participants
 | 
						|
    _participants.addAll(
 | 
						|
      remoteParticipants.values.map((remote) {
 | 
						|
        final match =
 | 
						|
            _participantInfoByIdentity[remote.identity] ??
 | 
						|
            CallParticipant(
 | 
						|
              identity: remote.identity,
 | 
						|
              name: remote.identity,
 | 
						|
              joinedAt: DateTime.now(),
 | 
						|
            );
 | 
						|
        return CallParticipantLive(
 | 
						|
          participant: match,
 | 
						|
          remoteParticipant: remote,
 | 
						|
        );
 | 
						|
      }),
 | 
						|
    );
 | 
						|
    state = state.copyWith();
 | 
						|
  }
 | 
						|
 | 
						|
  /// Builds the CallParticipant object for the local participant.
 | 
						|
  /// Optionally, pass [participants] if you want to prioritize info from the latest list.
 | 
						|
  CallParticipant _buildParticipant({List<CallParticipant>? participants}) {
 | 
						|
    if (_localParticipant == null) {
 | 
						|
      throw StateError('No local participant available');
 | 
						|
    }
 | 
						|
    // Prefer info from the latest participants list if available
 | 
						|
    if (participants != null) {
 | 
						|
      final idx = participants.indexWhere(
 | 
						|
        (p) => p.identity == _localParticipant!.identity,
 | 
						|
      );
 | 
						|
      if (idx != -1) return participants[idx];
 | 
						|
    }
 | 
						|
 | 
						|
    // Otherwise, use info from the identity map or fallback to minimal
 | 
						|
    return _participantInfoByIdentity[_localParticipant!.identity] ??
 | 
						|
        CallParticipant(
 | 
						|
          identity: _localParticipant!.identity,
 | 
						|
          name: _localParticipant!.identity,
 | 
						|
          joinedAt: DateTime.now(),
 | 
						|
        );
 | 
						|
  }
 | 
						|
 | 
						|
  void _updateLiveParticipants(List<CallParticipant> participants) {
 | 
						|
    // Update the info map for lookup
 | 
						|
    for (final p in participants) {
 | 
						|
      _participantInfoByIdentity[p.identity] = p;
 | 
						|
    }
 | 
						|
    if (_room == null) {
 | 
						|
      // Can't build live objects, just store empty
 | 
						|
      _participants = [];
 | 
						|
      state = state.copyWith();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    final remoteParticipants = _room!.remoteParticipants;
 | 
						|
    final remotes = remoteParticipants.values.toList();
 | 
						|
    _participants = [];
 | 
						|
    // Add local participant if present in the list
 | 
						|
    if (_localParticipant != null) {
 | 
						|
      final localInfo = _buildParticipant(participants: participants);
 | 
						|
      _participants.add(
 | 
						|
        CallParticipantLive(
 | 
						|
          participant: localInfo,
 | 
						|
          remoteParticipant: _localParticipant!,
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      state = state.copyWith();
 | 
						|
    }
 | 
						|
    // Add remote participants
 | 
						|
    _participants.addAll(
 | 
						|
      participants.map((p) {
 | 
						|
        lk.RemoteParticipant? remote;
 | 
						|
        for (final r in remotes) {
 | 
						|
          if (r.identity == p.identity) {
 | 
						|
            remote = r;
 | 
						|
            break;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        if (_localParticipant != null &&
 | 
						|
            p.identity == _localParticipant!.identity) {
 | 
						|
          return null; // Already added local
 | 
						|
        }
 | 
						|
        return remote != null
 | 
						|
            ? CallParticipantLive(participant: p, remoteParticipant: remote)
 | 
						|
            : null;
 | 
						|
      }).whereType<CallParticipantLive>(),
 | 
						|
    );
 | 
						|
    state = state.copyWith();
 | 
						|
  }
 | 
						|
 | 
						|
  String? _roomId;
 | 
						|
  String? get roomId => _roomId;
 | 
						|
 | 
						|
  Future<void> joinRoom(String roomId) async {
 | 
						|
    if (_roomId == roomId && _room != null) {
 | 
						|
      talker.info('[Call] Call skipped. Already has data');
 | 
						|
      return;
 | 
						|
    } else if (_room != null) {
 | 
						|
      if (!_room!.isDisposed &&
 | 
						|
          _room!.connectionState != lk.ConnectionState.disconnected) {
 | 
						|
        throw Exception('Call already connected');
 | 
						|
      }
 | 
						|
    }
 | 
						|
    _roomId = roomId;
 | 
						|
    if (_room != null) {
 | 
						|
      await _room!.disconnect();
 | 
						|
      await _room!.dispose();
 | 
						|
      _room = null;
 | 
						|
      _localParticipant = null;
 | 
						|
      _participants = [];
 | 
						|
    }
 | 
						|
    try {
 | 
						|
      final apiClient = ref.read(apiClientProvider);
 | 
						|
      final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
 | 
						|
      final response = await apiClient.get(
 | 
						|
        '/sphere/chat/realtime/$roomId/join',
 | 
						|
      );
 | 
						|
      if (response.statusCode == 200 && response.data != null) {
 | 
						|
        final data = response.data;
 | 
						|
        // Parse join response
 | 
						|
        final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
 | 
						|
        final participants = joinResponse.participants;
 | 
						|
        final String endpoint = joinResponse.endpoint;
 | 
						|
        final String token = joinResponse.token;
 | 
						|
 | 
						|
        // Setup duration timer
 | 
						|
        _durationTimer?.cancel();
 | 
						|
        _durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
 | 
						|
          state = state.copyWith(
 | 
						|
            duration: Duration(
 | 
						|
              milliseconds:
 | 
						|
                  (DateTime.now().millisecondsSinceEpoch -
 | 
						|
                      (ongoingCall?.createdAt.millisecondsSinceEpoch ??
 | 
						|
                          DateTime.now().millisecondsSinceEpoch)),
 | 
						|
            ),
 | 
						|
          );
 | 
						|
        });
 | 
						|
 | 
						|
        // Connect to LiveKit
 | 
						|
        _room = lk.Room();
 | 
						|
 | 
						|
        await _room!.connect(
 | 
						|
          endpoint,
 | 
						|
          token,
 | 
						|
          connectOptions: lk.ConnectOptions(autoSubscribe: true),
 | 
						|
          roomOptions: lk.RoomOptions(adaptiveStream: true, dynacast: true),
 | 
						|
          fastConnectOptions: lk.FastConnectOptions(
 | 
						|
            microphone: lk.TrackOption(enabled: true),
 | 
						|
          ),
 | 
						|
        );
 | 
						|
        _localParticipant = _room!.localParticipant;
 | 
						|
 | 
						|
        _initRoomListeners();
 | 
						|
        _updateLiveParticipants(participants);
 | 
						|
 | 
						|
        if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
 | 
						|
          lk.Hardware.instance.setSpeakerphoneOn(true);
 | 
						|
        }
 | 
						|
 | 
						|
        // Listen for connection updates
 | 
						|
        _room!.addListener(() {
 | 
						|
          final wasConnected = state.isConnected;
 | 
						|
          final isNowConnected =
 | 
						|
              _room!.connectionState == lk.ConnectionState.connected;
 | 
						|
          state = state.copyWith(
 | 
						|
            isConnected: isNowConnected,
 | 
						|
            isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
 | 
						|
            isCameraEnabled: _localParticipant!.isCameraEnabled(),
 | 
						|
            isScreenSharing: _localParticipant!.isScreenShareEnabled(),
 | 
						|
          );
 | 
						|
          // Enable wakelock when call connects
 | 
						|
          if (!wasConnected && isNowConnected) {
 | 
						|
            WakelockPlus.enable();
 | 
						|
          }
 | 
						|
          // Disable wakelock when call disconnects
 | 
						|
          else if (wasConnected && !isNowConnected) {
 | 
						|
            WakelockPlus.disable();
 | 
						|
          }
 | 
						|
        });
 | 
						|
        state = state.copyWith(isConnected: true);
 | 
						|
        // Enable wakelock when call connects
 | 
						|
        WakelockPlus.enable();
 | 
						|
      } else {
 | 
						|
        state = state.copyWith(error: 'Failed to join room');
 | 
						|
      }
 | 
						|
    } catch (e) {
 | 
						|
      state = state.copyWith(error: e.toString());
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> toggleMicrophone() async {
 | 
						|
    if (_localParticipant != null) {
 | 
						|
      const autostop = true;
 | 
						|
      final target = !_localParticipant!.isMicrophoneEnabled();
 | 
						|
      state = state.copyWith(isMicrophoneEnabled: target);
 | 
						|
      if (target) {
 | 
						|
        await _localParticipant!.audioTrackPublications.firstOrNull?.unmute(
 | 
						|
          stopOnMute: autostop,
 | 
						|
        );
 | 
						|
      } else {
 | 
						|
        await _localParticipant!.audioTrackPublications.firstOrNull?.mute(
 | 
						|
          stopOnMute: autostop,
 | 
						|
        );
 | 
						|
      }
 | 
						|
      state = state.copyWith();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> toggleCamera() async {
 | 
						|
    if (_localParticipant != null) {
 | 
						|
      final target = !_localParticipant!.isCameraEnabled();
 | 
						|
      state = state.copyWith(isCameraEnabled: target);
 | 
						|
      await _localParticipant!.setCameraEnabled(target);
 | 
						|
      state = state.copyWith();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> toggleScreenShare(BuildContext context) async {
 | 
						|
    if (_localParticipant != null) {
 | 
						|
      final target = !_localParticipant!.isScreenShareEnabled();
 | 
						|
      state = state.copyWith(isScreenSharing: target);
 | 
						|
 | 
						|
      if (target && lk.lkPlatformIsDesktop()) {
 | 
						|
        try {
 | 
						|
          final source = await showDialog<DesktopCapturerSource>(
 | 
						|
            context: context,
 | 
						|
            builder: (context) => lk.ScreenSelectDialog(),
 | 
						|
          );
 | 
						|
          if (source == null) {
 | 
						|
            return;
 | 
						|
          }
 | 
						|
          var track = await lk.LocalVideoTrack.createScreenShareTrack(
 | 
						|
            lk.ScreenShareCaptureOptions(
 | 
						|
              sourceId: source.id,
 | 
						|
              maxFrameRate: 30.0,
 | 
						|
              captureScreenAudio: true,
 | 
						|
            ),
 | 
						|
          );
 | 
						|
          await _localParticipant!.publishVideoTrack(track);
 | 
						|
        } catch (err) {
 | 
						|
          showErrorAlert(err);
 | 
						|
        }
 | 
						|
        return;
 | 
						|
      } else {
 | 
						|
        await _localParticipant!.setScreenShareEnabled(target);
 | 
						|
      }
 | 
						|
 | 
						|
      state = state.copyWith();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> toggleSpeakerphone() async {
 | 
						|
    state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
 | 
						|
    await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
 | 
						|
    state = state.copyWith();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> disconnect() async {
 | 
						|
    if (_room != null) {
 | 
						|
      await _room!.disconnect();
 | 
						|
      state = state.copyWith(
 | 
						|
        isConnected: false,
 | 
						|
        isMicrophoneEnabled: false,
 | 
						|
        isCameraEnabled: false,
 | 
						|
        isScreenSharing: false,
 | 
						|
      );
 | 
						|
      // Disable wakelock when call disconnects
 | 
						|
      WakelockPlus.disable();
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void setParticipantVolume(CallParticipantLive live, double volume) {
 | 
						|
    if (participantsVolumes[live.remoteParticipant.sid] == null) {
 | 
						|
      participantsVolumes[live.remoteParticipant.sid] = 1;
 | 
						|
    }
 | 
						|
    Helper.setVolume(
 | 
						|
      volume,
 | 
						|
      live
 | 
						|
          .remoteParticipant
 | 
						|
          .audioTrackPublications
 | 
						|
          .first
 | 
						|
          .track!
 | 
						|
          .mediaStreamTrack,
 | 
						|
    );
 | 
						|
    participantsVolumes[live.remoteParticipant.sid] = volume;
 | 
						|
  }
 | 
						|
 | 
						|
  double getParticipantVolume(CallParticipantLive live) {
 | 
						|
    return participantsVolumes[live.remoteParticipant.sid] ?? 1;
 | 
						|
  }
 | 
						|
 | 
						|
  void dispose() {
 | 
						|
    state = state.copyWith(
 | 
						|
      error: null,
 | 
						|
      isConnected: false,
 | 
						|
      isMicrophoneEnabled: false,
 | 
						|
      isCameraEnabled: false,
 | 
						|
      isScreenSharing: false,
 | 
						|
    );
 | 
						|
    _roomListener?.dispose();
 | 
						|
    _room?.removeListener(_onRoomChange);
 | 
						|
    _room?.dispose();
 | 
						|
    _durationTimer?.cancel();
 | 
						|
    _roomId = null;
 | 
						|
    participantsVolumes = {};
 | 
						|
    // Disable wakelock when disposing
 | 
						|
    WakelockPlus.disable();
 | 
						|
  }
 | 
						|
}
 |