refactor: clean up
This commit is contained in:
@@ -4,3 +4,9 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
plugins:
|
||||
- dart_code_linter
|
||||
|
||||
dart_code_linter:
|
||||
rules:
|
||||
- avoid-unused-parameters
|
||||
|
||||
@@ -11,15 +11,6 @@ class ScreenBreakpoints {
|
||||
static const double largeDesktop = 1600;
|
||||
}
|
||||
|
||||
/// Pagination constants
|
||||
class PaginationConstants {
|
||||
/// Default page size for library items
|
||||
static const int defaultPageSize = 1000;
|
||||
|
||||
/// Page size for search results
|
||||
static const int searchPageSize = 50;
|
||||
}
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Maximum cross-axis extent for grid items in comfortable density mode
|
||||
@@ -44,54 +35,3 @@ class GridLayoutConstants {
|
||||
static const double crossAxisSpacing = 0;
|
||||
static const double mainAxisSpacing = 0;
|
||||
}
|
||||
|
||||
/// Padding and spacing constants
|
||||
class SpacingConstants {
|
||||
/// Extra small spacing (4px)
|
||||
static const double xs = 4;
|
||||
|
||||
/// Small spacing (8px)
|
||||
static const double sm = 8;
|
||||
|
||||
/// Medium spacing (12px)
|
||||
static const double md = 12;
|
||||
|
||||
/// Large spacing (16px)
|
||||
static const double lg = 16;
|
||||
|
||||
/// Extra large spacing (24px)
|
||||
static const double xl = 24;
|
||||
|
||||
/// Double extra large spacing (32px)
|
||||
static const double xxl = 32;
|
||||
}
|
||||
|
||||
/// Icon size constants
|
||||
class IconSizeConstants {
|
||||
/// Small icon size (16px)
|
||||
static const double sm = 16;
|
||||
|
||||
/// Medium icon size (24px)
|
||||
static const double md = 24;
|
||||
|
||||
/// Large icon size (32px)
|
||||
static const double lg = 32;
|
||||
|
||||
/// Extra large icon size (48px)
|
||||
static const double xl = 48;
|
||||
}
|
||||
|
||||
/// Border radius constants
|
||||
class BorderRadiusConstants {
|
||||
/// Small border radius (4px)
|
||||
static const double sm = 4;
|
||||
|
||||
/// Medium border radius (8px)
|
||||
static const double md = 8;
|
||||
|
||||
/// Large border radius (12px)
|
||||
static const double lg = 12;
|
||||
|
||||
/// Extra large border radius (16px)
|
||||
static const double xl = 16;
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'plex_metadata.dart';
|
||||
import 'plex_library.dart';
|
||||
|
||||
part 'media_container.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class MediaContainer<T> {
|
||||
final int? size;
|
||||
final int? totalSize;
|
||||
final int? offset;
|
||||
final String? identifier;
|
||||
@JsonKey(name: 'Directory')
|
||||
final List<PlexLibrary>? directories;
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? metadata;
|
||||
|
||||
MediaContainer({
|
||||
this.size,
|
||||
this.totalSize,
|
||||
this.offset,
|
||||
this.identifier,
|
||||
this.directories,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
|
||||
_$MediaContainerFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'media_container.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
|
||||
MediaContainer<T>(
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
totalSize: (json['totalSize'] as num?)?.toInt(),
|
||||
offset: (json['offset'] as num?)?.toInt(),
|
||||
identifier: json['identifier'] as String?,
|
||||
directories: (json['Directory'] as List<dynamic>?)
|
||||
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
metadata: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
|
||||
<String, dynamic>{
|
||||
'size': instance.size,
|
||||
'totalSize': instance.totalSize,
|
||||
'offset': instance.offset,
|
||||
'identifier': instance.identifier,
|
||||
'Directory': instance.directories,
|
||||
'Metadata': instance.metadata,
|
||||
};
|
||||
@@ -337,7 +337,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
value,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
Navigator.pop(context);
|
||||
_loadItems();
|
||||
|
||||
@@ -62,9 +62,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// Fetch full metadata with clearLogo and OnDeck episode
|
||||
final result = await client.getMetadataWithImagesAndOnDeck(
|
||||
@@ -127,9 +124,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
// Preserve serverId for each season
|
||||
@@ -158,9 +152,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
final metadata = await client.getMetadataWithImages(
|
||||
widget.metadata.ratingKey,
|
||||
@@ -206,9 +197,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// If seasons aren't loaded yet, wait for them or load them
|
||||
if (_seasons.isEmpty && !_isLoadingSeasons) {
|
||||
@@ -274,7 +262,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
PlexMetadata metadata,
|
||||
) async {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
@@ -403,13 +390,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
return Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(metadata.art),
|
||||
fit: BoxFit.cover,
|
||||
@@ -471,18 +451,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
final client = _getClientForMetadata(
|
||||
context,
|
||||
);
|
||||
if (client == null) {
|
||||
return Text(
|
||||
metadata.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(
|
||||
metadata.clearLogo,
|
||||
@@ -730,9 +698,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (_onDeckEpisode != null) {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d(
|
||||
'Playing on deck episode: ${_onDeckEpisode!.title}',
|
||||
);
|
||||
@@ -750,9 +715,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
await _playFirstEpisode();
|
||||
}
|
||||
} else {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d('Playing: ${metadata.title}');
|
||||
// For movies or episodes, play directly
|
||||
await navigateToVideoPlayer(
|
||||
@@ -804,7 +766,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
onPressed: () async {
|
||||
try {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
await client.markAsWatched(metadata.ratingKey);
|
||||
if (context.mounted) {
|
||||
@@ -844,7 +805,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
onPressed: () async {
|
||||
try {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
await client.markAsUnwatched(metadata.ratingKey);
|
||||
if (context.mounted) {
|
||||
@@ -1124,16 +1084,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForMetadata(context);
|
||||
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,
|
||||
|
||||
@@ -186,7 +186,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (_isPlayerInitialized && mounted) {
|
||||
// Restore media metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client != null && _mediaControlsManager != null) {
|
||||
if (_mediaControlsManager != null) {
|
||||
_mediaControlsManager!.updateMetadata(
|
||||
metadata: widget.metadata,
|
||||
client: client,
|
||||
@@ -355,7 +355,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (!mounted || player == null) return;
|
||||
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
// Initialize progress tracker
|
||||
_progressTracker = PlaybackProgressTracker(
|
||||
@@ -452,7 +451,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
|
||||
try {
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
@@ -518,7 +516,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
// Load adjacent episodes using the service
|
||||
final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes(
|
||||
@@ -545,9 +542,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// Capture profile settings before async gap
|
||||
final profileSettings = context.profileSettings;
|
||||
|
||||
// Initialize playback service
|
||||
final playbackService = PlaybackInitializationService(
|
||||
@@ -583,7 +580,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
// Initialize track selection service and apply tracks
|
||||
_trackSelectionService = TrackSelectionService(
|
||||
player: player!,
|
||||
profileSettings: context.profileSettings,
|
||||
profileSettings: profileSettings,
|
||||
metadata: widget.metadata,
|
||||
);
|
||||
|
||||
@@ -799,10 +796,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (!mounted) return;
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
appLogger.w('No client available to save audio language preference');
|
||||
return;
|
||||
}
|
||||
await client.setMetadataPreferences(
|
||||
targetRatingKey,
|
||||
audioLanguage: languageCode,
|
||||
@@ -851,10 +844,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (!mounted) return;
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) {
|
||||
appLogger.w('No client available to save subtitle language preference');
|
||||
return;
|
||||
}
|
||||
await client.setMetadataPreferences(
|
||||
targetRatingKey,
|
||||
subtitleLanguage: languageCode,
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import 'plex_auth_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../config/plex_config.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Result of a server connection attempt
|
||||
class ServerConnectionResult {
|
||||
final PlexClient? client;
|
||||
final PlexUserProfile? userProfile;
|
||||
final String? error;
|
||||
|
||||
ServerConnectionResult({this.client, this.userProfile, this.error});
|
||||
|
||||
bool get isSuccess => client != null;
|
||||
}
|
||||
|
||||
/// Service for handling optimized server connections
|
||||
/// Implements fast-first connection with background optimization
|
||||
class ServerConnectionService {
|
||||
static StreamSubscription<List<ConnectivityResult>>?
|
||||
_connectivitySubscription;
|
||||
static Future<void>? _activeOptimization;
|
||||
static PlexServer? _activeServer;
|
||||
static PlexClient? _activeClient;
|
||||
|
||||
/// Connect to a Plex server with optimized connection testing
|
||||
///
|
||||
/// Returns immediately with first working connection, then continues
|
||||
/// testing in background to find the optimal connection.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [server]: The PlexServer to connect to
|
||||
/// - [clientIdentifier]: Client identifier for the PlexClient
|
||||
/// - [plexToken]: Optional plex.tv token to save to storage
|
||||
/// - [verifyServer]: Whether to verify server is accessible before returning
|
||||
/// - [fetchUserProfile]: Whether to fetch and cache user profile
|
||||
/// - [onProgress]: Callback for progress updates (e.g., show/hide loading)
|
||||
static Future<ServerConnectionResult> connectToServer(
|
||||
PlexServer server, {
|
||||
required String clientIdentifier,
|
||||
String? plexToken,
|
||||
bool verifyServer = false,
|
||||
bool fetchUserProfile = false,
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
final connectionStream = server
|
||||
.findBestWorkingConnection()
|
||||
.asBroadcastStream();
|
||||
PlexClient? client;
|
||||
final serverId = server.clientIdentifier;
|
||||
|
||||
final optimizationSubscription = connectionStream
|
||||
.skip(1)
|
||||
.listen(
|
||||
(connection) async {
|
||||
await _handleOptimizedConnection(
|
||||
connection: connection,
|
||||
storage: storage,
|
||||
server: server,
|
||||
serverId: serverId,
|
||||
client: client,
|
||||
reason: 'initial_latency_sweep',
|
||||
);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Background connection optimization error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final connection = await connectionStream.first;
|
||||
|
||||
if (onProgress != null) {
|
||||
onProgress('Connected to ${connection.displayType} endpoint');
|
||||
}
|
||||
|
||||
// Save server information to storage
|
||||
await storage.saveServerData(server.toJson());
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerEndpoint(serverId, connection.uri);
|
||||
await storage.saveServerAccessToken(server.accessToken);
|
||||
|
||||
// Save plex token if provided
|
||||
if (plexToken != null) {
|
||||
await storage.savePlexToken(plexToken);
|
||||
}
|
||||
|
||||
// Create client with working connection
|
||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: cachedEndpoint ?? connection.uri,
|
||||
);
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: connection.uri,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: clientIdentifier,
|
||||
);
|
||||
client = PlexClient(
|
||||
config,
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
onEndpointChanged: (newUrl) async {
|
||||
await storage.saveServerUrl(newUrl);
|
||||
await storage.saveServerEndpoint(serverId, newUrl);
|
||||
appLogger.i(
|
||||
'Updated stored server URL after failover',
|
||||
error: newUrl,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch machine identifier and cache it in config
|
||||
try {
|
||||
final machineId = await client.getMachineIdentifier();
|
||||
if (machineId != null) {
|
||||
client.config = config.copyWith(machineIdentifier: machineId);
|
||||
appLogger.d('Cached machine identifier: $machineId');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch machine identifier', error: e);
|
||||
// Continue without it - buildMetadataUri will fallback to fetching it
|
||||
}
|
||||
|
||||
// Verify server is accessible if requested
|
||||
if (verifyServer) {
|
||||
try {
|
||||
await client.getServerIdentity();
|
||||
} catch (e) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.w('Server identity verification failed', error: e);
|
||||
await storage.clearCredentials();
|
||||
return ServerConnectionResult(error: 'Server is not accessible: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user profile if requested
|
||||
PlexUserProfile? userProfile;
|
||||
if (fetchUserProfile && plexToken != null) {
|
||||
userProfile = await _fetchUserProfile(plexToken);
|
||||
}
|
||||
|
||||
// Return success result while optimization continues in background
|
||||
_activeServer = server;
|
||||
_activeClient = client;
|
||||
_startConnectivityMonitoring(server);
|
||||
|
||||
return ServerConnectionResult(client: client, userProfile: userProfile);
|
||||
} on StateError catch (e, stackTrace) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.e(
|
||||
'No working connections found for this server',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return ServerConnectionResult(
|
||||
error: 'No working connections found for this server',
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
await optimizationSubscription.cancel();
|
||||
appLogger.e(
|
||||
'Error connecting to server',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return ServerConnectionResult(error: 'Connection failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch user profile from Plex API
|
||||
static Future<PlexUserProfile?> _fetchUserProfile(String plexToken) async {
|
||||
appLogger.d('Fetching user profile from Plex API');
|
||||
try {
|
||||
final authService = await PlexAuthService.create();
|
||||
final profile = await authService.getUserProfile(plexToken);
|
||||
|
||||
appLogger.i(
|
||||
'Successfully fetched user profile from API',
|
||||
error: {
|
||||
'autoSelectAudio': profile.autoSelectAudio,
|
||||
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
|
||||
'autoSelectSubtitle': profile.autoSelectSubtitle,
|
||||
'defaultSubtitleLanguage':
|
||||
profile.defaultSubtitleLanguage ?? 'not set',
|
||||
'defaultSubtitleForced': profile.defaultSubtitleForced,
|
||||
},
|
||||
);
|
||||
|
||||
return profile;
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch user profile from API', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void _startConnectivityMonitoring(PlexServer server) {
|
||||
_connectivitySubscription?.cancel();
|
||||
final connectivity = Connectivity();
|
||||
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
|
||||
(results) {
|
||||
final status = results.isNotEmpty
|
||||
? results.first
|
||||
: ConnectivityResult.none;
|
||||
if (status == ConnectivityResult.none) {
|
||||
appLogger.w(
|
||||
'Connectivity lost, pausing optimization until network returns',
|
||||
);
|
||||
return;
|
||||
}
|
||||
appLogger.d(
|
||||
'Connectivity change detected, triggering endpoint optimization',
|
||||
error: {
|
||||
'status': status.name,
|
||||
'interfaces': results.map((r) => r.name).toList(),
|
||||
},
|
||||
);
|
||||
_activeServer = server;
|
||||
_triggerReoptimization(reason: 'connectivity:${status.name}');
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connectivity listener error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static void _triggerReoptimization({required String reason}) {
|
||||
if (_activeServer == null) {
|
||||
appLogger.d(
|
||||
'Optimization trigger ignored because there is no active server',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeOptimization != null) {
|
||||
appLogger.d(
|
||||
'Optimization already running, skipping new trigger',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_activeOptimization =
|
||||
_runOptimization(
|
||||
server: _activeServer!,
|
||||
client: _activeClient,
|
||||
reason: reason,
|
||||
).whenComplete(() {
|
||||
_activeOptimization = null;
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _runOptimization({
|
||||
required PlexServer server,
|
||||
required PlexClient? client,
|
||||
required String reason,
|
||||
}) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final serverId = server.clientIdentifier;
|
||||
try {
|
||||
appLogger.d(
|
||||
'Starting background connection optimization run',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
await _handleOptimizedConnection(
|
||||
connection: connection,
|
||||
storage: storage,
|
||||
server: server,
|
||||
serverId: serverId,
|
||||
client: client,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w(
|
||||
'Background connection optimization failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleOptimizedConnection({
|
||||
required PlexConnection connection,
|
||||
required StorageService storage,
|
||||
required PlexServer server,
|
||||
required String serverId,
|
||||
required PlexClient? client,
|
||||
required String reason,
|
||||
}) async {
|
||||
final previousUrl = storage.getServerEndpoint(serverId);
|
||||
final isNewEndpoint = previousUrl != connection.uri;
|
||||
|
||||
await storage.saveServerUrl(connection.uri);
|
||||
await storage.saveServerEndpoint(serverId, connection.uri);
|
||||
appLogger.d(
|
||||
'Evaluated optimized endpoint candidate',
|
||||
error: {
|
||||
'uri': connection.uri,
|
||||
'displayType': connection.displayType,
|
||||
'reason': reason,
|
||||
'isNewEndpoint': isNewEndpoint,
|
||||
},
|
||||
);
|
||||
|
||||
if (client != null) {
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: connection.uri,
|
||||
);
|
||||
await client.updateEndpointPreferences(
|
||||
prioritizedEndpoints,
|
||||
switchToFirst: isNewEndpoint,
|
||||
);
|
||||
if (isNewEndpoint) {
|
||||
appLogger.i(
|
||||
'Active client switched to optimized endpoint',
|
||||
error: {'uri': connection.uri, 'reason': reason},
|
||||
);
|
||||
}
|
||||
} else if (isNewEndpoint) {
|
||||
appLogger.i(
|
||||
'Stored optimized endpoint for future sessions',
|
||||
error: {'uri': connection.uri, 'reason': reason},
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewEndpoint && !connection.uri.startsWith('https://')) {
|
||||
final upgraded = await server.upgradeConnectionToHttps(connection);
|
||||
if (upgraded != null && upgraded.uri != connection.uri) {
|
||||
await _handleOptimizedConnection(
|
||||
connection: upgraded,
|
||||
storage: storage,
|
||||
server: server,
|
||||
serverId: serverId,
|
||||
client: client,
|
||||
reason: '$reason:https-upgrade',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,8 @@ Future<void> playCollectionOrPlaylist({
|
||||
}
|
||||
|
||||
String ratingKey = item.ratingKey;
|
||||
String? serverId = isCollection
|
||||
? (item as PlexMetadata).serverId
|
||||
: (item as PlexPlaylist).serverId;
|
||||
String? serverName = isCollection
|
||||
? (item as PlexMetadata).serverName
|
||||
: (item as PlexPlaylist).serverName;
|
||||
String? serverId = item.serverId;
|
||||
String? serverName = item.serverName;
|
||||
|
||||
final PlayQueueResponse? playQueue;
|
||||
if (isCollection) {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
import '../i18n/strings.g.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(
|
||||
t.messages.failedToSwitchProfile(displayName: user.displayName),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// A menu action item for context menus
|
||||
class ContextMenuItem {
|
||||
@@ -22,171 +20,3 @@ class ContextMenuItem {
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// A wrapper widget that shows context menus differently based on platform.
|
||||
/// On mobile (iOS/Android): Shows a bottom sheet on long-press
|
||||
/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press
|
||||
class ContextMenuWrapper extends StatefulWidget {
|
||||
final Widget child;
|
||||
final List<ContextMenuItem> menuItems;
|
||||
final Function(String)? onMenuItemSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? title;
|
||||
final bool forceBottomSheet;
|
||||
|
||||
const ContextMenuWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.menuItems,
|
||||
this.onMenuItemSelected,
|
||||
this.onTap,
|
||||
this.title,
|
||||
this.forceBottomSheet = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ContextMenuWrapper> createState() => _ContextMenuWrapperState();
|
||||
}
|
||||
|
||||
class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
|
||||
Offset _tapPosition = Offset.zero;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
Future<bool> _showConfirmationDialog({
|
||||
required String title,
|
||||
required String message,
|
||||
required bool isDestructive,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: isDestructive
|
||||
? TextButton.styleFrom(foregroundColor: Colors.red)
|
||||
: null,
|
||||
child: Text(t.common.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> _showContextMenu(BuildContext context) async {
|
||||
final useBottomSheet =
|
||||
widget.forceBottomSheet || PlatformDetector.isMobile(context);
|
||||
String? selected;
|
||||
|
||||
if (useBottomSheet) {
|
||||
// Mobile: Show bottom sheet
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
...widget.menuItems.map(
|
||||
(item) => ListTile(
|
||||
leading: Icon(item.icon),
|
||||
title: Text(item.label),
|
||||
onTap: () => Navigator.pop(context, item.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Desktop: Show popup menu
|
||||
final RenderBox overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final overlayRect = Rect.fromPoints(
|
||||
_tapPosition,
|
||||
_tapPosition.translate(1, 1),
|
||||
);
|
||||
|
||||
final menuItems = widget.menuItems
|
||||
.map(
|
||||
(item) => PopupMenuItem<String>(
|
||||
value: item.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(item.icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(item.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
overlayRect,
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: menuItems,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
popUpAnimationStyle: AnimationStyle(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
reverseDuration: const Duration(milliseconds: 100),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selected != null && widget.onMenuItemSelected != null) {
|
||||
// Find the selected item to check if confirmation is needed
|
||||
final selectedItem = widget.menuItems.firstWhere(
|
||||
(item) => item.value == selected,
|
||||
);
|
||||
|
||||
if (selectedItem.requiresConfirmation) {
|
||||
final confirmed = await _showConfirmationDialog(
|
||||
title: selectedItem.confirmationTitle ?? t.dialog.confirmAction,
|
||||
message: selectedItem.confirmationMessage ?? t.dialog.areYouSure,
|
||||
isDestructive: selectedItem.isDestructive,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
widget.onMenuItemSelected!(selected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: () => _showContextMenu(context),
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: () => _showContextMenu(context),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,58 +3,6 @@ import '../utils/desktop_window_padding.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import 'app_bar_back_button.dart';
|
||||
|
||||
/// A custom app bar that automatically handles desktop window controls spacing.
|
||||
/// Use this instead of AppBar for consistent desktop platform behavior.
|
||||
class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final Widget? title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double? elevation;
|
||||
final Color? backgroundColor;
|
||||
final Color? surfaceTintColor;
|
||||
final Color? shadowColor;
|
||||
final double? scrolledUnderElevation;
|
||||
|
||||
const DesktopAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation,
|
||||
this.backgroundColor,
|
||||
this.surfaceTintColor,
|
||||
this.shadowColor,
|
||||
this.scrolledUnderElevation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appBar = AppBar(
|
||||
title: title != null
|
||||
? DesktopTitleBarPadding(
|
||||
leftPadding: leading != null ? 0 : null,
|
||||
child: title!,
|
||||
)
|
||||
: null,
|
||||
actions: DesktopAppBarHelper.buildAdjustedActions(actions),
|
||||
leading: DesktopAppBarHelper.buildAdjustedLeading(leading),
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
elevation: elevation,
|
||||
backgroundColor: backgroundColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
shadowColor: shadowColor,
|
||||
scrolledUnderElevation: scrolledUnderElevation,
|
||||
);
|
||||
|
||||
return DesktopAppBarHelper.wrapWithGestureDetector(appBar);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
/// A custom sliver app bar that automatically handles desktop window controls spacing.
|
||||
/// Use this instead of SliverAppBar for consistent desktop platform behavior.
|
||||
class DesktopSliverAppBar extends StatelessWidget {
|
||||
@@ -142,66 +90,6 @@ class DesktopSliverAppBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenient wrapper for DesktopAppBar with built-in back button handling
|
||||
class PlexAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final Widget? title;
|
||||
final List<Widget>? actions;
|
||||
final VoidCallback? onBackPressed;
|
||||
final double? elevation;
|
||||
final Color? backgroundColor;
|
||||
final Color? surfaceTintColor;
|
||||
final Color? shadowColor;
|
||||
final double? scrolledUnderElevation;
|
||||
|
||||
const PlexAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.actions,
|
||||
this.onBackPressed,
|
||||
this.elevation,
|
||||
this.backgroundColor,
|
||||
this.surfaceTintColor,
|
||||
this.shadowColor,
|
||||
this.scrolledUnderElevation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
|
||||
return DesktopAppBar(
|
||||
key: ValueKey('plex_app_bar_$isFullscreen'),
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: _shouldShowBackButton(context)
|
||||
? AppBarBackButton(
|
||||
style: BackButtonStyle.plain,
|
||||
onPressed: onBackPressed,
|
||||
)
|
||||
: null,
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: elevation,
|
||||
backgroundColor: backgroundColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
shadowColor: shadowColor,
|
||||
scrolledUnderElevation: scrolledUnderElevation,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowBackButton(BuildContext context) {
|
||||
final parentRoute = ModalRoute.of(context);
|
||||
return parentRoute?.canPop ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
/// Convenient wrapper for DesktopSliverAppBar with built-in back button handling
|
||||
class CustomAppBar extends StatelessWidget {
|
||||
final Widget? title;
|
||||
|
||||
@@ -678,13 +678,6 @@ Widget _buildPosterImage(BuildContext context, dynamic item) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForItem(context, item);
|
||||
if (client == null) {
|
||||
return SkeletonLoader(
|
||||
child: Center(
|
||||
child: Icon(fallbackIcon, size: 40, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(posterUrl!),
|
||||
|
||||
@@ -440,9 +440,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (ratingKey == null) return;
|
||||
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
try{
|
||||
final metadata = await client.getMetadata(ratingKey);
|
||||
if (metadata != null && context.mounted) {
|
||||
await Navigator.push(
|
||||
@@ -463,9 +462,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// Show file info bottom sheet
|
||||
Future<void> _showFileInfo(BuildContext context) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
try{
|
||||
// Show loading indicator
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
@@ -518,7 +516,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// Handle shuffle play using play queues
|
||||
Future<void> _handleShufflePlayWithQueue(BuildContext context) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
@@ -690,9 +687,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// Show dialog to select playlist and add item
|
||||
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
try{
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
|
||||
@@ -801,9 +797,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// Show dialog to select collection and add item
|
||||
Future<void> _showAddToCollectionDialog(BuildContext context) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
try{
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
|
||||
@@ -890,6 +885,8 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final itemUri = await client.buildMetadataUri(metadata.ratingKey);
|
||||
appLogger.d('Built URI for $itemType: $itemUri');
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (result == '_create_new') {
|
||||
// Create new collection flow
|
||||
final collectionName = await showDialog<String>(
|
||||
@@ -1023,7 +1020,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
PlexMetadata metadata,
|
||||
) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
if (widget.collectionId == null) {
|
||||
appLogger.e('Cannot remove from collection: collectionId is null');
|
||||
@@ -1099,7 +1095,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
bool isPlaylist,
|
||||
) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
await playCollectionOrPlaylist(
|
||||
context: context,
|
||||
@@ -1116,7 +1111,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
bool isPlaylist,
|
||||
) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
await playCollectionOrPlaylist(
|
||||
context: context,
|
||||
@@ -1133,7 +1127,6 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
bool isPlaylist,
|
||||
) async {
|
||||
final client = _getClientForItem();
|
||||
if (client == null) return;
|
||||
|
||||
final itemTitle = widget.item.title;
|
||||
final itemTypeLabel = isCollection
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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';
|
||||
import '../i18n/strings.g.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)
|
||||
Expanded(
|
||||
child: Center(child: Text(t.profile.noUsersAvailable)),
|
||||
)
|
||||
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: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _switchToUser(BuildContext context, PlexHomeUser user) async {
|
||||
await UserSwitchingUtils.switchToUser(context, user, popOnSuccess: true);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class ServerBadge extends StatelessWidget {
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final bgColor =
|
||||
backgroundColor ?? theme.colorScheme.primaryContainer.withOpacity(0.8);
|
||||
backgroundColor ?? theme.colorScheme.primaryContainer.withValues(alpha: 0.8);
|
||||
final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer;
|
||||
|
||||
final displayText = showFullName
|
||||
@@ -62,58 +62,3 @@ class ServerBadge extends StatelessWidget {
|
||||
return Tooltip(message: serverName!, child: badge);
|
||||
}
|
||||
}
|
||||
|
||||
/// Server section header for grouping content by server
|
||||
class ServerSectionHeader extends StatelessWidget {
|
||||
final String serverName;
|
||||
final bool isOnline;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
const ServerSectionHeader({
|
||||
super.key,
|
||||
required this.serverName,
|
||||
this.isOnline = true,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
child: Row(
|
||||
children: [
|
||||
// Server status indicator
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Server name
|
||||
Expanded(
|
||||
child: Text(
|
||||
serverName,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Optional trailing widget
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +96,6 @@ class ChapterSheet extends StatelessWidget {
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForChapters(context);
|
||||
if (client == null) {
|
||||
return const Icon(
|
||||
Icons.image,
|
||||
color: Colors.white54,
|
||||
size: 34,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
client.getThumbnailUrl(chapter.thumb),
|
||||
width: 60,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
|
||||
/// Bottom sheet for selecting playback speed
|
||||
class PlaybackSpeedSheet extends StatelessWidget {
|
||||
final Player player;
|
||||
|
||||
const PlaybackSpeedSheet({super.key, required this.player});
|
||||
|
||||
static void show(BuildContext context, Player player) {
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
builder: (context) => PlaybackSpeedSheet(player: player),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<double>(
|
||||
stream: player.stream.rate,
|
||||
initialData: player.state.rate,
|
||||
builder: (context, snapshot) {
|
||||
final currentRate = snapshot.data ?? 1.0;
|
||||
|
||||
// Define available playback speeds
|
||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
||||
|
||||
return BaseVideoControlSheet(
|
||||
title: 'Playback Speed',
|
||||
icon: Icons.speed,
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||
|
||||
// Format speed label
|
||||
final label = speed == 1.0
|
||||
? 'Normal'
|
||||
: '${speed.toStringAsFixed(2)}x';
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Colors.blue)
|
||||
: null,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import '../widgets/sleep_timer_content.dart';
|
||||
|
||||
/// Bottom sheet for sleep timer configuration
|
||||
class SleepTimerSheet extends StatelessWidget {
|
||||
final Player player;
|
||||
final int defaultDuration;
|
||||
|
||||
const SleepTimerSheet({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.defaultDuration,
|
||||
});
|
||||
|
||||
static void show(BuildContext context, Player player) async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
final defaultDuration = settingsService.getSleepTimerDuration();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
BaseVideoControlSheet.showSheet(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: sleepTimer,
|
||||
builder: (context, _) {
|
||||
return BaseVideoControlSheet(
|
||||
title: t.videoControls.sleepTimer,
|
||||
icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||
iconColor: sleepTimer.isActive ? Colors.amber : null,
|
||||
child: SleepTimerContent(
|
||||
player: player,
|
||||
sleepTimer: sleepTimer,
|
||||
defaultDuration: defaultDuration,
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -346,7 +346,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
Future<void> _loadChapters() async {
|
||||
final client = _getClientForMetadata();
|
||||
if (client == null) return;
|
||||
|
||||
final chapters = await client.getChapters(widget.metadata.ratingKey);
|
||||
if (mounted) {
|
||||
@@ -359,7 +358,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
Future<void> _loadMarkers() async {
|
||||
final client = _getClientForMetadata();
|
||||
if (client == null) return;
|
||||
|
||||
final markers = await client.getMarkers(widget.metadata.ratingKey);
|
||||
|
||||
|
||||
@@ -17,6 +17,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.7.1"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -217,6 +233,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
csv:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -225,6 +249,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
dart_code_linter:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: dart_code_linter
|
||||
sha256: "9456e0a7508b0d76be301dc2f73be37e601019e85c5f59e6a5cc460af90f7dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,6 +448,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -801,6 +841,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -817,6 +865,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pub_updater:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_updater
|
||||
sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -75,6 +75,7 @@ dev_dependencies:
|
||||
json_serializable: ^6.7.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
slang_build_runner: ^3.31.0
|
||||
dart_code_linter: ^3.1.1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
Reference in New Issue
Block a user