team-2/CHALLENGE_2/sleepysound/lib/services/network_group_service.dart
2025-08-02 07:51:04 +02:00

608 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:multicast_dns/multicast_dns.dart';
import 'music_queue_service.dart';
class NetworkUser {
final String id;
final String name;
final String ipAddress;
final DateTime joinedAt;
final int votes;
bool isOnline;
DateTime lastSeen;
String? currentTrackId;
String? currentTrackName;
String? currentArtist;
String? currentTrackImage;
bool isListening;
NetworkUser({
required this.id,
required this.name,
required this.ipAddress,
required this.joinedAt,
this.votes = 0,
this.isOnline = true,
DateTime? lastSeen,
this.currentTrackId,
this.currentTrackName,
this.currentArtist,
this.currentTrackImage,
this.isListening = false,
}) : lastSeen = lastSeen ?? DateTime.now();
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'ipAddress': ipAddress,
'joinedAt': joinedAt.toIso8601String(),
'votes': votes,
'isOnline': isOnline,
'lastSeen': lastSeen.toIso8601String(),
'currentTrackId': currentTrackId,
'currentTrackName': currentTrackName,
'currentArtist': currentArtist,
'currentTrackImage': currentTrackImage,
'isListening': isListening,
};
factory NetworkUser.fromJson(Map<String, dynamic> json) => NetworkUser(
id: json['id'],
name: json['name'],
ipAddress: json['ipAddress'],
joinedAt: DateTime.parse(json['joinedAt']),
votes: json['votes'] ?? 0,
isOnline: json['isOnline'] ?? true,
lastSeen: DateTime.parse(json['lastSeen']),
currentTrackId: json['currentTrackId'],
currentTrackName: json['currentTrackName'],
currentArtist: json['currentArtist'],
currentTrackImage: json['currentTrackImage'],
isListening: json['isListening'] ?? false,
);
}
class NetworkGroupService extends ChangeNotifier {
static const int _discoveryPort = 8089;
static const int _heartbeatInterval = 10; // seconds
final Connectivity _connectivity = Connectivity();
final NetworkInfo _networkInfo = NetworkInfo();
MDnsClient? _mdnsClient;
HttpServer? _httpServer;
Timer? _heartbeatTimer;
Timer? _discoveryTimer;
bool _isConnectedToWifi = false;
String _currentNetworkName = '';
String _currentNetworkSSID = '';
String _localIpAddress = '';
String _networkSubnet = '';
final Map<String, NetworkUser> _networkUsers = {};
late NetworkUser _currentUser;
MusicQueueService? _musicService;
// Getters
bool get isConnectedToWifi => _isConnectedToWifi;
String get currentNetworkName => _currentNetworkName;
String get currentNetworkSSID => _currentNetworkSSID;
String get localIpAddress => _localIpAddress;
List<NetworkUser> get networkUsers => _networkUsers.values.toList();
NetworkUser get currentUser => _currentUser;
int get onlineUsersCount => _networkUsers.values.where((u) => u.isOnline).length;
NetworkGroupService() {
_initializeCurrentUser();
_startNetworkMonitoring();
// Initialize music service reference
_musicService = MusicQueueService();
}
void _initializeCurrentUser() {
final random = Random();
final guestNames = ['Alex', 'Sarah', 'Marco', 'Lisa', 'Tom', 'Anna', 'David', 'Emma'];
final randomName = guestNames[random.nextInt(guestNames.length)];
final randomId = random.nextInt(999);
_currentUser = NetworkUser(
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
name: '$randomName #$randomId',
ipAddress: _localIpAddress,
joinedAt: DateTime.now(),
);
}
Future<void> _startNetworkMonitoring() async {
// Monitor connectivity changes
_connectivity.onConnectivityChanged.listen(_onConnectivityChanged);
// Initial connectivity check
await _checkConnectivity();
}
Future<void> _onConnectivityChanged(List<ConnectivityResult> results) async {
await _checkConnectivity();
}
Future<void> _checkConnectivity() async {
final connectivityResult = await _connectivity.checkConnectivity();
final wasConnected = _isConnectedToWifi;
_isConnectedToWifi = connectivityResult.contains(ConnectivityResult.wifi);
if (_isConnectedToWifi) {
await _getNetworkInfo();
if (!wasConnected) {
await _startNetworkServices();
}
} else {
if (wasConnected) {
await _stopNetworkServices();
}
_clearNetworkInfo();
}
notifyListeners();
}
Future<void> _getNetworkInfo() async {
try {
_currentNetworkSSID = await _networkInfo.getWifiName() ?? 'Unknown Network';
_currentNetworkName = _currentNetworkSSID.replaceAll('"', ''); // Remove quotes
_localIpAddress = await _networkInfo.getWifiIP() ?? '';
// Calculate network subnet (assuming /24)
if (_localIpAddress.isNotEmpty) {
final parts = _localIpAddress.split('.');
if (parts.length == 4) {
_networkSubnet = '${parts[0]}.${parts[1]}.${parts[2]}';
}
}
// Update current user's IP
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _localIpAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
);
} catch (e) {
if (kDebugMode) {
print('Error getting network info: $e');
}
}
}
void _clearNetworkInfo() {
_currentNetworkName = '';
_currentNetworkSSID = '';
_localIpAddress = '';
_networkSubnet = '';
_networkUsers.clear();
}
Future<void> _startNetworkServices() async {
if (!_isConnectedToWifi || _localIpAddress.isEmpty) return;
try {
// Start mDNS client for service discovery
_mdnsClient = MDnsClient();
await _mdnsClient!.start();
// Start HTTP server for peer communication
await _startHttpServer();
// Start announcing our service
await _announceService();
// Start discovering other users
_startDiscovery();
// Start heartbeat for keeping users online
_startHeartbeat();
// Add ourselves to the users list
_networkUsers[_currentUser.id] = _currentUser;
} catch (e) {
if (kDebugMode) {
print('Error starting network services: $e');
}
}
}
Future<void> _stopNetworkServices() async {
// Stop timers
_heartbeatTimer?.cancel();
_discoveryTimer?.cancel();
// Stop HTTP server
await _httpServer?.close();
_httpServer = null;
// Stop mDNS client
_mdnsClient?.stop();
_mdnsClient = null;
// Clear users
_networkUsers.clear();
}
Future<void> _startHttpServer() async {
try {
_httpServer = await HttpServer.bind(InternetAddress.anyIPv4, _discoveryPort);
_httpServer!.listen((request) async {
await _handleHttpRequest(request);
});
} catch (e) {
if (kDebugMode) {
print('Error starting HTTP server: $e');
}
}
}
Future<void> _handleHttpRequest(HttpRequest request) async {
try {
final response = request.response;
response.headers.set('Content-Type', 'application/json');
response.headers.set('Access-Control-Allow-Origin', '*');
if (request.method == 'GET' && request.uri.path == '/user') {
// Update current user with latest listening info before sending
await _updateCurrentUserListeningInfo();
// Return current user info
response.write(jsonEncode(_currentUser.toJson()));
} else if (request.method == 'POST' && request.uri.path == '/heartbeat') {
// Handle heartbeat from other users
final body = await utf8.decoder.bind(request).join();
final userData = jsonDecode(body);
final user = NetworkUser.fromJson(userData);
_networkUsers[user.id] = user;
notifyListeners();
response.write(jsonEncode({'status': 'ok'}));
} else if (request.method == 'POST' && request.uri.path == '/join-session') {
// Handle request to join this user's listening session
final body = await utf8.decoder.bind(request).join();
// Parse request data for future use (logging, analytics, etc.)
jsonDecode(body);
// Get current track info to send back
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo,
'message': 'Successfully joined listening session'
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} else if (request.method == 'GET' && request.uri.path == '/current-track') {
// Get current track info without joining
await _updateCurrentUserListeningInfo();
final currentTrackInfo = _musicService?.currentTrackInfo;
if (currentTrackInfo != null) {
response.write(jsonEncode({
'status': 'ok',
'trackInfo': currentTrackInfo
}));
} else {
response.write(jsonEncode({
'status': 'no_track',
'message': 'No track currently playing'
}));
}
} else {
response.statusCode = 404;
response.write(jsonEncode({'error': 'Not found'}));
}
await response.close();
} catch (e) {
if (kDebugMode) {
print('Error handling HTTP request: $e');
}
}
}
Future<void> _announceService() async {
if (_mdnsClient == null || _localIpAddress.isEmpty) return;
try {
// This would announce our service via mDNS
// In a real implementation, you'd use proper mDNS announcements
if (kDebugMode) {
print('Announcing service on $_localIpAddress:$_discoveryPort');
}
} catch (e) {
if (kDebugMode) {
print('Error announcing service: $e');
}
}
}
void _startDiscovery() {
_discoveryTimer = Timer.periodic(const Duration(seconds: 15), (_) async {
await _discoverUsers();
});
// Initial discovery
_discoverUsers();
}
Future<void> _discoverUsers() async {
if (_networkSubnet.isEmpty) return;
// Scan local network for other SleepySound users
final futures = <Future>[];
for (int i = 1; i <= 254; i++) {
final ip = '$_networkSubnet.$i';
if (ip != _localIpAddress) {
futures.add(_tryConnectToUser(ip));
}
}
// Wait for all connection attempts (with timeout)
await Future.wait(futures).timeout(
const Duration(seconds: 10),
onTimeout: () => [],
);
}
Future<void> _tryConnectToUser(String ip) async {
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 2);
final request = await client.getUrl(Uri.parse('http://$ip:$_discoveryPort/user'));
final response = await request.close().timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final body = await utf8.decoder.bind(response).join();
final userData = jsonDecode(body);
final user = NetworkUser.fromJson(userData);
if (user.id != _currentUser.id) {
_networkUsers[user.id] = user;
notifyListeners();
}
}
client.close();
} catch (e) {
// Ignore connection errors (expected for non-SleepySound devices)
}
}
void _startHeartbeat() {
_heartbeatTimer = Timer.periodic(const Duration(seconds: _heartbeatInterval), (_) async {
await _sendHeartbeat();
_cleanupOfflineUsers();
});
}
Future<void> _sendHeartbeat() async {
final heartbeatData = jsonEncode(_currentUser.toJson());
// Send heartbeat to all known users
final futures = _networkUsers.values
.where((user) => user.id != _currentUser.id && user.isOnline)
.map((user) => _sendHeartbeatToUser(user.ipAddress, heartbeatData));
await Future.wait(futures);
}
Future<void> _sendHeartbeatToUser(String ip, String data) async {
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 2);
final request = await client.postUrl(Uri.parse('http://$ip:$_discoveryPort/heartbeat'));
request.headers.set('Content-Type', 'application/json');
request.write(data);
await request.close().timeout(const Duration(seconds: 2));
client.close();
} catch (e) {
// Mark user as potentially offline
final user = _networkUsers.values.firstWhere(
(u) => u.ipAddress == ip,
orElse: () => NetworkUser(id: '', name: '', ipAddress: '', joinedAt: DateTime.now()),
);
if (user.id.isNotEmpty) {
user.isOnline = false;
notifyListeners();
}
}
}
void _cleanupOfflineUsers() {
final now = DateTime.now();
final usersToRemove = <String>[];
for (final user in _networkUsers.values) {
if (user.id != _currentUser.id) {
final timeSinceLastSeen = now.difference(user.lastSeen).inSeconds;
if (timeSinceLastSeen > _heartbeatInterval * 3) {
user.isOnline = false;
// Remove users that have been offline for more than 5 minutes
if (timeSinceLastSeen > 300) {
usersToRemove.add(user.id);
}
}
}
}
for (final userId in usersToRemove) {
_networkUsers.remove(userId);
}
if (usersToRemove.isNotEmpty) {
notifyListeners();
}
}
Future<void> _updateCurrentUserListeningInfo() async {
final currentTrackInfo = _musicService?.currentTrackInfo;
final currentTrack = _musicService?.currentTrack;
if (currentTrack != null && currentTrackInfo != null) {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
currentTrackId: currentTrack.id,
currentTrackName: currentTrack.name,
currentArtist: currentTrack.artistNames,
currentTrackImage: currentTrack.imageUrl,
isListening: currentTrackInfo['isPlaying'] ?? false,
);
} else {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
isOnline: true,
isListening: false,
);
}
_networkUsers[_currentUser.id] = _currentUser;
}
// Public methods for UI interaction
Future<void> refreshNetwork() async {
await _checkConnectivity();
if (_isConnectedToWifi) {
await _discoverUsers();
}
}
void updateUserVotes(int votes) {
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _currentUser.ipAddress,
joinedAt: _currentUser.joinedAt,
votes: votes,
isOnline: true,
);
_networkUsers[_currentUser.id] = _currentUser;
notifyListeners();
}
String getConnectionStatus() {
if (!_isConnectedToWifi) {
return 'Not connected to WiFi';
}
if (_currentNetworkName.isEmpty) {
return 'Connected to WiFi';
}
return 'Connected to $_currentNetworkName';
}
// Join another user's listening session
Future<bool> joinListeningSession(NetworkUser user) async {
if (!user.isListening || user.currentTrackId == null) {
return false;
}
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.postUrl(
Uri.parse('http://${user.ipAddress}:$_discoveryPort/join-session')
);
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'userId': _currentUser.id}));
final response = await request.close().timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final body = await utf8.decoder.bind(response).join();
final responseData = jsonDecode(body);
if (responseData['status'] == 'ok' && responseData['trackInfo'] != null) {
// Here you could sync the track with your local player
// For now, we'll just return success
return true;
}
}
client.close();
return false;
} catch (e) {
if (kDebugMode) {
print('Error joining listening session: $e');
}
return false;
}
}
// Demo methods for testing
void simulateNetworkConnection() {
_isConnectedToWifi = true;
_currentNetworkName = 'Demo WiFi Network';
_currentNetworkSSID = '"Demo WiFi Network"';
_localIpAddress = '192.168.1.100';
_networkSubnet = '192.168.1';
// Update current user with new IP
_currentUser = NetworkUser(
id: _currentUser.id,
name: _currentUser.name,
ipAddress: _localIpAddress,
joinedAt: _currentUser.joinedAt,
votes: _currentUser.votes,
);
_networkUsers[_currentUser.id] = _currentUser;
notifyListeners();
}
void clearDemoUsers() {
_networkUsers.clear();
_networkUsers[_currentUser.id] = _currentUser;
notifyListeners();
}
void addDemoUser(String name, int votes) {
final user = NetworkUser(
id: 'demo_${DateTime.now().millisecondsSinceEpoch}_${name.hashCode}',
name: name,
ipAddress: '192.168.1.${101 + _networkUsers.length}',
joinedAt: DateTime.now().subtract(Duration(minutes: (votes * 2))),
votes: votes,
isOnline: true,
);
_networkUsers[user.id] = user;
notifyListeners();
}
@override
void dispose() {
_stopNetworkServices();
super.dispose();
}
}