diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 0594a426..97299fb0 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -6,6 +6,14 @@ import '../models/plex_media_info.dart'; import '../models/plex_filter.dart'; import '../utils/app_logger.dart'; +/// Result of testing a connection, including success status and latency +class ConnectionTestResult { + final bool success; + final int latencyMs; + + ConnectionTestResult({required this.success, required this.latencyMs}); +} + class PlexClient { final PlexConfig config; late final Dio _dio; @@ -43,12 +51,28 @@ class PlexClient { } } - /// Test connection to a specific URL with token + /// Test connection to a specific URL with token (legacy method) static Future testConnectionUrl( String baseUrl, String token, { Duration timeout = const Duration(seconds: 5), }) async { + final result = await testConnectionWithLatency( + baseUrl, + token, + timeout: timeout, + ); + return result.success; + } + + /// Test connection to a specific URL with token and measure latency + static Future testConnectionWithLatency( + String baseUrl, + String token, { + Duration timeout = const Duration(seconds: 5), + }) async { + final stopwatch = Stopwatch()..start(); + try { final dio = Dio( BaseOptions( @@ -64,12 +88,57 @@ class PlexClient { options: Options(headers: {'X-Plex-Token': token}), ); - return response.statusCode == 200 || response.statusCode == 401; + stopwatch.stop(); + final success = response.statusCode == 200 || response.statusCode == 401; + + return ConnectionTestResult( + success: success, + latencyMs: stopwatch.elapsedMilliseconds, + ); } catch (e) { - return false; + stopwatch.stop(); + return ConnectionTestResult( + success: false, + latencyMs: stopwatch.elapsedMilliseconds, + ); } } + /// Test connection multiple times and return average latency + static Future testConnectionWithAverageLatency( + String baseUrl, + String token, { + int attempts = 3, + Duration timeout = const Duration(seconds: 5), + }) async { + final results = []; + + for (int i = 0; i < attempts; i++) { + final result = await testConnectionWithLatency( + baseUrl, + token, + timeout: timeout, + ); + + // If any attempt fails, return failed result immediately + if (!result.success) { + return ConnectionTestResult( + success: false, + latencyMs: result.latencyMs, + ); + } + + results.add(result); + } + + // Calculate average latency from successful attempts + final avgLatency = + results.fold(0, (sum, result) => sum + result.latencyMs) ~/ + results.length; + + return ConnectionTestResult(success: true, latencyMs: avgLatency); + } + /// Get server identity Future> getServerIdentity() async { final response = await _dio.get('/identity'); diff --git a/lib/main.dart b/lib/main.dart index 92f3c167..db4cc016 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,15 +3,13 @@ import 'package:flutter/services.dart'; import 'dart:io' show Platform; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; -import 'client/plex_client.dart'; -import 'config/plex_config.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'services/storage_service.dart'; import 'services/plex_auth_service.dart'; +import 'services/server_connection_service.dart'; import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; -import 'models/plex_user_profile.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; @@ -97,52 +95,37 @@ class _SetupScreenState extends State { // Recreate PlexServer from stored data final server = PlexServer.fromJson(serverData); - // Test connections to find best working one - final connection = await server.findBestWorkingConnection(); + // Connect using the optimized service + final result = await ServerConnectionService.connectToServer( + server, + clientIdentifier: clientId, + verifyServer: true, + fetchUserProfile: plexToken != null, + plexToken: plexToken, + ); - if (connection != null) { - // Update stored server URL with working connection - await storage.saveServerUrl(connection.uri); - - // Create client with working connection - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientId, - ); - final client = PlexClient(config); - - // Verify server is accessible - try { - await client.getServerIdentity(); - - // Fetch and cache user profile if we have a plex token - PlexUserProfile? userProfile; - if (plexToken != null) { - userProfile = await _fetchAndCacheUserProfile(plexToken); - } - - // Success! Navigate to main screen - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - MainScreen(client: client, userProfile: userProfile), + // Handle result + if (result.isSuccess) { + // Success! Navigate to main screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => MainScreen( + client: result.client!, + userProfile: result.userProfile, ), - ); - return; - } - } catch (e) { - // Server identity check failed - await storage.clearCredentials(); + ), + ); } + return; } else { - // No working connections found + // Connection failed, clear credentials await storage.clearCredentials(); } } catch (e) { // Error loading or testing server + appLogger.e('Error during auto-login', error: e); await storage.clearCredentials(); } } @@ -156,62 +139,6 @@ class _SetupScreenState extends State { } } - Future _fetchAndCacheUserProfile(String plexToken) async { - appLogger.d('Fetching user profile from Plex API'); - try { - final authService = await PlexAuthService.create(); - final profile = await authService.getUserProfile(plexToken); - - appLogger.i( - 'Successfully fetched user profile', - error: { - 'autoSelectAudio': profile.autoSelectAudio, - 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', - 'autoSelectSubtitle': profile.autoSelectSubtitle, - 'defaultSubtitleLanguage': - profile.defaultSubtitleLanguage ?? 'not set', - 'defaultSubtitleForced': profile.defaultSubtitleForced, - }, - ); - - // Cache the profile - final storage = await StorageService.getInstance(); - await storage.saveUserProfile(profile.toJson()); - appLogger.d('User profile cached locally'); - - return profile; - } catch (e) { - appLogger.w( - 'Failed to fetch user profile from API, attempting to load from cache', - error: e, - ); - - // Failed to fetch profile, try to load from cache - final storage = await StorageService.getInstance(); - final cachedProfile = storage.getUserProfile(); - if (cachedProfile != null) { - final profile = PlexUserProfile.fromJson(cachedProfile); - appLogger.i( - 'Loaded user profile from cache', - error: { - 'autoSelectAudio': profile.autoSelectAudio, - 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', - 'autoSelectSubtitle': profile.autoSelectSubtitle, - 'defaultSubtitleLanguage': - profile.defaultSubtitleLanguage ?? 'not set', - 'defaultSubtitleForced': profile.defaultSubtitleForced, - }, - ); - return profile; - } - - appLogger.w( - 'No cached user profile available, track selection will use defaults', - ); - return null; - } - } - @override Widget build(BuildContext context) { return const Scaffold( diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 1557a986..39cdb4b5 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; -import '../config/plex_config.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../services/storage_service.dart'; import '../services/plex_auth_service.dart'; +import '../services/server_connection_service.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/server_list_tile.dart'; @@ -229,34 +229,16 @@ class _DiscoverScreenState extends State with Refreshable { ); } - // Test connections to find best working one - final connection = await server.findBestWorkingConnection(); - - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } - - if (connection == null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No working connections found for this server'), - ), - ); - } - return; - } - - // Store server information - final storage = await StorageService.getInstance(); - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerAccessToken(server.accessToken); - // Get client identifier + final storage = await StorageService.getInstance(); final clientId = storage.getClientIdentifier(); + if (clientId == null) { + // Close loading dialog + if (mounted) { + Navigator.pop(context); + } + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Client identifier not found')), @@ -265,20 +247,35 @@ class _DiscoverScreenState extends State with Refreshable { return; } - // Create new client - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, + // Connect using the optimized service + final result = await ServerConnectionService.connectToServer( + server, clientIdentifier: clientId, ); - final client = PlexClient(config); - // Replace current screen with main screen (includes bottom nav) + // Close loading dialog if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => MainScreen(client: client)), - ); + Navigator.pop(context); + } + + // Handle result + if (result.isSuccess) { + // Replace current screen with main screen (includes bottom nav) + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => MainScreen(client: result.client!), + ), + ); + } + } else { + // Show error + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? 'Connection failed')), + ); + } } } diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 951cd7f6..0e5cf060 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; -import '../client/plex_client.dart'; -import '../config/plex_config.dart'; +import '../services/server_connection_service.dart'; import '../widgets/server_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; import 'main_screen.dart'; @@ -66,49 +65,41 @@ class _ServerSelectionScreenState extends State { ); } - // Test connections to find best working one - final connection = await server.findBestWorkingConnection(); + // Get client identifier + final storage = await StorageService.getInstance(); + final clientId = + storage.getClientIdentifier() ?? widget.authService.clientIdentifier; + + // Connect using the optimized service + final result = await ServerConnectionService.connectToServer( + server, + clientIdentifier: clientId, + plexToken: widget.plexToken, + ); // Close loading dialog if (mounted) { Navigator.pop(context); } - if (connection == null) { + // Handle result + if (result.isSuccess) { + // Navigate to main app if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No working connections found for this server'), + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => MainScreen(client: result.client!), ), ); } - return; - } - - // Store server information - final storage = await StorageService.getInstance(); - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerAccessToken(server.accessToken); - await storage.savePlexToken(widget.plexToken); - - // Get client identifier - final clientId = - storage.getClientIdentifier() ?? widget.authService.clientIdentifier; - - // Create client and navigate to main app - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientId, - ); - final client = PlexClient(config); - - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => MainScreen(client: client)), - ); + } else { + // Show error + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? 'Connection failed')), + ); + } } } diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 3c45a0a8..e5b181ac 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -281,9 +281,121 @@ class PlexServer { } /// Find the best working connection by testing them - /// Tests ALL connections simultaneously and returns the best working one + /// Returns a Stream that emits connections progressively: + /// 1. First emission: The first connection that responds successfully + /// 2. Second emission (optional): The best connection after latency testing /// Priority: local > remote > relay (from successful connections) - Future findBestWorkingConnection() async { + Stream findBestWorkingConnection() async* { + if (connections.isEmpty) return; + + // Phase 1: Race to find first working connection + final completer = Completer(); + PlexConnection? firstConnection; + int completedTests = 0; + + // Start testing all connections simultaneously + for (final connection in connections) { + PlexClient.testConnectionWithLatency(connection.uri, accessToken).then(( + result, + ) { + completedTests++; + + // If this is the first successful connection, emit it immediately + if (result.success && !completer.isCompleted) { + completer.complete(connection); + } + + // If all tests complete without success, complete with null + if (completedTests == connections.length && !completer.isCompleted) { + completer.complete(null); + } + }); + } + + // Wait for and emit the first successful connection + firstConnection = await completer.future; + if (firstConnection == null) { + return; // No working connections found + } + + yield firstConnection; + + // Phase 2: Continue testing in background to find best connection + // Test each connection 2-3 times and average the latency + final connectionResults = {}; + + await Future.wait( + connections.map((connection) async { + final result = await PlexClient.testConnectionWithAverageLatency( + connection.uri, + accessToken, + attempts: 2, + ); + + if (result.success) { + connectionResults[connection] = result; + } + }), + ); + + // If no connections succeeded, we're done + if (connectionResults.isEmpty) { + return; + } + + // Find the best connection considering both priority and latency + PlexConnection? bestConnection; + int bestLatency = double.maxFinite.toInt(); + + // Group connections by priority + final localConnections = connectionResults.entries + .where((e) => e.key.local && !e.key.relay) + .toList(); + final remoteConnections = connectionResults.entries + .where((e) => !e.key.local && !e.key.relay) + .toList(); + final relayConnections = connectionResults.entries + .where((e) => e.key.relay) + .toList(); + + // Find best local connection + for (final entry in localConnections) { + if (entry.value.latencyMs < bestLatency) { + bestLatency = entry.value.latencyMs; + bestConnection = entry.key; + } + } + + // If no local connection, find best remote connection + if (bestConnection == null) { + for (final entry in remoteConnections) { + if (entry.value.latencyMs < bestLatency) { + bestLatency = entry.value.latencyMs; + bestConnection = entry.key; + } + } + } + + // If no remote connection, find best relay connection + if (bestConnection == null) { + for (final entry in relayConnections) { + if (entry.value.latencyMs < bestLatency) { + bestLatency = entry.value.latencyMs; + bestConnection = entry.key; + } + } + } + + // Emit the best connection if it's different from the first one + if (bestConnection != null && bestConnection.uri != firstConnection.uri) { + yield bestConnection; + } + } + + /// Legacy method for backward compatibility - returns first working connection + /// For optimal performance, use findBestWorkingConnection() stream instead + @Deprecated('Use findBestWorkingConnection() stream for optimized connection') + Future findBestWorkingConnectionLegacy() async { if (connections.isEmpty) return null; // Test all connections simultaneously diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart new file mode 100644 index 00000000..ecf4d616 --- /dev/null +++ b/lib/services/server_connection_service.dart @@ -0,0 +1,184 @@ +import 'plex_auth_service.dart'; +import 'storage_service.dart'; +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../models/plex_user_profile.dart'; +import '../utils/app_logger.dart'; + +/// Result of a server connection attempt +class ServerConnectionResult { + final PlexClient? client; + final PlexUserProfile? userProfile; + final String? error; + + ServerConnectionResult({this.client, this.userProfile, this.error}); + + bool get isSuccess => client != null; +} + +/// Service for handling optimized server connections +/// Implements fast-first connection with background optimization +class ServerConnectionService { + /// Connect to a Plex server with optimized connection testing + /// + /// Returns immediately with first working connection, then continues + /// testing in background to find the optimal connection. + /// + /// Parameters: + /// - [server]: The PlexServer to connect to + /// - [clientIdentifier]: Client identifier for the PlexClient + /// - [plexToken]: Optional plex.tv token to save to storage + /// - [verifyServer]: Whether to verify server is accessible before returning + /// - [fetchUserProfile]: Whether to fetch and cache user profile + /// - [onProgress]: Callback for progress updates (e.g., show/hide loading) + static Future connectToServer( + PlexServer server, { + required String clientIdentifier, + String? plexToken, + bool verifyServer = false, + bool fetchUserProfile = false, + void Function(String message)? onProgress, + }) async { + PlexConnection? firstConnection; + final storage = await StorageService.getInstance(); + + try { + // Listen to the connection stream for progressive connection testing + await for (final connection in server.findBestWorkingConnection()) { + if (firstConnection == null) { + // First emission - use this connection immediately + firstConnection = connection; + + if (onProgress != null) { + onProgress('Connected to ${connection.displayType} endpoint'); + } + + // Save server information to storage + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); + + // Save plex token if provided + if (plexToken != null) { + await storage.savePlexToken(plexToken); + } + + // Create client with working connection + final config = await PlexConfig.create( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientIdentifier, + ); + final client = PlexClient(config); + + // Verify server is accessible if requested + if (verifyServer) { + try { + await client.getServerIdentity(); + } catch (e) { + appLogger.w('Server identity verification failed', error: e); + await storage.clearCredentials(); + return ServerConnectionResult( + error: 'Server is not accessible: $e', + ); + } + } + + // Fetch user profile if requested + PlexUserProfile? userProfile; + if (fetchUserProfile && plexToken != null) { + userProfile = await _fetchAndCacheUserProfile(plexToken); + } + + // Return success result + // Note: Stream continues in background to find better connection + return ServerConnectionResult( + client: client, + userProfile: userProfile, + ); + } else { + // Second emission - better connection found + // Update stored connection seamlessly for future app launches + await storage.saveServerUrl(connection.uri); + appLogger.d( + 'Switched to better connection: ${connection.displayType} (${connection.uri})', + ); + } + } + + // Handle case where no connections were found + if (firstConnection == null) { + return ServerConnectionResult( + error: 'No working connections found for this server', + ); + } + + // Should never reach here due to return in the stream loop + return ServerConnectionResult( + error: 'Unexpected error in connection flow', + ); + } catch (e) { + appLogger.e('Error connecting to server', error: e); + return ServerConnectionResult(error: 'Connection failed: $e'); + } + } + + /// Fetch user profile from Plex API and cache it locally + static Future _fetchAndCacheUserProfile( + String plexToken, + ) async { + appLogger.d('Fetching user profile from Plex API'); + try { + final authService = await PlexAuthService.create(); + final profile = await authService.getUserProfile(plexToken); + + appLogger.i( + 'Successfully fetched user profile', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + + // Cache the profile + final storage = await StorageService.getInstance(); + await storage.saveUserProfile(profile.toJson()); + appLogger.d('User profile cached locally'); + + return profile; + } catch (e) { + appLogger.w( + 'Failed to fetch user profile from API, attempting to load from cache', + error: e, + ); + + // Failed to fetch profile, try to load from cache + final storage = await StorageService.getInstance(); + final cachedProfile = storage.getUserProfile(); + if (cachedProfile != null) { + final profile = PlexUserProfile.fromJson(cachedProfile); + appLogger.i( + 'Loaded user profile from cache', + error: { + 'autoSelectAudio': profile.autoSelectAudio, + 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', + 'autoSelectSubtitle': profile.autoSelectSubtitle, + 'defaultSubtitleLanguage': + profile.defaultSubtitleLanguage ?? 'not set', + 'defaultSubtitleForced': profile.defaultSubtitleForced, + }, + ); + return profile; + } + + appLogger.w( + 'No cached user profile available, track selection will use defaults', + ); + return null; + } + } +}