refactor: clean up

This commit is contained in:
edde746
2025-11-22 07:54:52 +01:00
parent 3945aa7b58
commit 19601e87c7
23 changed files with 78 additions and 1243 deletions
+6
View File
@@ -4,3 +4,9 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
plugins:
- dart_code_linter
dart_code_linter:
rules:
- avoid-unused-parameters
-60
View File
@@ -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;
}
-31
View File
@@ -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);
}
-31
View File
@@ -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();
-50
View File
@@ -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,
+5 -16
View File
@@ -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,
-356
View File
@@ -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) {
-31
View File
@@ -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;
}
}
-170
View File
@@ -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,
);
}
}
-112
View File
@@ -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;
-7
View File
@@ -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!),
+6 -13
View File
@@ -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
-88
View File
@@ -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);
}
}
-88
View File
@@ -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);
}
}
+1 -56
View File
@@ -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);
+56
View File
@@ -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:
+1
View File
@@ -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