feat: profiles
This commit is contained in:
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user