feat: profiles

This commit is contained in:
edde746
2025-10-30 19:50:50 +01:00
parent 17dba97dbb
commit 36b897f963
34 changed files with 2537 additions and 606 deletions
+6
View File
@@ -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
+21
View File
@@ -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"]
}
}
+9 -1
View File
@@ -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<bool> testConnection() async {
try {
+47 -10
View File
@@ -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<SetupScreen> {
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<SetupScreen> {
// 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(
+70
View File
@@ -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<PlexHomeUser> 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<String, dynamic> json) {
final List<dynamic> usersJson = json['users'] as List<dynamic>;
final users = usersJson
.map(
(userJson) => PlexHomeUser.fromJson(userJson as Map<String, dynamic>),
)
.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<String, dynamic> 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<PlexHomeUser> get managedUsers =>
users.where((user) => !user.admin).toList();
List<PlexHomeUser> 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;
}
+74
View File
@@ -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<String, dynamic> 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<String, dynamic> 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;
}
+137
View File
@@ -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<String, dynamic> 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<String, dynamic> 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;
}
+31
View File
@@ -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();
}
}
+434
View File
@@ -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<void> 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<void> _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<void> 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<bool> 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<void> 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<void> 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<void> 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;
}
}
+195 -281
View File
@@ -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<DiscoverScreen> createState() => _DiscoverScreenState();
@@ -37,7 +34,7 @@ class DiscoverScreen extends StatefulWidget {
class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
@override
PlexClient get client => widget.client;
PlexClient get client => context.clientSafe;
List<PlexMetadata> _onDeck = [];
List<PlexMetadata> _recentlyAdded = [];
@@ -101,8 +98,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
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<PlexServer>(
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<void> _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<DiscoverScreen>
}
}
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<DiscoverScreen>
icon: const Icon(Icons.refresh),
onPressed: _loadContent,
),
PopupMenuButton<String>(
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<UserProfileProvider>(
builder: (context, userProvider, child) {
return PopupMenuButton<String>(
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<DiscoverScreen>
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<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: heroItem,
userProfile: widget.userProfile,
),
@@ -669,21 +559,33 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<PlexClientProvider>(
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<DiscoverScreen>
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<PlexClientProvider>(
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<DiscoverScreen>
return InkWell(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Playing: ${heroItem.title}');
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: heroItem,
userProfile: widget.userProfile,
),
@@ -1023,7 +938,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
padding: const EdgeInsets.symmetric(horizontal: 2),
child: MediaCard(
key: Key(item.ratingKey),
client: widget.client,
item: item,
width: cardWidth,
height: cardHeight,
+56 -13
View File
@@ -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<LibrariesScreen> createState() => _LibrariesScreenState();
@@ -25,7 +27,7 @@ class LibrariesScreen extends StatefulWidget {
class _LibrariesScreenState extends State<LibrariesScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
PlexClient get client => context.clientSafe;
List<PlexLibrary> _libraries = [];
List<PlexMetadata> _items = [];
@@ -50,7 +52,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
});
try {
final libraries = await widget.client.getLibraries();
final clientProvider = Provider.of<PlexClientProvider>(
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<LibrariesScreen>
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<LibrariesScreen>
_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<LibrariesScreen>
if (index < 0 || index >= _libraries.length) return;
try {
final filters = await widget.client.getLibraryFilters(
_libraries[index].key,
final clientProvider = Provider.of<PlexClientProvider>(
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<LibrariesScreen>
});
try {
final items = await widget.client.getLibraryContent(
final clientProvider = Provider.of<PlexClientProvider>(
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<LibrariesScreen>
builder: (context) => _FiltersBottomSheet(
filters: _filters,
selectedFilters: _selectedFilters,
client: widget.client,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
@@ -394,7 +431,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
class _FiltersBottomSheet extends StatefulWidget {
final List<PlexFilter> filters;
final Map<String, String> selectedFilters;
final PlexClient client;
final Function(Map<String, String>) 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<PlexClientProvider>(
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;
+36 -3
View File
@@ -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<MainScreen> with RouteAware {
late final List<Widget> _screens;
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
@override
void initState() {
@@ -31,13 +34,20 @@ class _MainScreenState extends State<MainScreen> 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<MainScreen> 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(
+181 -103
View File
@@ -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<MediaDetailScreen> {
});
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<MediaDetailScreen> {
});
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<MediaDetailScreen> {
/// This preserves scroll position and only updates watch-related data
Future<void> _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<MediaDetailScreen> {
// For shows, also refetch seasons to update their watch counts
List<PlexMetadata>? 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<MediaDetailScreen> {
Future<void> _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<MediaDetailScreen> {
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<MediaDetailScreen> {
// 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<MediaDetailScreen> {
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<PlexClientProvider>(
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<MediaDetailScreen> {
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<PlexClientProvider>(
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<MediaDetailScreen> {
?.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<MediaDetailScreen> {
// 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<MediaDetailScreen> {
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: _onDeckEpisode!,
userProfile: widget.userProfile,
),
@@ -504,13 +561,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
return Card(
clipBehavior: Clip.antiAlias,
child: MediaContextMenu(
client: widget.client,
metadata: season,
onRefresh: (ratingKey) {
_watchStateChanged = true;
@@ -701,8 +764,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
final watchStateChanged = await Navigator.push<bool>(
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<MediaDetailScreen> {
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<PlexClientProvider>(
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<MediaDetailScreen> {
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value:
season.viewedLeafCount! / season.leafCount!,
season.viewedLeafCount! /
season.leafCount!,
backgroundColor: tokens(context).outline,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
+124
View File
@@ -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<UserProfileProvider>(
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,
),
);
}
}
}
+10 -5
View File
@@ -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<SearchScreen> createState() => _SearchScreenState();
@@ -21,7 +21,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen>
with Refreshable, ItemUpdatable {
@override
PlexClient get client => widget.client;
PlexClient get client => context.clientSafe;
final _searchController = TextEditingController();
List<PlexMetadata> _searchResults = [];
@@ -86,7 +86,13 @@ class _SearchScreenState extends State<SearchScreen>
});
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<SearchScreen>
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
client: widget.client,
item: item,
onRefresh: updateItem,
userProfile: widget.userProfile,
+68 -48
View File
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
@@ -29,7 +26,7 @@ class SeasonDetailScreen extends StatefulWidget {
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable {
@override
PlexClient get client => widget.client;
PlexClient get client => context.clientSafe;
List<PlexMetadata> _episodes = [];
bool _isLoadingEpisodes = false;
@@ -47,7 +44,13 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
});
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<SeasonDetailScreen>
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<SeasonDetailScreen>
: 0.0;
return MediaContextMenu(
client: widget.client,
metadata: episode,
onRefresh: updateItem,
onTap: () async {
@@ -144,7 +144,6 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: episode,
userProfile: widget.userProfile,
),
@@ -155,14 +154,13 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
},
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<SeasonDetailScreen>
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<PlexClientProvider>(
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<SeasonDetailScreen>
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,
),
),
],
],
+153 -64
View File
@@ -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<ServerSelectionScreen> {
List<PlexServer>? _servers;
bool _isLoading = true;
String? _errorMessage;
String? _currentServerUrl;
@override
void initState() {
super.initState();
_loadCurrentServerUrl();
_loadServers();
}
Future<void> _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<void> _loadServers() async {
try {
final servers = await widget.authService.fetchServers(widget.plexToken);
@@ -58,92 +72,167 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
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),
),
);
},
),
),
],
),
);
}
}
+30 -14
View File
@@ -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<VideoPlayerScreen> {
PlexMetadata? _previousEpisode;
bool _isLoadingNext = false;
bool _showPlayNextDialog = false;
PlexClientProvider? _cachedClientProvider;
@override
void initState() {
@@ -87,6 +87,15 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
@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<VideoPlayerScreen> {
}
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<VideoPlayerScreen> {
Future<void> _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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
VideoPlayerScreen(
client: widget.client,
metadata: episodeMetadata,
preferredAudioTrack: currentAudioTrack,
preferredSubtitleTrack: currentSubtitleTrack,
@@ -676,7 +693,6 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
controller: controller,
controls: (state) => plexVideoControlsBuilder(
player,
widget.client,
widget.metadata,
onNext: _nextEpisode != null ? _playNext : null,
onPrevious: _previousEpisode != null ? _playPrevious : null,
+48
View File
@@ -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<String, dynamic>);
}
/// Get home users for the authenticated user
Future<PlexHome> getHomeUsers(String authToken) async {
final response = await _dio.get(
'$_clientsApi/home/users',
options: _getCommonOptions(authToken: authToken),
);
return PlexHome.fromJson(response.data as Map<String, dynamic>);
}
/// Switch to a different user in the home
Future<UserSwitchResponse> 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<String, dynamic>);
}
}
/// Represents a Plex Media Server
+73
View File
@@ -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<void> 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<void> saveHomeUsersCache(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic>;
} catch (e) {
return null;
}
}
Future<void> clearHomeUsersCache() async {
await Future.wait([
_prefs.remove(_keyHomeUsersCache),
_prefs.remove(_keyHomeUsersCacheExpiry),
]);
}
// Clear current user UUID (for server switching)
Future<void> clearCurrentUserUUID() async {
await _prefs.remove(_keyCurrentUserUUID);
}
// Clear all user-related data (for logout)
Future<void> clearUserData() async {
await Future.wait([
clearCredentials(),
clearLibraryPreferences(),
_prefs.remove(_keyUserProfile),
_prefs.remove(_keyCurrentUserUUID),
clearHomeUsersCache(),
]);
}
// Update current user after switching
Future<void> updateCurrentUser(String userUUID, String authToken) async {
await Future.wait([
saveCurrentUserUUID(userUUID),
saveToken(authToken), // Update the main token
]);
}
}
+25
View File
@@ -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<PlexClientProvider>(this, listen: false);
UserProfileProvider get userProfile =>
Provider.of<UserProfileProvider>(this, listen: false);
PlexClientProvider watchPlexClient() =>
Provider.of<PlexClientProvider>(this, listen: true);
UserProfileProvider watchUserProfile() =>
Provider.of<UserProfileProvider>(this, listen: true);
// Direct client access (nullable)
PlexClient? get client => plexClient.client;
// Null-safe client access
PlexClient get clientSafe => plexClient.client!;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../models/plex_home_user.dart';
import 'provider_extensions.dart';
class UserSwitchingUtils {
static Future<bool> 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;
}
}
+15 -11
View File
@@ -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,
),
),
),
);
+23 -4
View File
@@ -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,
+32 -21
View File
@@ -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<MediaCard>
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<MediaCard>
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
client: widget.client,
metadata: widget.item,
userProfile: widget.userProfile,
),
@@ -61,7 +63,6 @@ class _MediaCardState extends State<MediaCard>
context,
MaterialPageRoute(
builder: (context) => SeasonDetailScreen(
client: widget.client,
season: widget.item,
userProfile: widget.userProfile,
),
@@ -75,7 +76,6 @@ class _MediaCardState extends State<MediaCard>
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(
client: widget.client,
metadata: widget.item,
userProfile: widget.userProfile,
),
@@ -94,7 +94,6 @@ class _MediaCardState extends State<MediaCard>
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<MediaCard>
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<PlexClientProvider>(
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(
+13 -10
View File
@@ -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<MediaContextMenu> {
}
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
) 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,
+35 -16
View File
@@ -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<PlexVideoControls>
}
Future<void> _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<PlexVideoControls>
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<PlexClientProvider>(
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)
+114
View File
@@ -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<Widget> _buildUserAttributes(ThemeData theme) {
final attributes = <Widget>[];
final labels = <String>[];
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;
}
}
}
+88
View File
@@ -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<UserProfileProvider>(
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);
}
}
+87
View File
@@ -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<UserProfileProvider>(
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);
}
}
+22 -2
View File
@@ -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,
);
}
}
+235
View File
@@ -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<Widget> _buildTextLabels(ThemeData theme) {
if (!useTextLabels || !showIndicators) return [];
final labels = <Widget>[];
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;
}
+16
View File
@@ -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:
+1
View File
@@ -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: