import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:onufitness/constants/api_enum_constant.dart'; import 'package:onufitness/constants/data_constant.dart'; import 'package:onufitness/controller/get_agora_token_controller.dart'; import 'package:onufitness/screens/streamming/controllers/get_api_live_streams_controller.dart'; import 'package:onufitness/services/local_storage_services/shared_services.dart'; import 'package:onufitness/services/logger_service.dart'; import 'package:onufitness/utils/custom_sneakbar.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:agora_chat_sdk/agora_chat_sdk.dart'; // Live Stream Controller----------------------------------------------------------------------- class LiveStreamController extends GetxController { final logger = LoggerService(); late RtcEngine _engine; get engine => _engine; // Timer related variables--------------------------------------------------------------------- Timer? _streamTimer; static const int _streamDurationMinutes = streamEndsTime; RxInt remainingSeconds = (_streamDurationMinutes * 60).obs; RxString timeDisplay = "${_streamDurationMinutes.toString().padLeft(2, '0')}:00".obs; // Observable variables------------------------------------------------------------------------ RxBool localUserJoined = false.obs; RxInt remoteUid = 0.obs; RxBool isHost = false.obs; RxBool isJoined = false.obs; RxList chatMessages = [].obs; RxBool showChat = true.obs; RxBool chatRoomJoined = false.obs; RxInt memberCount = 0.obs; // Controllers--------------------------------------------------------------------------------- final TextEditingController commentController = TextEditingController(); final ScrollController chatScrollController = ScrollController(); // Agora Configuration ------------------------------------------------------------------------- static const appId = agoraAppId; static const String chatAppKey = agoraChatAppKey; // Chat Room Configuration--------------------------------------------------------------------- RxString chatRoomId = "".obs; RxString token = "".obs; RxString channel = "".obs; // User info------------------------------------------------------------------------------------ RxString hostName = "".obs; RxString hostProfilePic = "".obs; RxString currentUserId = "".obs; RxString currentUserName = "".obs; RxString currentUserProfilePic = "".obs; // Add flag to track if engine needs reinitialization bool _engineNeedsReinit = false; //------------------------------------------------------------------------------------------------ @override void onInit() { super.onInit(); _initializeChatSDK(); _initializeAgoraVideoSDK(); } @override void onClose() { stopStreamTimer(); _cleanupEverything(); commentController.dispose(); chatScrollController.dispose(); super.onClose(); } // Start the 10-minute countdown timer................................................................. void _startStreamTimer() { stopStreamTimer(); remainingSeconds.value = _streamDurationMinutes * 60; updateTimeDisplay(); _streamTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (remainingSeconds.value > 0) { remainingSeconds.value--; updateTimeDisplay(); // Show warnings at specific intervals if (remainingSeconds.value == 300) { showTimeWarning("5 minutes remaining before spark auto-ends"); } else if (remainingSeconds.value == 180) { showTimeWarning("3 minutes remaining before spark auto-ends"); } else if (remainingSeconds.value == 60) { showTimeWarning("1 minute remaining before spark auto-ends"); } else if (remainingSeconds.value == 30) { showTimeWarning("30 seconds remaining before spark auto-ends"); } } else { timer.cancel(); autoEndStream(); } }); } // Stop the stream timer....................................................................................... void stopStreamTimer() { _streamTimer?.cancel(); _streamTimer = null; } // Update the time display format (MM:SS)...................................................................... void updateTimeDisplay() { int minutes = remainingSeconds.value ~/ 60; int seconds = remainingSeconds.value % 60; timeDisplay.value = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } // Show time warning to users.................................................................................. void showTimeWarning(String message) { customSnackbar(title: 'Spark Timer', message: message, duration: 3); // Add system message to chat addChatMessage( LiveChatMessage( userId: "system", username: "System", message: "⏰ $message", timestamp: DateTime.now(), isSystemMessage: true, ), ); } // Auto-end stream when timer expires.................................................................................. Future autoEndStream() async { try { // Show final warning customSnackbar( title: 'Spark Ended', message: 'Spark automatically ended after 10 minutes', duration: 5, ); // Add system message to chat addChatMessage( LiveChatMessage( userId: "system", username: "System", message: "⏰ Spark automatically ended after 10 minutes. Thank you for watching!", timestamp: DateTime.now(), isSystemMessage: true, ), ); // Wait a moment for the message to be sent await Future.delayed(const Duration(seconds: 2)); // End the stream await endStream(); // Make API call to end stream on backend if (!Get.isRegistered()) { Get.put(GetLiveStreamsController()); } final apiController = Get.find(); await apiController.endLiveStream(streamID: channel.value); } catch (e, stackTrace) { logger.error( "Failed to end stream or make API call to end stream on backend", error: e, stackTrace: stackTrace, ); } } // Initialize Agora Chat SDK------------------------------------------------------------------------- Future _initializeChatSDK() async { try { ChatOptions options = ChatOptions( appKey: chatAppKey, deleteMessagesAsExitChatRoom: false, ); await ChatClient.getInstance.init(options); await loginChatUser(); _setupChatEventHandlers(); } catch (e) { Get.snackbar('Chat Error', 'Failed to initialize chat: $e'); } } // Chat Login------------------------------------------------------------------------------------------------------------ Future loginChatUser() async { try { final loginDetails = SharedServices.getLoginDetails(); if (loginDetails?.data?.userId == null) { return false; } final tokenSuccess = SharedServices.getAgoraUserAndRtmTokens(); if (tokenSuccess?.data == null || tokenSuccess?.data?.agoraUserToken == null) { return false; } final userToken = tokenSuccess!.data!.agoraUserToken.toString(); final userId = loginDetails!.data!.userId.toString(); final agoraUserId = userId.replaceAll("-", ""); await ChatClient.getInstance.loginWithToken(agoraUserId, userToken); return true; } on ChatError catch (e) { return false; } } // Setup chat room event handlers------------------------------------------------------------------------- void _setupChatEventHandlers() { ChatClient.getInstance.chatRoomManager.addEventHandler( "chat_event_handeller", ChatRoomEventHandler( // When a new member joins the chat room onMemberJoinedFromChatRoom: (roomId, participant, ext) async { await updateMemberCount(); }, // When a member leaves the chat room onMemberExitedFromChatRoom: (roomId, roomName, participant) async { await updateMemberCount(); }, // When chat room is destroyed - UPDATED WITH STREAM ENDED MESSAGE onChatRoomDestroyed: (roomId, roomName) { addChatMessage( LiveChatMessage( userId: "system", username: "System", message: "🔴 The Spark has ended. Thank you for watching!", timestamp: DateTime.now(), isSystemMessage: true, ), ); chatRoomJoined.value = false; customSnackbar( title: 'Live Stream ended', message: 'The Spark has ended. Thank you for watching!', duration: 2, ); }, // When member count changes onSpecificationChanged: (room) async { await updateMemberCount(); }, ), ); // Setup message event handler for receiving messages ChatClient.getInstance.chatManager.addEventHandler( "receive_message_handler", ChatEventHandler( onMessagesReceived: (messages) { for (var message in messages) { if (message.chatType == ChatType.ChatRoom && message.conversationId == chatRoomId.value) { _handleReceivedMessage(message); } } }, ), ); } // Handle received chat messages------------------------------------------------------------------------- void _handleReceivedMessage(ChatMessage message) { String messageText = ""; String senderName = message.attributes!['fullName'] ?? "Unknown"; if (message.body.type == MessageType.TXT) { ChatTextMessageBody body = message.body as ChatTextMessageBody; messageText = body.content; } addChatMessage( LiveChatMessage( userId: message.from ?? "unknown", username: senderName, message: messageText, timestamp: DateTime.fromMillisecondsSinceEpoch(message.serverTime), isSystemMessage: false, ), ); } // Update member count------------------------------------------------------------------------- Future updateMemberCount() async { try { // ChatRoom? room = await ChatClient.getInstance.chatRoomManager // .getChatRoomWithId(chatRoomId.value); ChatRoom? room = await ChatClient.getInstance.chatRoomManager .fetchChatRoomInfoFromServer(chatRoomId.value); memberCount.value = room.memberCount ?? 0; } catch (e, stackTrace) { logger.error( "Failed to update member count from chat room", error: e, stackTrace: stackTrace, ); } } // Initialize the Agora RTC engine instance------------------------------------------------------------------------- Future _initializeAgoraVideoSDK() async { try { _engine = createAgoraRtcEngine(); await _engine.initialize( const RtcEngineContext( appId: appId, channelProfile: ChannelProfileType.channelProfileLiveBroadcasting, ), ); _setupEventHandlers(); await _requestPermissions(); _engineNeedsReinit = false; } catch (e) { Get.snackbar('Error', 'Failed to initialize Agora SDK: $e'); } } // Request microphone and camera permissions------------------------------------------------------------------------- Future _requestPermissions() async { if (Platform.isAndroid) { await [Permission.microphone, Permission.camera].request(); } } // Register event handlers for Agora RTC------------------------------------------------------------------------- void _setupEventHandlers() { _engine.registerEventHandler( RtcEngineEventHandler( onJoinChannelSuccess: (RtcConnection connection, int elapsed) async { localUserJoined.value = true; isJoined.value = true; // Start the 10-minute timer when successfully joined (only for host) if (isHost.value) { _startStreamTimer(); } // Join chat room after successfully joining video channel await joinChatRoom(); }, onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) { this.remoteUid.value = remoteUid; }, onUserOffline: ( RtcConnection connection, int remoteUid, UserOfflineReasonType reason, ) { this.remoteUid.value = 0; }, onError: (ErrorCodeType err, String msg) {}, ), ); } // Setup local video..................................................................... Future _setupLocalVideo() async { await _engine.enableVideo(); await _engine.startPreview(); } // Join channel as host..................................................................... Future joinAsHost() async { try { // Reinitialize engine if needed if (_engineNeedsReinit) { await _initializeAgoraVideoSDK(); } isHost.value = true; await _setupLocalVideo(); if (!Get.isRegistered()) { Get.put(AgoraTokenController()); } final agoraTokenController = Get.find(); // Generate a random 32-bit integer final random = Random(); final tempId = random.nextInt(4294967296); // 2^32 await agoraTokenController .getRTCtoken( channelName: channel.value, role: ApiEnum.SUBSCRIBER, tempId: tempId.toString(), ) .then((value) { if (value) { String tokenValue = agoraTokenController.rTCtoken.value; token.value = tokenValue; } }); // Enabling the chat.................................................................. chatRoomJoined.value = true; //...................................................................................... await _engine.joinChannel( token: token.value, channelId: channel.value, options: const ChannelMediaOptions( autoSubscribeVideo: true, autoSubscribeAudio: true, publishCameraTrack: true, publishMicrophoneTrack: true, clientRoleType: ClientRoleType.clientRoleBroadcaster, audienceLatencyLevel: AudienceLatencyLevelType.audienceLatencyLevelUltraLowLatency, ), uid: tempId, ); } catch (e, stackTrace) { logger.error( "Failed to join channel as broadcaster", error: e, stackTrace: stackTrace, ); } } // Join channel as audience..................................................................... Future joinAsAudience() async { // Reinitialize engine if needed if (_engineNeedsReinit) { await _initializeAgoraVideoSDK(); } if (!Get.isRegistered()) { Get.put(AgoraTokenController()); } final agoraTokenController = Get.find(); // Generate a random 32-bit integer final random = Random(); final tempId = random.nextInt(4294967296); // 2^32 await agoraTokenController .getRTCtoken( channelName: channel.value, role: ApiEnum.SUBSCRIBER, tempId: tempId.toString(), ) .then((value) { if (value) { String tokenValue = agoraTokenController.rTCtoken.value; token.value = tokenValue; } }); try { isHost.value = false; // Enabling the chat.................................................................. chatRoomJoined.value = true; //...................................................................................... await _engine.joinChannel( token: token.value, channelId: channel.value, options: const ChannelMediaOptions( autoSubscribeVideo: true, autoSubscribeAudio: true, publishCameraTrack: false, publishMicrophoneTrack: false, clientRoleType: ClientRoleType.clientRoleAudience, audienceLatencyLevel: AudienceLatencyLevelType.audienceLatencyLevelUltraLowLatency, ), uid: tempId, ); } catch (e, stackTrace) { logger.error( "Failed to join channel as audience", error: e, stackTrace: stackTrace, ); } } // End stream (host only)------------------------------------------------------------------------ Future endStream() async { try { // Stop the timer first stopStreamTimer(); // Step 1: Destroy chat room if host (this will kick out all members) if (isHost.value && chatRoomJoined.value) { try { await ChatClient.getInstance.chatRoomManager.destroyChatRoom( chatRoomId.value, ); } catch (e) { // If destroy fails, at least leave the room await _leaveChatRoom(); } } else { // If not host, just leave the chat room await _leaveChatRoom(); } // Step 2: Clean up video engine await _cleanupAgoraEngine(); // Step 3: Reset all state variables _resetStreamState(); // Step 4: Navigate back Get.back(); } catch (e, stackTrace) { logger.error( "Failed to end stream and cleanup", error: e, stackTrace: stackTrace, ); } } // Leave stream(For audience) -------------------------------------------------------------------------- Future leaveStream() async { try { // Stop timer if running (shouldn't be for audience, but just in case) stopStreamTimer(); // Send system message.............................. await sendJoinedAndLeaveMessage(false); // Leave chat room first.............................. await _leaveChatRoom(); // Clean up video engine.............................. await _cleanupAgoraEngine(); // Reset state.............................. _resetStreamState(); // Navigate back.............................. Get.back(); } catch (e, stackTrace) { logger.error( "Failed to leave stream and cleanup", error: e, stackTrace: stackTrace, ); } } // Reset stream state------------------------------------------------------------------------- void _resetStreamState() { // Reset observable variables localUserJoined.value = false; isJoined.value = false; remoteUid.value = 0; chatRoomJoined.value = false; memberCount.value = 0; // Reset timer state remainingSeconds.value = _streamDurationMinutes * 60; timeDisplay.value = "${_streamDurationMinutes.toString().padLeft(2, '0')}:00"; // Clear chat messages chatMessages.clear(); // Clear controllers commentController.clear(); // Reset tokens and channel info token.value = ""; // Don't reset channel.value as it might be needed for rejoining } // Cleanup Agora engine ------------------------------------------------------------------------- Future _cleanupAgoraEngine() async { try { if (isJoined.value) { await _engine.leaveChannel(); } await _engine.release(); _engineNeedsReinit = true; // Mark that engine needs reinitialization } catch (e) { _engineNeedsReinit = true; // Still mark for reinit even if cleanup failed } } // Complete cleanup - for onClose------------------------------------------------------------------------- Future _cleanupEverything() async { try { // First cleanup chat await _leaveChatRoom(); // Then cleanup video engine if (!_engineNeedsReinit) { await _cleanupAgoraEngine(); } // Remove all event handlers ChatClient.getInstance.chatRoomManager.removeEventHandler( "chat_event_handeller", ); ChatClient.getInstance.chatManager.removeEventHandler( "receive_message_handler", ); } catch (e, stackTrace) { logger.error( "Failed to cleanup Agora engine or remove event handlers", error: e, stackTrace: stackTrace, ); } } // Join chat room ------------------------------------------------------------------------- Future joinChatRoom() async { try { await ChatClient.getInstance.chatRoomManager.joinChatRoom( chatRoomId.value, leaveOtherRooms: false, ext: currentUserId.value, ); chatRoomJoined.value = true; await updateMemberCount(); // Add welcome message addChatMessage( LiveChatMessage( userId: "system", username: "System", message: "Welcome to the live chat! Stream will auto-end after 10 minutes.", timestamp: DateTime.now(), isSystemMessage: true, ), ); Future.delayed(Duration(milliseconds: 500), () async { await sendJoinedAndLeaveMessage(true); }); } catch (e, stackTrace) { logger.error( "Failed to join chat room or send welcome message", error: e, stackTrace: stackTrace, ); } } // Leave chat room - IMPROVED VERSION---------------------------------------------------------------------------------------- Future _leaveChatRoom() async { try { if (chatRoomJoined.value && chatRoomId.value.isNotEmpty) { await ChatClient.getInstance.chatRoomManager.leaveChatRoom( chatRoomId.value, ); chatRoomJoined.value = false; } } catch (e) { // Don't show error to user as this might happen during normal cleanup } } // Send comment to chat room------------------------------------------------------------------------- void sendComment() { if (commentController.text.trim().isNotEmpty && chatRoomJoined.value) { _sendChatMessage(commentController.text); commentController.clear(); } else if (!chatRoomJoined.value) { Get.snackbar('Chat Error', 'Not connected to chat room'); } } // Send message to chat room------------------------------------------------------------------------- Future _sendChatMessage(String messageText) async { try { // Create text message ChatMessage message = ChatMessage.createTxtSendMessage( targetId: chatRoomId.value, content: messageText, chatType: ChatType.ChatRoom, ); message.attributes = { "fullName": currentUserName.value, "profilePicture": currentUserProfilePic.value, }; // Send message await ChatClient.getInstance.chatManager.sendMessage(message); // Add to local chat immediately for better UX addChatMessage( LiveChatMessage( userId: currentUserId.value, username: 'You', message: messageText, timestamp: DateTime.now(), isSystemMessage: false, ), ); } catch (e) { Get.snackbar('Chat Error', 'Failed to send message: $e'); } } //.... Show System message When user Join the room and Left the Room................................................ Future sendJoinedAndLeaveMessage(bool isJoined) async { try { // Create text message ChatMessage message = ChatMessage.createTxtSendMessage( targetId: chatRoomId.value, content: isJoined ? "${currentUserName.value} Joined the chat" : "${currentUserName.value} left the chat", chatType: ChatType.ChatRoom, ); message.attributes = { "fullName": "System", "profilePicture": "", "isSystemMessage": true, }; // Send message await ChatClient.getInstance.chatManager.sendMessage(message); } catch (e) { Get.snackbar('Chat Error', 'Failed to send message: $e'); } } // Add chat message------------------------------------------------------------------------- void addChatMessage(LiveChatMessage message) { chatMessages.add(message); // Auto scroll to bottom WidgetsBinding.instance.addPostFrameCallback((_) { if (chatScrollController.hasClients) { chatScrollController.animateTo( chatScrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } // Toggle chat visibility------------------------------------------------------------------------- void toggleChat() { showChat.value = !showChat.value; } } // Live Chat Message Model------------------------------------------------------------------------- class LiveChatMessage { final String userId; final String username; final String message; final DateTime timestamp; final bool isSystemMessage; LiveChatMessage({ required this.userId, required this.username, required this.message, required this.timestamp, this.isSystemMessage = false, }); }