608 lines
18 KiB
Dart
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();
|
|
}
|
|
}
|