diff --git a/.gitignore b/.gitignore index 79c113f9..85e9078c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Code duplication reports +duplication-report/ + +# Test credentials (do not commit!) +.env.test diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 00000000..b75490d2 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,21 @@ +{ + "threshold": 5, + "reporters": ["html", "console"], + "ignore": [ + "**/*.g.dart", + "**/*.freezed.dart", + "**/*.mocks.dart", + "**/generated/**", + "**/*.config.dart" + ], + "absolute": true, + "gitignore": true, + "format": ["dart"], + "minLines": 5, + "minTokens": 50, + "output": "./duplication-report", + "mode": "strict", + "formatsExts": { + "dart": ["dart"] + } +} diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index ef34f714..bc65cd89 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -15,7 +15,7 @@ class ConnectionTestResult { } class PlexClient { - final PlexConfig config; + PlexConfig config; late final Dio _dio; PlexClient(this.config) { @@ -41,6 +41,14 @@ class PlexClient { ); } + /// Update the token used by this client + void updateToken(String newToken) { + // Update both the Dio headers and the config to ensure consistency + _dio.options.headers['X-Plex-Token'] = newToken; + config = config.copyWith(token: newToken); + appLogger.d('PlexClient token updated (headers and config)'); + } + /// Test connection to server Future testConnection() async { try { diff --git a/lib/main.dart b/lib/main.dart index 3ab38ffc..799e4e71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ 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 'package:provider/provider.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'services/storage_service.dart'; @@ -10,8 +11,11 @@ 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 'providers/user_profile_provider.dart'; +import 'providers/plex_client_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; +import 'utils/provider_extensions.dart'; import 'theme/mono_theme.dart'; void main() async { @@ -55,13 +59,21 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Plezy', - debugShowCheckedModeBanner: false, - theme: monoTheme(dark: false), - darkTheme: monoTheme(dark: true), - navigatorObservers: [routeObserver], - home: const SetupScreen(), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => PlexClientProvider()), + ChangeNotifierProvider( + create: (context) => UserProfileProvider()..initialize(), + ), + ], + child: MaterialApp( + title: 'Plezy', + debugShowCheckedModeBanner: false, + theme: monoTheme(dark: false), + darkTheme: monoTheme(dark: true), + navigatorObservers: [routeObserver], + home: const SetupScreen(), + ), ); } } @@ -88,14 +100,37 @@ class _SetupScreenState extends State { final clientId = storage.getClientIdentifier(); final plexToken = storage.getPlexToken(); + // Get current user's server token (prioritize over original server token) + final currentUserToken = storage.getToken(); + if (serverData != null && clientId != null) { try { // Recreate PlexServer from stored data final server = PlexServer.fromJson(serverData); - // Connect using the optimized service + // Use current user's token if available, fallback to server's original token + final tokenToUse = currentUserToken ?? server.accessToken; + + appLogger.d( + 'App startup token selection: currentUserToken=${currentUserToken != null ? 'present' : 'null'}, using=${currentUserToken != null ? 'current user' : 'original server'} token', + ); + + // Create updated server with correct token for current user + final serverWithCurrentToken = PlexServer( + name: server.name, + clientIdentifier: server.clientIdentifier, + accessToken: tokenToUse, + connections: server.connections, + owned: server.owned, + product: server.product, + platform: server.platform, + lastSeenAt: server.lastSeenAt, + presence: server.presence, + ); + + // Connect using the optimized service with current user's token final result = await ServerConnectionService.connectToServer( - server, + serverWithCurrentToken, clientIdentifier: clientId, verifyServer: true, fetchUserProfile: plexToken != null, @@ -104,8 +139,10 @@ class _SetupScreenState extends State { // Handle result if (result.isSuccess) { - // Success! Navigate to main screen + // Success! Set client in provider and navigate to main screen if (mounted) { + context.plexClient.setClient(result.client!); + Navigator.pushReplacement( context, MaterialPageRoute( diff --git a/lib/models/plex_home.dart b/lib/models/plex_home.dart new file mode 100644 index 00000000..99a200e5 --- /dev/null +++ b/lib/models/plex_home.dart @@ -0,0 +1,70 @@ +import 'plex_home_user.dart'; + +class PlexHome { + final int id; + final String name; + final int guestUserID; + final String guestUserUUID; + final bool guestEnabled; + final bool subscription; + final List users; + + PlexHome({ + required this.id, + required this.name, + required this.guestUserID, + required this.guestUserUUID, + required this.guestEnabled, + required this.subscription, + required this.users, + }); + + factory PlexHome.fromJson(Map json) { + final List usersJson = json['users'] as List; + final users = usersJson + .map( + (userJson) => PlexHomeUser.fromJson(userJson as Map), + ) + .toList(); + + return PlexHome( + id: json['id'] as int, + name: json['name'] as String, + guestUserID: json['guestUserID'] as int, + guestUserUUID: json['guestUserUUID'] as String, + guestEnabled: json['guestEnabled'] as bool, + subscription: json['subscription'] as bool, + users: users, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'guestUserID': guestUserID, + 'guestUserUUID': guestUserUUID, + 'guestEnabled': guestEnabled, + 'subscription': subscription, + 'users': users.map((user) => user.toJson()).toList(), + }; + } + + PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull; + + List get managedUsers => + users.where((user) => !user.admin).toList(); + + List get restrictedUsers => + users.where((user) => user.restricted).toList(); + + PlexHomeUser? getUserByUUID(String uuid) { + try { + return users.firstWhere((user) => user.uuid == uuid); + } catch (e) { + return null; + } + } + + bool get hasMultipleUsers => users.length > 1; +} diff --git a/lib/models/plex_home_user.dart b/lib/models/plex_home_user.dart new file mode 100644 index 00000000..4efabb27 --- /dev/null +++ b/lib/models/plex_home_user.dart @@ -0,0 +1,74 @@ +class PlexHomeUser { + final int id; + final String uuid; + final String title; + final String? username; + final String? email; + final String? friendlyName; + final String thumb; + final bool hasPassword; + final bool restricted; + final int updatedAt; + final bool admin; + final bool guest; + final bool protected; + + PlexHomeUser({ + required this.id, + required this.uuid, + required this.title, + this.username, + this.email, + this.friendlyName, + required this.thumb, + required this.hasPassword, + required this.restricted, + required this.updatedAt, + required this.admin, + required this.guest, + required this.protected, + }); + + factory PlexHomeUser.fromJson(Map json) { + return PlexHomeUser( + id: json['id'] as int, + uuid: json['uuid'] as String, + title: json['title'] as String, + username: json['username'] as String?, + email: json['email'] as String?, + friendlyName: json['friendlyName'] as String?, + thumb: json['thumb'] as String, + hasPassword: json['hasPassword'] as bool, + restricted: json['restricted'] as bool, + updatedAt: json['updatedAt'] as int, + admin: json['admin'] as bool, + guest: json['guest'] as bool, + protected: json['protected'] as bool, + ); + } + + Map toJson() { + return { + 'id': id, + 'uuid': uuid, + 'title': title, + 'username': username, + 'email': email, + 'friendlyName': friendlyName, + 'thumb': thumb, + 'hasPassword': hasPassword, + 'restricted': restricted, + 'updatedAt': updatedAt, + 'admin': admin, + 'guest': guest, + 'protected': protected, + }; + } + + String get displayName => friendlyName ?? title; + + bool get isAdminUser => admin; + bool get isRestrictedUser => restricted; + bool get isGuestUser => guest; + bool get requiresPassword => hasPassword; +} diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart new file mode 100644 index 00000000..78d0bb1f --- /dev/null +++ b/lib/models/user_switch_response.dart @@ -0,0 +1,137 @@ +import 'plex_user_profile.dart'; + +class UserSwitchResponse { + final int id; + final String uuid; + final String username; + final String title; + final String email; + final String? friendlyName; + final String? locale; + final bool confirmed; + final int joinedAt; + final bool emailOnlyAuth; + final bool hasPassword; + final bool protected; + final String thumb; + final String authToken; + final bool? mailingListActive; + final String scrobbleTypes; + final String country; + final bool restricted; + final bool? anonymous; + final bool home; + final bool guest; + final int homeSize; + final bool homeAdmin; + final int maxHomeSize; + final PlexUserProfile profile; + final bool twoFactorEnabled; + final bool backupCodesCreated; + final String? attributionPartner; + + UserSwitchResponse({ + required this.id, + required this.uuid, + required this.username, + required this.title, + required this.email, + this.friendlyName, + this.locale, + required this.confirmed, + required this.joinedAt, + required this.emailOnlyAuth, + required this.hasPassword, + required this.protected, + required this.thumb, + required this.authToken, + this.mailingListActive, + required this.scrobbleTypes, + required this.country, + required this.restricted, + this.anonymous, + required this.home, + required this.guest, + required this.homeSize, + required this.homeAdmin, + required this.maxHomeSize, + required this.profile, + required this.twoFactorEnabled, + required this.backupCodesCreated, + this.attributionPartner, + }); + + factory UserSwitchResponse.fromJson(Map json) { + return UserSwitchResponse( + id: json['id'] as int, + uuid: json['uuid'] as String, + username: json['username'] as String? ?? '', + title: json['title'] as String, + email: json['email'] as String? ?? '', + friendlyName: json['friendlyName'] as String?, + locale: json['locale'] as String?, + confirmed: json['confirmed'] as bool, + joinedAt: json['joinedAt'] as int, + emailOnlyAuth: json['emailOnlyAuth'] as bool, + hasPassword: json['hasPassword'] as bool, + protected: json['protected'] as bool, + thumb: json['thumb'] as String, + authToken: json['authToken'] as String, + mailingListActive: json['mailingListActive'] as bool?, + scrobbleTypes: json['scrobbleTypes'] as String? ?? '', + country: json['country'] as String? ?? '', + restricted: json['restricted'] as bool, + anonymous: json['anonymous'] as bool?, + home: json['home'] as bool, + guest: json['guest'] as bool, + homeSize: json['homeSize'] as int, + homeAdmin: json['homeAdmin'] as bool, + maxHomeSize: json['maxHomeSize'] as int, + profile: PlexUserProfile.fromJson(json), + twoFactorEnabled: json['twoFactorEnabled'] as bool, + backupCodesCreated: json['backupCodesCreated'] as bool, + attributionPartner: json['attributionPartner'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'uuid': uuid, + 'username': username, + 'title': title, + 'email': email, + 'friendlyName': friendlyName, + 'locale': locale, + 'confirmed': confirmed, + 'joinedAt': joinedAt, + 'emailOnlyAuth': emailOnlyAuth, + 'hasPassword': hasPassword, + 'protected': protected, + 'thumb': thumb, + 'authToken': authToken, + 'mailingListActive': mailingListActive, + 'scrobbleTypes': scrobbleTypes, + 'country': country, + 'restricted': restricted, + 'anonymous': anonymous, + 'home': home, + 'guest': guest, + 'homeSize': homeSize, + 'homeAdmin': homeAdmin, + 'maxHomeSize': maxHomeSize, + 'profile': profile.toJson()['profile'], + 'twoFactorEnabled': twoFactorEnabled, + 'backupCodesCreated': backupCodesCreated, + 'attributionPartner': attributionPartner, + }; + } + + String get displayName => friendlyName ?? title; + + bool get isAdminUser => homeAdmin; + bool get isRestrictedUser => restricted; + bool get isGuestUser => guest; + bool get requiresPassword => hasPassword; + bool get isSecureUser => twoFactorEnabled || backupCodesCreated; +} diff --git a/lib/providers/plex_client_provider.dart b/lib/providers/plex_client_provider.dart new file mode 100644 index 00000000..99c9aaa0 --- /dev/null +++ b/lib/providers/plex_client_provider.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import '../client/plex_client.dart'; +import '../utils/app_logger.dart'; + +class PlexClientProvider extends ChangeNotifier { + PlexClient? _client; + + PlexClient? get client => _client; + + void setClient(PlexClient client) { + _client = client; + appLogger.d('PlexClientProvider: Client set'); + notifyListeners(); + } + + void updateToken(String newToken) { + if (_client != null) { + _client!.updateToken(newToken); + appLogger.d('PlexClientProvider: Token updated'); + notifyListeners(); + } else { + appLogger.w('PlexClientProvider: Cannot update token - no client set'); + } + } + + void clearClient() { + _client = null; + appLogger.d('PlexClientProvider: Client cleared'); + notifyListeners(); + } +} diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart new file mode 100644 index 00000000..3a58505a --- /dev/null +++ b/lib/providers/user_profile_provider.dart @@ -0,0 +1,434 @@ +import 'package:flutter/material.dart'; +import '../models/plex_home.dart'; +import '../models/plex_home_user.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/provider_extensions.dart'; +import 'plex_client_provider.dart'; + +class UserProfileProvider extends ChangeNotifier { + PlexHome? _home; + PlexHomeUser? _currentUser; + bool _isLoading = false; + String? _error; + + PlexHome? get home => _home; + PlexHomeUser? get currentUser => _currentUser; + bool get isLoading => _isLoading; + String? get error => _error; + bool get hasMultipleUsers { + final result = _home?.hasMultipleUsers ?? false; + appLogger.d( + 'hasMultipleUsers: _home=${_home != null}, users count=${_home?.users.length ?? 0}, result=$result', + ); + return result; + } + + PlexAuthService? _authService; + StorageService? _storageService; + + // Callback for data invalidation when switching profiles + VoidCallback? _onDataInvalidationRequested; + + /// Set a callback to be called when profile switching requires data invalidation + void setDataInvalidationCallback(VoidCallback? callback) { + _onDataInvalidationRequested = callback; + } + + /// Trigger data invalidation for all screens + void _invalidateAllData() { + if (_onDataInvalidationRequested != null) { + _onDataInvalidationRequested!(); + appLogger.d('Data invalidation triggered for profile switch'); + } + } + + Future initialize() async { + appLogger.d('UserProfileProvider: Initializing...'); + try { + _authService = await PlexAuthService.create(); + _storageService = await StorageService.getInstance(); + await _loadCachedData(); + + // If no cached home data or it's expired, try to load from API + if (_home == null) { + appLogger.d( + 'UserProfileProvider: No cached home data, attempting to load from API', + ); + try { + await loadHomeUsers(); + } catch (e) { + appLogger.w( + 'UserProfileProvider: Failed to load home users during initialization', + error: e, + ); + // Don't set error here as it's not critical for app startup + } + } + appLogger.d('UserProfileProvider: Initialization complete'); + } catch (e) { + appLogger.e( + 'UserProfileProvider: Critical initialization failure', + error: e, + ); + _setError('Failed to initialize profile services'); + // Ensure services are null on failure + _authService = null; + _storageService = null; + } + } + + Future _loadCachedData() async { + if (_storageService == null) return; + + // Load cached home users + final cachedHomeData = _storageService!.getHomeUsersCache(); + if (cachedHomeData != null) { + try { + _home = PlexHome.fromJson(cachedHomeData); + } catch (e) { + appLogger.w('Failed to load cached home data', error: e); + } + } + + // Load current user UUID + final currentUserUUID = _storageService!.getCurrentUserUUID(); + if (currentUserUUID != null && _home != null) { + _currentUser = _home!.getUserByUUID(currentUserUUID); + } + + notifyListeners(); + } + + Future loadHomeUsers({bool forceRefresh = false}) async { + appLogger.d('loadHomeUsers called - forceRefresh: $forceRefresh'); + + // Auto-initialize services if not ready + if (_authService == null || _storageService == null) { + appLogger.d( + 'loadHomeUsers: Services not initialized, initializing services...', + ); + _authService = await PlexAuthService.create(); + _storageService = await StorageService.getInstance(); + await _loadCachedData(); + + // Double-check after initialization + if (_authService == null || _storageService == null) { + appLogger.e('loadHomeUsers: Failed to initialize services'); + _setError('Failed to initialize services'); + return; + } + } + + // Use cached data if available and not forcing refresh + if (!forceRefresh && _home != null) { + appLogger.d( + 'loadHomeUsers: Using cached data, users count: ${_home!.users.length}', + ); + return; + } + + _setLoading(true); + _clearError(); + + try { + final currentToken = _storageService!.getPlexToken(); + if (currentToken == null) { + throw Exception('No Plex.tv authentication token available'); + } + appLogger.d('loadHomeUsers: Using Plex.tv token'); + + appLogger.d('loadHomeUsers: Fetching home users from API'); + final home = await _authService!.getHomeUsers(currentToken); + _home = home; + + appLogger.i( + 'loadHomeUsers: Success! Home users count: ${home.users.length}', + ); + appLogger.d( + 'loadHomeUsers: Users: ${home.users.map((u) => u.displayName).join(', ')}', + ); + + // Cache the home data + await _storageService!.saveHomeUsersCache(home.toJson()); + + // Set current user if not already set + if (_currentUser == null) { + final currentUserUUID = _storageService!.getCurrentUserUUID(); + if (currentUserUUID != null) { + _currentUser = home.getUserByUUID(currentUserUUID); + appLogger.d( + 'loadHomeUsers: Set current user from UUID: ${_currentUser?.displayName}', + ); + } else { + // Default to admin user if no current user set + _currentUser = home.adminUser; + if (_currentUser != null) { + await _storageService!.saveCurrentUserUUID(_currentUser!.uuid); + appLogger.d( + 'loadHomeUsers: Set current user to admin: ${_currentUser?.displayName}', + ); + } + } + } + + notifyListeners(); + } catch (e) { + _setError('Failed to load home users: $e'); + appLogger.e('Failed to load home users', error: e); + } finally { + _setLoading(false); + } + } + + Future switchToUser(PlexHomeUser user, BuildContext? context) async { + if (_authService == null || _storageService == null) { + _setError('Services not initialized'); + return false; + } + + if (user.uuid == _currentUser?.uuid) { + // Already on this user + return true; + } + + // Extract client provider before async operations + PlexClientProvider? clientProvider; + if (context != null) { + try { + clientProvider = context.plexClient; + } catch (e) { + appLogger.w('Failed to get PlexClientProvider', error: e); + } + } + + _setLoading(true); + _clearError(); + + try { + final currentToken = _storageService!.getPlexToken(); + if (currentToken == null) { + throw Exception('No Plex.tv authentication token available'); + } + + final switchResponse = await _authService!.switchToUser( + user.uuid, + currentToken, + ); + + // switchResponse.authToken is the new user's Plex.tv token + // We need to fetch servers with this token to get the proper server access token + appLogger.d('Got new user Plex.tv token, fetching servers...'); + + final servers = await _authService!.fetchServers( + switchResponse.authToken, + ); + if (servers.isEmpty) { + throw Exception('No servers available for this user'); + } + + // Find the current server from storage + final currentServerData = _storageService!.getServerData(); + if (currentServerData == null) { + throw Exception('No current server data found'); + } + + // Find matching server by name or client identifier + final currentServerName = currentServerData['name'] as String?; + final currentServerClientId = + currentServerData['clientIdentifier'] as String?; + + final matchingServer = servers.firstWhere( + (server) => + server.name == currentServerName || + server.clientIdentifier == currentServerClientId, + orElse: () => servers.first, // Fallback to first server + ); + + appLogger.d( + 'Found matching server: ${matchingServer.name}, getting access token', + ); + + // Update storage with new tokens + await _storageService!.updateCurrentUser( + user.uuid, + matchingServer + .accessToken, // Use server access token, not Plex.tv token + ); + + // Also save the new Plex.tv token for future profile operations + await _storageService!.savePlexToken(switchResponse.authToken); + + // Update current user + _currentUser = user; + + // Save user profile + await _storageService!.saveUserProfile(switchResponse.profile.toJson()); + + // Update PlexClient with proper server access token + if (clientProvider != null) { + try { + clientProvider.updateToken(matchingServer.accessToken); + appLogger.d( + 'Updated PlexClient with server access token for user: ${user.displayName}', + ); + } catch (e) { + appLogger.w('Failed to update PlexClient token', error: e); + } + } + + notifyListeners(); + + // Invalidate all cached data for the new profile + // This will cause screens to refresh and rebuild widgets with the new token + _invalidateAllData(); + + appLogger.d( + 'Profile switch complete, all data and images should refresh with new token', + ); + + appLogger.i('Successfully switched to user: ${user.displayName}'); + return true; + } catch (e) { + _setError('Failed to switch user: $e'); + appLogger.e('Failed to switch to user: ${user.displayName}', error: e); + return false; + } finally { + _setLoading(false); + } + } + + Future refreshCurrentUser() async { + if (_currentUser != null) { + await loadHomeUsers(forceRefresh: true); + + // Update current user from refreshed data + if (_home != null) { + _currentUser = _home!.getUserByUUID(_currentUser!.uuid); + notifyListeners(); + } + } + } + + Future logout() async { + if (_storageService == null) return; + + _setLoading(true); + + try { + await _storageService!.clearUserData(); + _home = null; + _currentUser = null; + _clearError(); + notifyListeners(); + + appLogger.i('User logged out successfully'); + } catch (e) { + appLogger.e('Error during logout', error: e); + } finally { + _setLoading(false); + } + } + + /// Refresh provider for new server context + /// Call this when switching servers to ensure provider state is synchronized + Future refreshForNewServer([BuildContext? context]) async { + appLogger.d('UserProfileProvider: Refreshing for new server context'); + + _setLoading(true); + + try { + // Clear cached data from previous server (both memory and storage) + _home = null; + _currentUser = null; + _clearError(); + + // Re-initialize services with current storage state + _authService = await PlexAuthService.create(); + _storageService = await StorageService.getInstance(); + + // Clear storage state that's specific to the previous server context + await Future.wait([ + // Clear home users cache (server-specific) + _storageService!.clearHomeUsersCache(), + // Clear current user UUID (profile-specific, should not persist across servers) + _storageService!.clearCurrentUserUUID(), + ]); + + appLogger.d('UserProfileProvider: Cleared previous server storage state'); + + // Load fresh data for the new server (should be empty after clearing cache) + await _loadCachedData(); + + // Load from API since we cleared the cache + appLogger.d( + 'UserProfileProvider: Loading fresh home users for new server', + ); + + // Store context reference before async operations to avoid build context warnings + final contextForSwitch = context; + + try { + await loadHomeUsers(); + + // After loading home users, if a current user was set (admin user), + // perform a complete profile switch to ensure tokens are properly updated + if (_currentUser != null && contextForSwitch != null) { + appLogger.d( + 'UserProfileProvider: Performing complete profile switch to ${_currentUser!.displayName} for new server', + ); + + // Perform full profile switch which includes API calls and token updates + final userToSwitchTo = _currentUser!; + // ignore: use_build_context_synchronously + final success = await switchToUser(userToSwitchTo, contextForSwitch); + + if (success) { + appLogger.d( + 'UserProfileProvider: Successfully switched to admin user for new server', + ); + } else { + appLogger.w( + 'UserProfileProvider: Failed to complete profile switch for new server', + ); + } + } else if (_currentUser != null && contextForSwitch == null) { + appLogger.w( + 'UserProfileProvider: Cannot perform complete profile switch - no context provided', + ); + } + } catch (e) { + appLogger.w( + 'UserProfileProvider: Failed to load home users for new server', + error: e, + ); + // Don't set error as it's not critical + } + + appLogger.d('UserProfileProvider: Refresh for new server complete'); + } catch (e) { + appLogger.e( + 'UserProfileProvider: Failed to refresh for new server', + error: e, + ); + _setError('Failed to refresh for new server'); + } finally { + _setLoading(false); + } + } + + void _setLoading(bool loading) { + _isLoading = loading; + notifyListeners(); + } + + void _setError(String error) { + _error = error; + notifyListeners(); + } + + void _clearError() { + _error = null; + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 864bcd20..6b46fee2 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,34 +1,31 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.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'; +import '../widgets/user_avatar_widget.dart'; +import 'profile_switch_screen.dart'; +import 'server_selection_screen.dart'; +import '../providers/user_profile_provider.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; +import '../utils/provider_extensions.dart'; import 'video_player_screen.dart'; -import 'main_screen.dart'; -import 'about_screen.dart'; import 'auth_screen.dart'; class DiscoverScreen extends StatefulWidget { - final PlexClient client; final PlexUserProfile? userProfile; final VoidCallback? onBecameVisible; - const DiscoverScreen({ - super.key, - required this.client, - this.userProfile, - this.onBecameVisible, - }); + const DiscoverScreen({super.key, this.userProfile, this.onBecameVisible}); @override State createState() => _DiscoverScreenState(); @@ -37,7 +34,7 @@ class DiscoverScreen extends StatefulWidget { class _DiscoverScreenState extends State with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { @override - PlexClient get client => widget.client; + PlexClient get client => context.clientSafe; List _onDeck = []; List _recentlyAdded = []; @@ -101,8 +98,14 @@ class _DiscoverScreenState extends State try { appLogger.d('Fetching onDeck and recentlyAdded from Plex'); - final onDeck = await widget.client.getOnDeck(); - final recentlyAdded = await widget.client.getRecentlyAdded(limit: 20); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final onDeck = await client.getOnDeck(); + final recentlyAdded = await client.getRecentlyAdded(limit: 20); appLogger.d( 'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items', @@ -163,155 +166,25 @@ class _DiscoverScreenState extends State return; } - // Show loading dialog - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading servers...'), - ], - ), - ), - ); - } - try { - // Fetch available servers final authService = await PlexAuthService.create(); - final servers = await authService.fetchServers(plexToken); - // Close loading dialog + // Navigate to server selection screen if (mounted) { - Navigator.pop(context); - } - - if (servers.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('No servers found'))); - } - return; - } - - // Show server selection dialog - if (mounted) { - final selectedServer = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Switch Server'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: servers.length, - itemBuilder: (context, index) { - final server = servers[index]; - return ServerListTile( - server: server, - onTap: () => Navigator.pop(context, server), - showTrailingIcon: false, - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ], - ), - ); - - if (selectedServer != null) { - await _connectToServer(selectedServer); - } - } - } catch (e) { - // Close loading dialog if still open - if (mounted) { - Navigator.pop(context); - } - - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to load servers: $e'))); - } - } - } - - Future _connectToServer(PlexServer server) async { - // Show loading dialog - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Testing connections...'), - ], - ), - ), - ); - } - - // 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')), - ); - } - return; - } - - // Connect using the optimized service - final result = await ServerConnectionService.connectToServer( - server, - clientIdentifier: clientId, - ); - - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } - - // Handle result - if (result.isSuccess) { - // Replace current screen with main screen (includes bottom nav) - if (mounted) { - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( - builder: (context) => MainScreen(client: result.client!), + builder: (context) => ServerSelectionScreen( + authService: authService, + plexToken: plexToken, + ), ), ); } - } else { - // Show error + } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result.error ?? 'Connection failed')), + SnackBar(content: Text('Failed to initialize server selection: $e')), ); } } @@ -349,6 +222,13 @@ class _DiscoverScreenState extends State } } + void _handleSwitchProfile(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -369,54 +249,61 @@ class _DiscoverScreenState extends State icon: const Icon(Icons.refresh), onPressed: _loadContent, ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - if (value == 'switch_server') { - _handleSwitchServer(); - } else if (value == 'logout') { - _handleLogout(); - } else if (value == 'about') { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AboutScreen(), + Consumer( + builder: (context, userProvider, child) { + return PopupMenuButton( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget( + user: userProvider.currentUser!, + size: 32, + showIndicators: false, + ) + : const Icon(Icons.account_circle, size: 32), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'switch_server') { + _handleSwitchServer(); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + // Only show Switch Profile if multiple users available + if (userProvider.hasMultipleUsers) + const PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + Icon(Icons.people), + SizedBox(width: 8), + Text('Switch Profile'), + ], + ), + ), + const PopupMenuItem( + value: 'switch_server', + child: Row( + children: [ + Icon(Icons.swap_horiz), + SizedBox(width: 8), + Text('Switch Server'), + ], + ), ), - ); - } + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('Logout'), + ], + ), + ), + ], + ); }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'switch_server', - child: Row( - children: [ - Icon(Icons.swap_horiz), - SizedBox(width: 8), - Text('Switch Server'), - ], - ), - ), - const PopupMenuItem( - value: 'about', - child: Row( - children: [ - Icon(Icons.info_outline), - SizedBox(width: 8), - Text('About'), - ], - ), - ), - const PopupMenuItem( - value: 'logout', - child: Row( - children: [ - Icon(Icons.logout), - SizedBox(width: 8), - Text('Logout'), - ], - ), - ), - ], ), ], ), @@ -617,12 +504,15 @@ class _DiscoverScreenState extends State return GestureDetector( onTap: () { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); Navigator.push( context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: heroItem, userProfile: widget.userProfile, ), @@ -669,21 +559,33 @@ class _DiscoverScreenState extends State child: Opacity(opacity: value, child: child), ); }, - child: CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - heroItem.art ?? heroItem.grandparentArt, - ), - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + heroItem.art ?? heroItem.grandparentArt, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ); + }, ), ), ) @@ -728,72 +630,82 @@ class _DiscoverScreenState extends State SizedBox( height: 120, width: 400, - child: CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - heroItem.clearLogo, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: isLargeScreen - ? Alignment.bottomLeft - : Alignment.bottomCenter, - placeholder: (context, url) => Align( - alignment: isLargeScreen - ? Alignment.centerLeft - : Alignment.center, - child: Text( - showName, - style: Theme.of(context).textTheme.displaySmall - ?.copyWith( - color: Colors.white.withValues( - alpha: 0.3, - ), - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, - ), - ), - errorWidget: (context, url, error) { - // Fallback to text if logo fails to load - return Align( - alignment: isLargeScreen - ? Alignment.centerLeft - : Alignment.center, - child: Text( - showName, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container(); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + heroItem.clearLogo, ), + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: isLargeScreen + ? Alignment.bottomLeft + : Alignment.bottomCenter, + placeholder: (context, url) => Align( + alignment: isLargeScreen + ? Alignment.centerLeft + : Alignment.center, + child: Text( + showName, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, + ), + ), + errorWidget: (context, url, error) { + // Fallback to text if logo fails to load + return Align( + alignment: isLargeScreen + ? Alignment.centerLeft + : Alignment.center, + child: Text( + showName, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, + ), + ); + }, ); }, ), @@ -920,12 +832,15 @@ class _DiscoverScreenState extends State return InkWell( onTap: () { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + appLogger.d('Playing: ${heroItem.title}'); Navigator.push( context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: heroItem, userProfile: widget.userProfile, ), @@ -1023,7 +938,6 @@ class _DiscoverScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 2), child: MediaCard( key: Key(item.ratingKey), - client: widget.client, item: item, width: cardWidth, height: cardHeight, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index d2f64683..04089ab7 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; import '../models/plex_filter.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; @@ -13,10 +16,9 @@ import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; class LibrariesScreen extends StatefulWidget { - final PlexClient client; final PlexUserProfile? userProfile; - const LibrariesScreen({super.key, required this.client, this.userProfile}); + const LibrariesScreen({super.key, this.userProfile}); @override State createState() => _LibrariesScreenState(); @@ -25,7 +27,7 @@ class LibrariesScreen extends StatefulWidget { class _LibrariesScreenState extends State with Refreshable, ItemUpdatable { @override - PlexClient get client => widget.client; + PlexClient get client => context.clientSafe; List _libraries = []; List _items = []; @@ -50,7 +52,16 @@ class _LibrariesScreenState extends State }); try { - final libraries = await widget.client.getLibraries(); + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final libraries = await client.getLibraries(); setState(() { _libraries = libraries; _isLoadingLibraries = false; @@ -88,6 +99,17 @@ class _LibrariesScreenState extends State final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index; + // Extract context dependencies before async operations + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + setState(() { + _errorMessage = 'No client available'; + _isLoadingItems = false; + }); + return; + } + setState(() { _selectedLibraryIndex = index; _isLoadingItems = true; @@ -117,7 +139,7 @@ class _LibrariesScreenState extends State _loadFilters(index); // Load content - final items = await widget.client.getLibraryContent( + final items = await client.getLibraryContent( _libraries[index].key, filters: _selectedFilters, ); @@ -137,9 +159,16 @@ class _LibrariesScreenState extends State if (index < 0 || index >= _libraries.length) return; try { - final filters = await widget.client.getLibraryFilters( - _libraries[index].key, + final clientProvider = Provider.of( + context, + listen: false, ); + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final filters = await client.getLibraryFilters(_libraries[index].key); setState(() { _filters = filters; }); @@ -157,7 +186,16 @@ class _LibrariesScreenState extends State }); try { - final items = await widget.client.getLibraryContent( + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final items = await client.getLibraryContent( _libraries[_selectedLibraryIndex].key, filters: _selectedFilters, ); @@ -196,7 +234,6 @@ class _LibrariesScreenState extends State builder: (context) => _FiltersBottomSheet( filters: _filters, selectedFilters: _selectedFilters, - client: widget.client, onFiltersChanged: (filters) async { setState(() { _selectedFilters.clear(); @@ -394,7 +431,6 @@ class _LibrariesScreenState extends State final item = _items[index]; return MediaCard( key: Key(item.ratingKey), - client: widget.client, item: item, onRefresh: updateItem, userProfile: widget.userProfile, @@ -427,13 +463,11 @@ class _LibrariesScreenState extends State class _FiltersBottomSheet extends StatefulWidget { final List filters; final Map selectedFilters; - final PlexClient client; final Function(Map) onFiltersChanged; const _FiltersBottomSheet({ required this.filters, required this.selectedFilters, - required this.client, required this.onFiltersChanged, }); @@ -480,7 +514,16 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { }); try { - final values = await widget.client.getFilterValues(filter.key); + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final values = await client.getFilterValues(filter.key); setState(() { _filterValues = values; _isLoadingValues = false; diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index d9ee0cc4..8b15322c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; import '../utils/app_logger.dart'; +import '../utils/provider_extensions.dart'; import '../main.dart'; import '../mixins/refreshable.dart'; import 'discover_screen.dart'; @@ -23,6 +24,8 @@ class _MainScreenState extends State with RouteAware { late final List _screens; final GlobalKey> _discoverKey = GlobalKey(); + final GlobalKey> _librariesKey = GlobalKey(); + final GlobalKey> _searchKey = GlobalKey(); @override void initState() { @@ -31,13 +34,20 @@ class _MainScreenState extends State with RouteAware { _screens = [ DiscoverScreen( key: _discoverKey, - client: widget.client, userProfile: widget.userProfile, onBecameVisible: _onDiscoverBecameVisible, ), - LibrariesScreen(client: widget.client, userProfile: widget.userProfile), - SearchScreen(client: widget.client, userProfile: widget.userProfile), + LibrariesScreen(key: _librariesKey, userProfile: widget.userProfile), + SearchScreen(key: _searchKey, userProfile: widget.userProfile), ]; + + // Set up data invalidation callback for profile switching + WidgetsBinding.instance.addPostFrameCallback((_) { + context.userProfile.setDataInvalidationCallback(_invalidateAllScreens); + + // Set the client in the provider so profile switching can update its token + context.plexClient.setClient(widget.client); + }); } @override @@ -77,6 +87,29 @@ class _MainScreenState extends State with RouteAware { } } + /// Invalidate all cached data across all screens when profile is switched + void _invalidateAllScreens() { + appLogger.d('Invalidating all screen data due to profile switch'); + + // Refresh discover screen + final discoverState = _discoverKey.currentState; + if (discoverState != null && discoverState is Refreshable) { + (discoverState as Refreshable).refresh(); + } + + // Refresh libraries screen + final librariesState = _librariesKey.currentState; + if (librariesState != null && librariesState is Refreshable) { + (librariesState as Refreshable).refresh(); + } + + // Refresh search screen + final searchState = _searchKey.currentState; + if (searchState != null && searchState is Refreshable) { + (searchState as Refreshable).refresh(); + } + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 583a90bc..796a1b5e 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,24 +1,24 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import '../client/plex_client.dart'; +import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/media_context_menu.dart'; import '../utils/app_logger.dart'; +import '../utils/provider_extensions.dart'; import '../theme/theme_helper.dart'; import 'season_detail_screen.dart'; import 'video_player_screen.dart'; class MediaDetailScreen extends StatefulWidget { - final PlexClient client; final PlexMetadata metadata; final PlexUserProfile? userProfile; const MediaDetailScreen({ super.key, - required this.client, required this.metadata, this.userProfile, }); @@ -55,8 +55,14 @@ class _MediaDetailScreenState extends State { }); try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + // Fetch full metadata with clearLogo and OnDeck episode - final result = await widget.client.getMetadataWithImagesAndOnDeck( + final result = await client.getMetadataWithImagesAndOnDeck( widget.metadata.ratingKey, ); final metadata = result['metadata'] as PlexMetadata?; @@ -104,9 +110,13 @@ class _MediaDetailScreenState extends State { }); try { - final seasons = await widget.client.getChildren( - widget.metadata.ratingKey, - ); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final seasons = await client.getChildren(widget.metadata.ratingKey); setState(() { _seasons = seasons; _isLoadingSeasons = false; @@ -122,7 +132,13 @@ class _MediaDetailScreenState extends State { /// This preserves scroll position and only updates watch-related data Future _updateWatchState() async { try { - final metadata = await widget.client.getMetadataWithImages( + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final metadata = await client.getMetadataWithImages( widget.metadata.ratingKey, ); @@ -130,9 +146,7 @@ class _MediaDetailScreenState extends State { // For shows, also refetch seasons to update their watch counts List? updatedSeasons; if (metadata.type.toLowerCase() == 'show') { - updatedSeasons = await widget.client.getChildren( - widget.metadata.ratingKey, - ); + updatedSeasons = await client.getChildren(widget.metadata.ratingKey); } // Single setState to minimize rebuilds - scroll position is preserved by controller @@ -151,6 +165,13 @@ class _MediaDetailScreenState extends State { Future _playFirstEpisode() async { try { + // Extract context dependencies before async operations + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + // If seasons aren't loaded yet, wait for them or load them if (_seasons.isEmpty && !_isLoadingSeasons) { await _loadSeasons(); @@ -174,7 +195,7 @@ class _MediaDetailScreenState extends State { final firstSeason = _seasons.first; // Get episodes of the first season - final episodes = await widget.client.getChildren(firstSeason.ratingKey); + final episodes = await client.getChildren(firstSeason.ratingKey); if (episodes.isEmpty) { if (mounted) { @@ -188,12 +209,15 @@ class _MediaDetailScreenState extends State { // Play the first episode final firstEpisode = episodes.first; if (mounted) { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + appLogger.d('Playing first episode: ${firstEpisode.title}'); await Navigator.push( context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: firstEpisode, userProfile: widget.userProfile, ), @@ -249,19 +273,31 @@ class _MediaDetailScreenState extends State { children: [ // Background Art if (metadata.art != null) - CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl(metadata.art), - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(metadata.art), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ); + }, ) else Container( @@ -303,42 +339,11 @@ class _MediaDetailScreenState extends State { SizedBox( height: 120, width: 400, - child: CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - metadata.clearLogo, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - placeholder: (context, url) => Align( - alignment: Alignment.centerLeft, - child: Text( - metadata.title, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white.withValues( - alpha: 0.3, - ), - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - errorWidget: (context, url, error) { - return Align( - alignment: Alignment.centerLeft, - child: Text( + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Text( metadata.title, style: Theme.of(context) .textTheme @@ -346,17 +351,66 @@ class _MediaDetailScreenState extends State { ?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black - .withValues(alpha: 0.5), - blurRadius: 8, - ), - ], ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + metadata.clearLogo, ), + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white.withValues( + alpha: 0.3, + ), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + errorWidget: (context, url, error) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, ); }, ), @@ -481,6 +535,10 @@ class _MediaDetailScreenState extends State { // Otherwise, play the first episode of the first season if (metadata.type.toLowerCase() == 'show') { if (_onDeckEpisode != null) { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + appLogger.d( 'Playing on deck episode: ${_onDeckEpisode!.title}', ); @@ -488,7 +546,6 @@ class _MediaDetailScreenState extends State { context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: _onDeckEpisode!, userProfile: widget.userProfile, ), @@ -504,13 +561,16 @@ class _MediaDetailScreenState extends State { await _playFirstEpisode(); } } else { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + appLogger.d('Playing: ${metadata.title}'); // For movies or episodes, play directly await Navigator.push( context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: metadata, userProfile: widget.userProfile, ), @@ -540,9 +600,11 @@ class _MediaDetailScreenState extends State { IconButton.filledTonal( onPressed: () async { try { - await widget.client.markAsWatched( - metadata.ratingKey, - ); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await client.markAsWatched(metadata.ratingKey); if (context.mounted) { _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( @@ -573,9 +635,11 @@ class _MediaDetailScreenState extends State { IconButton.filledTonal( onPressed: () async { try { - await widget.client.markAsUnwatched( - metadata.ratingKey, - ); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await client.markAsUnwatched(metadata.ratingKey); if (context.mounted) { _watchStateChanged = true; ScaffoldMessenger.of(context).showSnackBar( @@ -691,7 +755,6 @@ class _MediaDetailScreenState extends State { return Card( clipBehavior: Clip.antiAlias, child: MediaContextMenu( - client: widget.client, metadata: season, onRefresh: (ratingKey) { _watchStateChanged = true; @@ -701,8 +764,7 @@ class _MediaDetailScreenState extends State { final watchStateChanged = await Navigator.push( context, MaterialPageRoute( - builder: (context) => - SeasonDetailScreen(client: widget.client, season: season), + builder: (context) => SeasonDetailScreen(season: season), ), ); if (watchStateChanged == true) { @@ -719,26 +781,41 @@ class _MediaDetailScreenState extends State { if (season.thumb != null) ClipRRect( borderRadius: BorderRadius.circular(6), - child: CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl(season.thumb), - width: 80, - height: 120, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(season.thumb), + width: 80, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ); + }, ), ) else @@ -785,7 +862,8 @@ class _MediaDetailScreenState extends State { borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: - season.viewedLeafCount! / season.leafCount!, + season.viewedLeafCount! / + season.leafCount!, backgroundColor: tokens(context).outline, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, diff --git a/lib/screens/profile_switch_screen.dart b/lib/screens/profile_switch_screen.dart new file mode 100644 index 00000000..08a4af8f --- /dev/null +++ b/lib/screens/profile_switch_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_home_user.dart'; +import '../providers/user_profile_provider.dart'; +import '../utils/provider_extensions.dart'; +import '../widgets/profile_list_tile.dart'; +import '../widgets/desktop_app_bar.dart'; + +class ProfileSwitchScreen extends StatelessWidget { + const ProfileSwitchScreen({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + const DesktopSliverAppBar(title: Text('Switch Profile')), + SliverFillRemaining( + child: Consumer( + builder: (context, userProvider, child) { + final users = userProvider.home?.users ?? []; + + if (userProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (userProvider.error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + userProvider.error!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + userProvider.refreshCurrentUser(); + }, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (users.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_off, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No profiles available', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Contact your Plex administrator to add profiles', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: users.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final user = users[index]; + final isCurrentUser = + user.uuid == userProvider.currentUser?.uuid; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ProfileListTile( + user: user, + isCurrentUser: isCurrentUser, + onTap: () => _switchToUser(context, user), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } + + void _switchToUser(BuildContext context, PlexHomeUser user) async { + final userProvider = context.userProfile; + final success = await userProvider.switchToUser(user, context); + + if (success && context.mounted) { + Navigator.of(context).pop(); + } else if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to switch to ${user.displayName}'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 99afe512..4faa7ecd 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -3,16 +3,16 @@ import 'package:flutter/material.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; class SearchScreen extends StatefulWidget { - final PlexClient client; final PlexUserProfile? userProfile; - const SearchScreen({super.key, required this.client, this.userProfile}); + const SearchScreen({super.key, this.userProfile}); @override State createState() => _SearchScreenState(); @@ -21,7 +21,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State with Refreshable, ItemUpdatable { @override - PlexClient get client => widget.client; + PlexClient get client => context.clientSafe; final _searchController = TextEditingController(); List _searchResults = []; @@ -86,7 +86,13 @@ class _SearchScreenState extends State }); try { - final results = await widget.client.search(query); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final results = await client.search(query); if (mounted) { setState(() { _searchResults = results; @@ -220,7 +226,6 @@ class _SearchScreenState extends State final item = _searchResults[index]; return MediaCard( key: Key(item.ratingKey), - client: widget.client, item: item, onRefresh: updateItem, userProfile: widget.userProfile, diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 6e7332d1..8501c59f 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/media_context_menu.dart'; @@ -11,16 +14,10 @@ import '../theme/theme_helper.dart'; import 'video_player_screen.dart'; class SeasonDetailScreen extends StatefulWidget { - final PlexClient client; final PlexMetadata season; final PlexUserProfile? userProfile; - const SeasonDetailScreen({ - super.key, - required this.client, - required this.season, - this.userProfile, - }); + const SeasonDetailScreen({super.key, required this.season, this.userProfile}); @override State createState() => _SeasonDetailScreenState(); @@ -29,7 +26,7 @@ class SeasonDetailScreen extends StatefulWidget { class _SeasonDetailScreenState extends State with ItemUpdatable { @override - PlexClient get client => widget.client; + PlexClient get client => context.clientSafe; List _episodes = []; bool _isLoadingEpisodes = false; @@ -47,7 +44,13 @@ class _SeasonDetailScreenState extends State }); try { - final episodes = await widget.client.getChildren(widget.season.ratingKey); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final episodes = await client.getChildren(widget.season.ratingKey); setState(() { _episodes = episodes; _isLoadingEpisodes = false; @@ -104,9 +107,7 @@ class _SeasonDetailScreenState extends State const SizedBox(height: 16), Text( 'No episodes found', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith( + style: Theme.of(context).textTheme.titleLarge?.copyWith( color: tokens(context).textMuted, ), ), @@ -136,7 +137,6 @@ class _SeasonDetailScreenState extends State : 0.0; return MediaContextMenu( - client: widget.client, metadata: episode, onRefresh: updateItem, onTap: () async { @@ -144,7 +144,6 @@ class _SeasonDetailScreenState extends State context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: episode, userProfile: widget.userProfile, ), @@ -155,14 +154,13 @@ class _SeasonDetailScreenState extends State }, child: InkWell( key: Key(episode.ratingKey), - hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05), + hoverColor: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.05), child: Container( decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: tokens(context).outline, - width: 0.5, - ), + bottom: BorderSide(color: tokens(context).outline, width: 0.5), ), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), @@ -179,23 +177,42 @@ class _SeasonDetailScreenState extends State child: AspectRatio( aspectRatio: 16 / 9, child: episode.thumb != null - ? CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl( - episode.thumb, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ), + ? Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Center( + child: Icon(Icons.movie, size: 40), + ), + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + episode.thumb, + ), + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon( + Icons.movie, + size: 32, + ), + ), + ); + }, ) : Container( color: Theme.of( @@ -327,28 +344,31 @@ class _SeasonDetailScreenState extends State if (episode.duration != null) Text( _formatDuration(episode.duration!), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), ), if (episode.duration != null && episode.isWatched) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( '•', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), ), ), Text( 'Watched ✓', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), ), ], ], diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 0e5cf060..d7e7605a 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -4,6 +4,7 @@ import '../services/storage_service.dart'; import '../services/server_connection_service.dart'; import '../widgets/server_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; +import '../utils/app_logger.dart'; import 'main_screen.dart'; class ServerSelectionScreen extends StatefulWidget { @@ -24,13 +25,26 @@ class _ServerSelectionScreenState extends State { List? _servers; bool _isLoading = true; String? _errorMessage; + String? _currentServerUrl; @override void initState() { super.initState(); + _loadCurrentServerUrl(); _loadServers(); } + Future _loadCurrentServerUrl() async { + try { + final storage = await StorageService.getInstance(); + setState(() { + _currentServerUrl = storage.getServerUrl(); + }); + } catch (e) { + appLogger.w('Failed to load current server URL', error: e); + } + } + Future _loadServers() async { try { final servers = await widget.authService.fetchServers(widget.plexToken); @@ -58,92 +72,167 @@ class _ServerSelectionScreenState extends State { children: [ CircularProgressIndicator(), SizedBox(height: 16), - Text('Testing connections...'), + Text('Connecting to server...'), ], ), ), ); } - // Get client identifier - final storage = await StorageService.getInstance(); - final clientId = - storage.getClientIdentifier() ?? widget.authService.clientIdentifier; + try { + // Get client identifier and storage + 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, - ); + // Get current profile context to maintain profile across server switch + final currentUserUUID = storage.getCurrentUserUUID(); - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } + PlexServer serverWithCorrectToken = server; - // Handle result - if (result.isSuccess) { - // Navigate to main app - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => MainScreen(client: result.client!), - ), - ); + // If we have a current profile, get a profile-specific token for this server + if (currentUserUUID != null) { + try { + // Switch to the current profile on the new server to get the correct token + final switchResponse = await widget.authService.switchToUser( + currentUserUUID, + widget.plexToken, + ); + + // Get servers with the profile's Plex.tv token to get profile-specific server tokens + final servers = await widget.authService.fetchServers( + switchResponse.authToken, + ); + + // Find the matching server with the profile-specific token + final matchingServer = servers.firstWhere( + (s) => + s.name == server.name || + s.clientIdentifier == server.clientIdentifier, + orElse: () => server, // Fallback to original server + ); + + serverWithCorrectToken = matchingServer; + appLogger.d( + 'Got profile-specific token for server ${server.name} and user UUID $currentUserUUID', + ); + } catch (e) { + appLogger.w( + 'Failed to get profile-specific token, using default server token', + error: e, + ); + // Continue with original server token as fallback + } } - } else { - // Show error + + // Connect using the server with the correct token + final result = await ServerConnectionService.connectToServer( + serverWithCorrectToken, + clientIdentifier: clientId, + plexToken: widget.plexToken, + ); + + // Close loading dialog if (mounted) { + Navigator.pop(context); + } + + // Handle result + if (result.isSuccess) { + // Navigate to main app and clear navigation stack + if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => MainScreen(client: result.client!), + ), + (route) => false, // Remove all routes + ); + } + } else { + // Show error + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.error ?? 'Connection failed')), + ); + } + } + } catch (e) { + // Close loading dialog on error + if (mounted) { + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result.error ?? 'Connection failed')), + SnackBar(content: Text('Failed to connect to server: $e')), ); } + appLogger.e('Server selection failed', error: e); } } + bool _isCurrentServer(PlexServer server) { + if (_currentServerUrl == null) return false; + + // Check all server connections to see if any match the current server URL + for (final connection in server.connections) { + if (connection.uri == _currentServerUrl) { + return true; + } + } + return false; + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: const DesktopAppBar(title: Text('Select Server')), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _errorMessage != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + body: CustomScrollView( + slivers: [ + const DesktopSliverAppBar(title: Text('Select Server')), + SliverFillRemaining( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadServers, + child: const Text('Retry'), + ), + ], ), - textAlign: TextAlign.center, + ) + : _servers == null || _servers!.isEmpty + ? const Center(child: Text('No servers found')) + : ListView.builder( + itemCount: _servers!.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final server = _servers![index]; + final isCurrentServer = _isCurrentServer(server); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ServerListTile( + server: server, + isCurrentServer: isCurrentServer, + onTap: () => _selectServer(server), + ), + ), + ); + }, ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadServers, - child: const Text('Retry'), - ), - ], - ), - ) - : _servers == null || _servers!.isEmpty - ? const Center(child: Text('No servers found')) - : ListView.builder( - itemCount: _servers!.length, - padding: const EdgeInsets.all(16), - itemBuilder: (context, index) { - final server = _servers![index]; - return Card( - child: ServerListTile( - server: server, - onTap: () => _selectServer(server), - ), - ); - }, - ), + ), + ], + ), ); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 29586820..ee466b1a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -3,15 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; -import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/plex_video_controls.dart'; import '../utils/language_codes.dart'; import '../utils/app_logger.dart'; class VideoPlayerScreen extends StatefulWidget { - final PlexClient client; final PlexMetadata metadata; final AudioTrack? preferredAudioTrack; final SubtitleTrack? preferredSubtitleTrack; @@ -20,7 +20,6 @@ class VideoPlayerScreen extends StatefulWidget { const VideoPlayerScreen({ super.key, - required this.client, required this.metadata, this.preferredAudioTrack, this.preferredSubtitleTrack, @@ -40,6 +39,7 @@ class _VideoPlayerScreenState extends State { PlexMetadata? _previousEpisode; bool _isLoadingNext = false; bool _showPlayNextDialog = false; + PlexClientProvider? _cachedClientProvider; @override void initState() { @@ -87,6 +87,15 @@ class _VideoPlayerScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + + // Cache provider reference for safe access in dispose() + try { + _cachedClientProvider = context.plexClient; + } catch (e) { + appLogger.w('Failed to cache PlexClientProvider', error: e); + _cachedClientProvider = null; + } + // Ensure landscape orientation is set even after navigation _setLandscapeOrientation(); } @@ -105,11 +114,12 @@ class _VideoPlayerScreenState extends State { } try { - final next = await widget.client.findAdjacentEpisode(widget.metadata, 1); - final previous = await widget.client.findAdjacentEpisode( - widget.metadata, - -1, - ); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final next = await client.findAdjacentEpisode(widget.metadata, 1); + final previous = await client.findAdjacentEpisode(widget.metadata, -1); if (mounted) { setState(() { @@ -124,10 +134,14 @@ class _VideoPlayerScreenState extends State { Future _startPlayback() async { try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + // Get the direct file URL from the server - final videoUrl = await widget.client.getVideoUrl( - widget.metadata.ratingKey, - ); + final videoUrl = await client.getVideoUrl(widget.metadata.ratingKey); if (videoUrl != null) { // Open video without auto-playing @@ -593,7 +607,11 @@ class _VideoPlayerScreenState extends State { final duration = player.state.duration.inMilliseconds; if (duration > 0) { - widget.client + final clientProvider = _cachedClientProvider; + final client = clientProvider?.client; + if (client == null) return; + + client .updateProgress( widget.metadata.ratingKey, time: position, @@ -648,7 +666,6 @@ class _VideoPlayerScreenState extends State { PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - client: widget.client, metadata: episodeMetadata, preferredAudioTrack: currentAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, @@ -676,7 +693,6 @@ class _VideoPlayerScreenState extends State { controller: controller, controls: (state) => plexVideoControlsBuilder( player, - widget.client, widget.metadata, onNext: _nextEpisode != null ? _playNext : null, onPrevious: _previousEpisode != null ? _playPrevious : null, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 186cfc64..a25129e5 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -4,6 +4,8 @@ import 'package:uuid/uuid.dart'; import 'storage_service.dart'; import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; +import '../models/plex_home.dart'; +import '../models/user_switch_response.dart'; class PlexAuthService { static const String _appName = 'Plezy'; @@ -163,6 +165,52 @@ class PlexAuthService { return PlexUserProfile.fromJson(response.data as Map); } + + /// Get home users for the authenticated user + Future getHomeUsers(String authToken) async { + final response = await _dio.get( + '$_clientsApi/home/users', + options: _getCommonOptions(authToken: authToken), + ); + + return PlexHome.fromJson(response.data as Map); + } + + /// Switch to a different user in the home + Future switchToUser( + String userUUID, + String currentToken, + ) async { + final queryParams = { + 'includeSubscriptions': '1', + 'includeProviders': '1', + 'includeSettings': '1', + 'includeSharedSettings': '1', + 'X-Plex-Product': _appName, + 'X-Plex-Version': '1.1.0', + 'X-Plex-Client-Identifier': _clientIdentifier, + 'X-Plex-Platform': 'Flutter', + 'X-Plex-Platform-Version': '3.8.1', + 'X-Plex-Token': currentToken, + 'X-Plex-Language': 'en', + }; + + final queryString = queryParams.entries + .map( + (e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ) + .join('&'); + + final response = await _dio.post( + '$_clientsApi/home/users/$userUUID/switch?$queryString', + options: Options( + headers: {'Accept': 'application/json', 'Content-Length': '0'}, + ), + ); + + return UserSwitchResponse.fromJson(response.data as Map); + } } /// Represents a Plex Media Server diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a863ac63..c8811e2b 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -10,6 +10,9 @@ class StorageService { static const String _keySelectedLibraryIndex = 'selected_library_index'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyUserProfile = 'user_profile'; + static const String _keyCurrentUserUUID = 'current_user_uuid'; + static const String _keyHomeUsersCache = 'home_users_cache'; + static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; static StorageService? _instance; late SharedPreferences _prefs; @@ -180,4 +183,74 @@ class StorageService { return null; } } + + // Current User UUID + Future saveCurrentUserUUID(String uuid) async { + await _prefs.setString(_keyCurrentUserUUID, uuid); + } + + String? getCurrentUserUUID() { + return _prefs.getString(_keyCurrentUserUUID); + } + + // Home Users Cache (stored as JSON string with expiry) + Future saveHomeUsersCache(Map homeData) async { + final jsonString = json.encode(homeData); + await _prefs.setString(_keyHomeUsersCache, jsonString); + + // Set cache expiry to 1 hour from now + final expiry = DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch; + await _prefs.setInt(_keyHomeUsersCacheExpiry, expiry); + } + + Map? getHomeUsersCache() { + final expiry = _prefs.getInt(_keyHomeUsersCacheExpiry); + if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) { + // Cache expired, clear it + clearHomeUsersCache(); + return null; + } + + final jsonString = _prefs.getString(_keyHomeUsersCache); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + return null; + } + } + + Future clearHomeUsersCache() async { + await Future.wait([ + _prefs.remove(_keyHomeUsersCache), + _prefs.remove(_keyHomeUsersCacheExpiry), + ]); + } + + // Clear current user UUID (for server switching) + Future clearCurrentUserUUID() async { + await _prefs.remove(_keyCurrentUserUUID); + } + + // Clear all user-related data (for logout) + Future clearUserData() async { + await Future.wait([ + clearCredentials(), + clearLibraryPreferences(), + _prefs.remove(_keyUserProfile), + _prefs.remove(_keyCurrentUserUUID), + clearHomeUsersCache(), + ]); + } + + // Update current user after switching + Future updateCurrentUser(String userUUID, String authToken) async { + await Future.wait([ + saveCurrentUserUUID(userUUID), + saveToken(authToken), // Update the main token + ]); + } } diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart new file mode 100644 index 00000000..09048586 --- /dev/null +++ b/lib/utils/provider_extensions.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/user_profile_provider.dart'; +import '../client/plex_client.dart'; + +extension ProviderExtensions on BuildContext { + PlexClientProvider get plexClient => + Provider.of(this, listen: false); + + UserProfileProvider get userProfile => + Provider.of(this, listen: false); + + PlexClientProvider watchPlexClient() => + Provider.of(this, listen: true); + + UserProfileProvider watchUserProfile() => + Provider.of(this, listen: true); + + // Direct client access (nullable) + PlexClient? get client => plexClient.client; + + // Null-safe client access + PlexClient get clientSafe => plexClient.client!; +} diff --git a/lib/utils/user_switching_utils.dart b/lib/utils/user_switching_utils.dart new file mode 100644 index 00000000..5841f714 --- /dev/null +++ b/lib/utils/user_switching_utils.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import '../models/plex_home_user.dart'; +import 'provider_extensions.dart'; + +class UserSwitchingUtils { + static Future switchToUser( + BuildContext context, + PlexHomeUser user, { + bool popOnSuccess = false, + }) async { + final userProvider = context.userProfile; + + final success = await userProvider.switchToUser(user, context); + + if (success && context.mounted && popOnSuccess) { + Navigator.of(context).pop(); + } else if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to switch to ${user.displayName}'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + return success; + } +} diff --git a/lib/widgets/app_bar_back_button.dart b/lib/widgets/app_bar_back_button.dart index 8e3b2164..7d517d7f 100644 --- a/lib/widgets/app_bar_back_button.dart +++ b/lib/widgets/app_bar_back_button.dart @@ -68,17 +68,21 @@ class AppBarBackButton extends StatelessWidget { /// Builds a back button with circular semi-transparent background Widget _buildCircularBackButton(BuildContext context) { return SafeArea( - child: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: IconButton( - icon: Icon(Icons.arrow_back, color: color ?? Colors.white), - onPressed: () => _handlePressed(context), - padding: EdgeInsets.zero, - tooltip: 'Back', + child: GestureDetector( + onTap: () => _handlePressed(context), + child: Container( + margin: const EdgeInsets.all(8), + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back, + color: color ?? Colors.white, + size: 20, + ), ), ), ); diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index 7e7b4748..3d065c1f 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -91,20 +91,39 @@ class DesktopSliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { + // Determine the effective leading widget + Widget? effectiveLeading = leading; + + // If no leading is provided but automaticallyImplyLeading is true, + // create a back button manually so it goes through our padding logic + if (leading == null && automaticallyImplyLeading) { + final parentRoute = ModalRoute.of(context); + final canPop = parentRoute?.canPop ?? false; + + if (canPop) { + effectiveLeading = IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } + } + return SliverAppBar( title: title != null ? DesktopTitleBarPadding( - leftPadding: leading != null ? 0 : null, + leftPadding: effectiveLeading != null ? 0 : null, child: title!, ) : null, actions: DesktopAppBarHelper.buildAdjustedActions(actions), leading: DesktopAppBarHelper.buildAdjustedLeading( - leading, + effectiveLeading, includeGestureDetector: true, ), - leadingWidth: DesktopAppBarHelper.calculateLeadingWidth(leading), - automaticallyImplyLeading: automaticallyImplyLeading, + leadingWidth: DesktopAppBarHelper.calculateLeadingWidth(effectiveLeading), + automaticallyImplyLeading: + false, // Always false since we handle it manually elevation: elevation, backgroundColor: backgroundColor, surfaceTintColor: surfaceTintColor, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index cba8442a..1b942598 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import '../client/plex_client.dart'; +import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/provider_extensions.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../screens/video_player_screen.dart'; @@ -10,7 +12,6 @@ import '../theme/theme_helper.dart'; import 'media_context_menu.dart'; class MediaCard extends StatefulWidget { - final PlexClient client; final PlexMetadata item; final double? width; final double? height; @@ -19,7 +20,6 @@ class MediaCard extends StatefulWidget { const MediaCard({ super.key, - required this.client, required this.item, this.width, this.height, @@ -37,6 +37,9 @@ class _MediaCardState extends State bool get wantKeepAlive => true; void _handleTap(BuildContext context) async { + final client = context.client; + if (client == null) return; + final itemType = widget.item.type.toLowerCase(); // For episodes, start playback directly @@ -45,7 +48,6 @@ class _MediaCardState extends State context, MaterialPageRoute( builder: (context) => VideoPlayerScreen( - client: widget.client, metadata: widget.item, userProfile: widget.userProfile, ), @@ -61,7 +63,6 @@ class _MediaCardState extends State context, MaterialPageRoute( builder: (context) => SeasonDetailScreen( - client: widget.client, season: widget.item, userProfile: widget.userProfile, ), @@ -75,7 +76,6 @@ class _MediaCardState extends State context, MaterialPageRoute( builder: (context) => MediaDetailScreen( - client: widget.client, metadata: widget.item, userProfile: widget.userProfile, ), @@ -94,7 +94,6 @@ class _MediaCardState extends State return SizedBox( width: widget.width, child: MediaContextMenu( - client: widget.client, metadata: widget.item, onRefresh: widget.onRefresh, onTap: () => _handleTap(context), @@ -185,20 +184,32 @@ class _MediaCardState extends State Widget _buildPosterImage(BuildContext context) { if (widget.item.posterThumb != null) { - return CachedNetworkImage( - imageUrl: widget.client.getThumbnailUrl(widget.item.posterThumb), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.broken_image, size: 40)), - ), + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.movie, size: 40)), + ); + } + + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(widget.item.posterThumb), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + filterQuality: FilterQuality.medium, + fadeInDuration: const Duration(milliseconds: 300), + placeholder: (context, url) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.broken_image, size: 40)), + ), + ); + }, ); } else { return Container( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index c1c9da73..54c7a7c3 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../utils/provider_extensions.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; @@ -17,7 +17,6 @@ class _MenuAction { /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. class MediaContextMenu extends StatefulWidget { - final PlexClient client; final PlexMetadata metadata; final void Function(String ratingKey)? onRefresh; final VoidCallback? onTap; @@ -25,7 +24,6 @@ class MediaContextMenu extends StatefulWidget { const MediaContextMenu({ super.key, - required this.client, required this.metadata, this.onRefresh, this.onTap, @@ -44,6 +42,9 @@ class _MediaContextMenuState extends State { } void _showContextMenu(BuildContext context) async { + final client = context.client; + if (client == null) return; + final itemType = widget.metadata.type.toLowerCase(); final isPartiallyWatched = widget.metadata.viewedLeafCount != null && @@ -188,7 +189,7 @@ class _MediaContextMenuState extends State { case 'watch': await _executeAction( context, - () => widget.client.markAsWatched(widget.metadata.ratingKey), + () => client.markAsWatched(widget.metadata.ratingKey), 'Marked as watched', ); break; @@ -196,7 +197,7 @@ class _MediaContextMenuState extends State { case 'unwatch': await _executeAction( context, - () => widget.client.markAsUnwatched(widget.metadata.ratingKey), + () => client.markAsUnwatched(widget.metadata.ratingKey), 'Marked as unwatched', ); break; @@ -205,8 +206,7 @@ class _MediaContextMenuState extends State { await _navigateToRelated( context, widget.metadata.grandparentRatingKey, - (metadata) => - MediaDetailScreen(client: widget.client, metadata: metadata), + (metadata) => MediaDetailScreen(metadata: metadata), 'Error loading series', ); break; @@ -215,8 +215,7 @@ class _MediaContextMenuState extends State { await _navigateToRelated( context, widget.metadata.parentRatingKey, - (metadata) => - SeasonDetailScreen(client: widget.client, season: metadata), + (metadata) => SeasonDetailScreen(season: metadata), 'Error loading season', ); break; @@ -255,8 +254,12 @@ class _MediaContextMenuState extends State { ) async { if (ratingKey == null) return; + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + try { - final metadata = await widget.client.getMetadata(ratingKey); + final metadata = await client.getMetadata(ratingKey); if (metadata != null && context.mounted) { await Navigator.push( context, diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index f3f916e5..4da28372 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -3,27 +3,27 @@ import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; -import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_media_info.dart'; +import '../providers/plex_client_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/platform_detector.dart'; +import '../utils/provider_extensions.dart'; import 'app_bar_back_button.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( Player player, - PlexClient client, PlexMetadata metadata, { VoidCallback? onNext, VoidCallback? onPrevious, }) { return PlexVideoControls( player: player, - client: client, metadata: metadata, onNext: onNext, onPrevious: onPrevious, @@ -32,7 +32,6 @@ Widget plexVideoControlsBuilder( class PlexVideoControls extends StatefulWidget { final Player player; - final PlexClient client; final PlexMetadata metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; @@ -40,7 +39,6 @@ class PlexVideoControls extends StatefulWidget { const PlexVideoControls({ super.key, required this.player, - required this.client, required this.metadata, this.onNext, this.onPrevious, @@ -167,7 +165,11 @@ class _PlexVideoControlsState extends State } Future _loadChapters() async { - final chapters = await widget.client.getChapters(widget.metadata.ratingKey); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final chapters = await client.getChapters(widget.metadata.ratingKey); if (mounted) { setState(() { _chapters = chapters; @@ -1505,20 +1507,37 @@ class _PlexVideoControlsState extends State children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: Image.network( - widget.client.getThumbnailUrl( - chapter.thumb, - ), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - const Icon( + child: Consumer( + builder: + (context, clientProvider, child) { + final client = + clientProvider.client; + if (client == null) { + return const Icon( Icons.image, color: Colors.white54, size: 34, + ); + } + return Image.network( + client.getThumbnailUrl( + chapter.thumb, ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: + ( + context, + error, + stackTrace, + ) => const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ); + }, ), ), if (isCurrentChapter) diff --git a/lib/widgets/profile_list_tile.dart b/lib/widgets/profile_list_tile.dart new file mode 100644 index 00000000..ca806763 --- /dev/null +++ b/lib/widgets/profile_list_tile.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import '../models/plex_home_user.dart'; +import 'user_avatar_widget.dart'; + +class ProfileListTile extends StatelessWidget { + final PlexHomeUser user; + final VoidCallback onTap; + final bool isCurrentUser; + final bool showTrailingIcon; + + const ProfileListTile({ + super.key, + required this.user, + required this.onTap, + this.isCurrentUser = false, + this.showTrailingIcon = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ListTile( + leading: UserAvatarWidget(user: user, size: 40, showIndicators: false), + title: Text(user.displayName), + subtitle: _hasUserAttributes() + ? Row(children: _buildUserAttributes(theme)) + : null, + trailing: isCurrentUser + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'CURRENT', + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ) + : (showTrailingIcon ? const Icon(Icons.chevron_right) : null), + onTap: isCurrentUser ? null : onTap, + enabled: !isCurrentUser, + ); + } + + bool _hasUserAttributes() { + return user.isAdminUser || user.isRestrictedUser || user.requiresPassword; + } + + List _buildUserAttributes(ThemeData theme) { + final attributes = []; + final labels = []; + + if (user.isAdminUser) { + labels.add('Admin'); + } + + if (user.isRestrictedUser && !user.isAdminUser) { + labels.add('Restricted'); + } + + if (user.requiresPassword) { + labels.add('Protected'); + } + + for (int i = 0; i < labels.length; i++) { + if (i > 0) { + attributes.addAll([ + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + ]); + } + + attributes.add( + Text( + labels[i], + style: TextStyle( + fontSize: 12, + color: _getAttributeColor(labels[i], theme), + fontWeight: FontWeight.w500, + ), + ), + ); + } + + return attributes; + } + + Color _getAttributeColor(String attribute, ThemeData theme) { + switch (attribute) { + case 'Admin': + return theme.colorScheme.primary; + case 'Restricted': + return theme.colorScheme.warning ?? Colors.orange; + case 'Protected': + return theme.colorScheme.secondary; + default: + return theme.colorScheme.onSurface; + } + } +} diff --git a/lib/widgets/profile_selector.dart b/lib/widgets/profile_selector.dart new file mode 100644 index 00000000..fd7d3070 --- /dev/null +++ b/lib/widgets/profile_selector.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_home_user.dart'; +import '../providers/user_profile_provider.dart'; +import '../utils/user_switching_utils.dart'; +import 'user_avatar_widget.dart'; +import '../screens/profile_switch_screen.dart'; + +class ProfileSelector extends StatelessWidget { + final double avatarSize; + final bool showCurrentUserOnly; + + const ProfileSelector({ + super.key, + this.avatarSize = 32, + this.showCurrentUserOnly = false, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, userProvider, child) { + if (userProvider.currentUser == null) { + return const SizedBox.shrink(); + } + + if (showCurrentUserOnly || !userProvider.hasMultipleUsers) { + // Show only current user avatar + return UserAvatarWidget( + user: userProvider.currentUser!, + size: avatarSize, + onTap: userProvider.hasMultipleUsers + ? () => _showProfileSwitchDialog(context) + : null, + ); + } + + // Show horizontal list of users + return SizedBox( + height: avatarSize + 8, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: userProvider.home?.users.length ?? 0, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final users = userProvider.home!.users; + final user = users[index]; + final isCurrentUser = user.uuid == userProvider.currentUser?.uuid; + + return Container( + decoration: isCurrentUser + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ) + : null, + child: Padding( + padding: EdgeInsets.all(isCurrentUser ? 2 : 0), + child: UserAvatarWidget( + user: user, + size: avatarSize - (isCurrentUser ? 4 : 0), + onTap: isCurrentUser + ? () => _showProfileSwitchDialog(context) + : () => _switchToUser(context, user), + ), + ), + ); + }, + ), + ); + }, + ); + } + + void _showProfileSwitchDialog(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()), + ); + } + + void _switchToUser(BuildContext context, PlexHomeUser user) async { + await UserSwitchingUtils.switchToUser(context, user); + } +} diff --git a/lib/widgets/profile_switch_dialog.dart b/lib/widgets/profile_switch_dialog.dart new file mode 100644 index 00000000..3b834f59 --- /dev/null +++ b/lib/widgets/profile_switch_dialog.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_home_user.dart'; +import '../providers/user_profile_provider.dart'; +import '../utils/user_switching_utils.dart'; +import 'profile_list_tile.dart'; + +class ProfileSwitchDialog extends StatelessWidget { + const ProfileSwitchDialog({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Consumer( + builder: (context, userProvider, child) { + final users = userProvider.home?.users ?? []; + + return AlertDialog( + content: SizedBox( + width: double.maxFinite, + height: users.isEmpty ? 100 : (users.length * 72.0).clamp(100, 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (userProvider.isLoading) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else if (users.isEmpty) + const Expanded( + child: Center(child: Text('No users available')), + ) + else + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + final isCurrentUser = + user.uuid == userProvider.currentUser?.uuid; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ProfileListTile( + user: user, + isCurrentUser: isCurrentUser, + onTap: () => _switchToUser(context, user), + ), + ), + ); + }, + ), + ), + + if (userProvider.error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + userProvider.error!, + style: TextStyle( + color: theme.colorScheme.error, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + } + + void _switchToUser(BuildContext context, PlexHomeUser user) async { + await UserSwitchingUtils.switchToUser(context, user, popOnSuccess: true); + } +} diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart index 54274e12..dff2650e 100644 --- a/lib/widgets/server_list_tile.dart +++ b/lib/widgets/server_list_tile.dart @@ -5,12 +5,14 @@ class ServerListTile extends StatelessWidget { final PlexServer server; final VoidCallback onTap; final bool showTrailingIcon; + final bool isCurrentServer; const ServerListTile({ super.key, required this.server, required this.onTap, this.showTrailingIcon = true, + this.isCurrentServer = false, }); @override @@ -57,8 +59,26 @@ class ServerListTile extends StatelessWidget { ), ], ), - trailing: showTrailingIcon ? const Icon(Icons.chevron_right) : null, - onTap: onTap, + trailing: isCurrentServer + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'CURRENT', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ) + : (showTrailingIcon ? const Icon(Icons.chevron_right) : null), + onTap: isCurrentServer ? null : onTap, + enabled: !isCurrentServer, ); } } diff --git a/lib/widgets/user_avatar_widget.dart b/lib/widgets/user_avatar_widget.dart new file mode 100644 index 00000000..6cd949c9 --- /dev/null +++ b/lib/widgets/user_avatar_widget.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../models/plex_home_user.dart'; + +class UserAvatarWidget extends StatelessWidget { + final PlexHomeUser user; + final double size; + final bool showIndicators; + final bool useTextLabels; + final VoidCallback? onTap; + + const UserAvatarWidget({ + super.key, + required this.user, + this.size = 40, + this.showIndicators = true, + this.useTextLabels = false, + this.onTap, + }); + + Widget _buildPlaceholderAvatar(ThemeData theme) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + Icons.person, + size: size * 0.6, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } + + List _buildTextLabels(ThemeData theme) { + if (!useTextLabels || !showIndicators) return []; + + final labels = []; + + if (user.isAdminUser) { + labels.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Admin', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + if (user.isRestrictedUser && !user.isAdminUser) { + labels.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.warning ?? Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Restricted', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + if (user.requiresPassword) { + labels.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Protected', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + if (labels.isEmpty) return []; + + return [ + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 2, + alignment: WrapAlignment.center, + children: labels, + ), + ]; + } + + Widget _buildAvatar(ThemeData theme) { + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + // Avatar image + ClipOval( + child: CachedNetworkImage( + imageUrl: user.thumb, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholderAvatar(theme), + errorWidget: (context, url, error) => + _buildPlaceholderAvatar(theme), + ), + ), + + // Indicators (only show icon indicators when not using text labels) + if (showIndicators && !useTextLabels) ...[ + // Admin badge + if (user.isAdminUser) + Positioned( + top: 0, + right: 0, + child: Container( + width: size * 0.3, + height: size * 0.3, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.surface, + width: 1, + ), + ), + child: Icon( + Icons.admin_panel_settings, + size: size * 0.2, + color: theme.colorScheme.onPrimary, + ), + ), + ), + + // Restricted badge + if (user.isRestrictedUser && !user.isAdminUser) + Positioned( + top: 0, + right: 0, + child: Container( + width: size * 0.3, + height: size * 0.3, + decoration: BoxDecoration( + color: theme.colorScheme.warning ?? Colors.orange, + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.surface, + width: 1, + ), + ), + child: Icon( + Icons.security, + size: size * 0.2, + color: theme.colorScheme.onPrimary, + ), + ), + ), + + // Password indicator + if (user.requiresPassword) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: size * 0.25, + height: size * 0.25, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.surface, + width: 1, + ), + ), + child: Icon( + Icons.lock, + size: size * 0.15, + color: theme.colorScheme.onSecondary, + ), + ), + ), + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (useTextLabels) { + // Return avatar with text labels below + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_buildAvatar(theme), ..._buildTextLabels(theme)], + ), + ); + } else { + // Return just the avatar (original behavior) + return GestureDetector(onTap: onTap, child: _buildAvatar(theme)); + } + } +} + +// Extension to add warning color to ColorScheme if not available +extension ColorSchemeExtension on ColorScheme { + Color? get warning => brightness == Brightness.light + ? Colors.orange.shade600 + : Colors.orange.shade400; +} diff --git a/pubspec.lock b/pubspec.lock index e4c41227..d184c2d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -513,6 +513,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" octo_image: dependency: transitive description: @@ -641,6 +649,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index edb7d6da..a07bec6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: window_manager: ^0.4.3 logger: ^2.0.2 package_info_plus: ^9.0.0 + provider: ^6.1.2 dependency_overrides: media_kit: