diff --git a/lib/mixins/library_tab_state.dart b/lib/mixins/library_tab_state.dart new file mode 100644 index 00000000..426e1486 --- /dev/null +++ b/lib/mixins/library_tab_state.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; + +/// Mixin providing common state management for library tab screens +/// Standardizes loading, error handling, and lifecycle management +mixin LibraryTabStateMixin on State { + /// The list of items to display + List get items; + set items(List value); + + /// Whether data is currently loading + bool get isLoading; + set isLoading(bool value); + + /// Error message if loading failed + String? get errorMessage; + set errorMessage(String? value); + + /// The library being displayed + PlexLibrary get library; + + /// Load or reload the content + Future loadContent(); + + /// Common lifecycle: reload if library changed + @mustCallSuper + void didUpdateLibrary(PlexLibrary oldLibrary) { + if (oldLibrary.key != library.key) { + loadContent(); + } + } + + /// Helper to set loading state + void setLoadingState(bool loading) { + if (mounted) { + setState(() { + isLoading = loading; + }); + } + } + + /// Helper to set error state + void setErrorState(String? error) { + if (mounted) { + setState(() { + errorMessage = error; + isLoading = false; + }); + } + } + + /// Helper to set success state with items + void setSuccessState(List newItems) { + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + errorMessage = null; + }); + } + } +} diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart index e568718f..27fe3c86 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -23,9 +23,10 @@ class PlexHub { factory PlexHub.fromJson(Map json) { final metadataList = []; - // Hubs can contain either Metadata or Directory entries - if (json['Metadata'] != null) { - for (final item in json['Metadata'] as List) { + // Helper function to parse entries from a JSON list + void parseEntries(List? entries) { + if (entries == null) return; + for (final item in entries) { try { metadataList.add(PlexMetadata.fromJson(item)); } catch (e) { @@ -34,15 +35,9 @@ class PlexHub { } } - if (json['Directory'] != null) { - for (final item in json['Directory'] as List) { - try { - metadataList.add(PlexMetadata.fromJson(item)); - } catch (e) { - // Skip items that fail to parse - } - } - } + // Hubs can contain either Metadata or Directory entries + parseEntries(json['Metadata'] as List?); + parseEntries(json['Directory'] as List?); return PlexHub( hubKey: json['key'] as String? ?? '', diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index a6c2bfa7..6e05cf73 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -12,13 +12,40 @@ class PlexMediaInfo { }); } -class PlexAudioTrack { +/// Mixin for building track labels with a consistent pattern +mixin TrackLabelBuilder { + int get id; + int? get index; + String? get displayTitle; + String? get language; + + /// Builds a label from the given parts + /// If displayTitle is present, returns it + /// Otherwise, combines language and additional parts + String buildLabel(List additionalParts) { + if (displayTitle != null && displayTitle!.isNotEmpty) { + return displayTitle!; + } + final parts = []; + if (language != null && language!.isNotEmpty) { + parts.add(language!); + } + parts.addAll(additionalParts); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexAudioTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final int? channels; final bool selected; @@ -36,22 +63,24 @@ class PlexAudioTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (codec != null) parts.add(codec!.toUpperCase()); - if (channels != null) parts.add('${channels!}ch'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (codec != null) additionalParts.add(codec!.toUpperCase()); + if (channels != null) additionalParts.add('${channels!}ch'); + return buildLabel(additionalParts); } } -class PlexSubtitleTrack { +class PlexSubtitleTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final bool selected; final bool forced; @@ -71,11 +100,9 @@ class PlexSubtitleTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (forced) parts.add('Forced'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (forced) additionalParts.add('Forced'); + return buildLabel(additionalParts); } /// Returns true if this subtitle track is an external file (sidecar subtitle) diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart new file mode 100644 index 00000000..c0a4d63f --- /dev/null +++ b/lib/screens/base_media_list_detail_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/collection_playlist_play_helper.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; + +/// Abstract base class for screens displaying media lists (collections/playlists) +/// Provides common state management and playback functionality +abstract class BaseMediaListDetailScreen + extends State with Refreshable, ItemUpdatable { + // State properties - concrete implementations to avoid duplication + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + List get items => _items; + set items(List value) => _items = value; + + bool get isLoading => _isLoading; + set isLoading(bool value) => _isLoading = value; + + String? get errorMessage => _errorMessage; + set errorMessage(String? value) => _errorMessage = value; + + @override + PlexClient get client => context.clientSafe; + + /// The media item being displayed (collection or playlist) + dynamic get mediaItem; + + /// Title to display in app bar + String get title; + + /// Message to show when list is empty + String get emptyMessage; + + @override + void initState() { + super.initState(); + loadItems(); + } + + /// Load or reload the items (subclasses implement this) + Future loadItems(); + + /// Play all items in the list + Future playItems() => _playWithShuffle(false); + + /// Shuffle play all items in the list + Future shufflePlayItems() => _playWithShuffle(true); + + /// Internal helper to play items with optional shuffle + Future _playWithShuffle(bool shuffle) async { + if (items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(emptyMessage)), + ); + } + return; + } + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: mediaItem, + shuffle: shuffle, + ); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + if (mounted) { + setState(() { + final index = items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + items[index] = updatedMetadata; + } + }); + } + } + + @override + void refresh() { + loadItems(); + } +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 18bdc683..376fd69d 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,18 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/collection_playlist_play_helper.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; import '../utils/grid_size_calculator.dart'; +import '../utils/dialogs.dart'; +import '../utils/provider_extensions.dart'; +import 'base_media_list_detail_screen.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -24,112 +21,49 @@ class CollectionDetailScreen extends StatefulWidget { State createState() => _CollectionDetailScreenState(); } -class _CollectionDetailScreenState extends State - with Refreshable, ItemUpdatable { +class _CollectionDetailScreenState + extends BaseMediaListDetailScreen { @override - PlexClient get client => context.clientSafe; - - List _items = []; - bool _isLoading = false; - String? _errorMessage; + PlexMetadata get mediaItem => widget.collection; @override - void initState() { - super.initState(); - _loadCollectionItems(); - } + String get title => widget.collection.title; - Future _loadCollectionItems() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + @override + String get emptyMessage => t.collections.empty; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); + final client = this.client; + final newItems = await client.getCollectionItems(widget.collection.ratingKey); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); } - final items = await client.getCollectionItems(widget.collection.ratingKey); - - setState(() { - _items = items; - _isLoading = false; - }); - appLogger.d( - 'Loaded ${items.length} items for collection: ${widget.collection.title}', + 'Loaded ${newItems.length} items for collection: ${widget.collection.title}', ); } catch (e) { appLogger.e('Failed to load collection items', error: e); - setState(() { - _errorMessage = t.collections.failedToLoadItems( - error: e.toString(), - ); - _isLoading = false; - }); - } - } - - @override - void refresh() { - _loadCollectionItems(); - } - - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - setState(() { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } - }); - } - - Future _playCollection() async { - if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.empty)), - ); + setState(() { + errorMessage = t.collections.failedToLoadItems(error: e.toString()); + isLoading = false; + }); } - return; } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.collection, - shuffle: false, - ); - } - - Future _shufflePlayCollection() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.empty)), - ); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.collection, - shuffle: true, - ); } Future _deleteCollection() async { @@ -137,8 +71,8 @@ class _CollectionDetailScreenState extends State int? sectionId = widget.collection.librarySectionID; // If collection doesn't have it, try to get it from loaded items - if (sectionId == null && _items.isNotEmpty) { - sectionId = _items.first.librarySectionID; + if (sectionId == null && items.isNotEmpty) { + sectionId = items.first.librarySectionID; } if (sectionId == null) { @@ -151,28 +85,14 @@ class _CollectionDetailScreenState extends State } // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.collections.deleteCollection), - content: Text( - t.collections.deleteConfirm(title: widget.collection.title), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(t.common.delete), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.collections.deleteCollection, + message: t.collections.deleteConfirm(title: widget.collection.title), ); if (confirmed != true) return; + if (!mounted) return; try { final clientProvider = context.plexClient; @@ -184,6 +104,8 @@ class _CollectionDetailScreenState extends State widget.collection.ratingKey, ); + if (!mounted) return; + if (mounted) { if (success) { ScaffoldMessenger.of(context).showSnackBar( @@ -221,18 +143,18 @@ class _CollectionDetailScreenState extends State pinned: true, actions: [ // Play button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.play_arrow), tooltip: t.discover.play, - onPressed: _playCollection, + onPressed: playItems, ), // Shuffle button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.shuffle), tooltip: t.common.shuffle, - onPressed: _shufflePlayCollection, + onPressed: shufflePlayItems, ), // Delete button IconButton( @@ -243,7 +165,7 @@ class _CollectionDetailScreenState extends State ), ], ), - if (_errorMessage != null) + if (errorMessage != null) SliverFillRemaining( child: Center( child: Column( @@ -255,21 +177,21 @@ class _CollectionDetailScreenState extends State color: Colors.red, ), const SizedBox(height: 16), - Text(_errorMessage!), + Text(errorMessage!), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadCollectionItems, + onPressed: loadItems, child: Text(t.common.retry), ), ], ), ), ) - else if (_items.isEmpty && _isLoading) + else if (items.isEmpty && isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_items.isEmpty) + else if (items.isEmpty) SliverFillRemaining( child: Center( child: Text(t.collections.noItems), @@ -292,16 +214,16 @@ class _CollectionDetailScreenState extends State ), delegate: SliverChildBuilderDelegate( (context, index) { - final item = _items[index]; + final item = items[index]; return MediaCard( key: Key(item.ratingKey), item: item, onRefresh: updateItem, collectionId: widget.collection.ratingKey, - onListRefresh: _loadCollectionItems, + onListRefresh: loadItems, ); }, - childCount: _items.length, + childCount: items.length, ), ); }, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 532bed37..19c32668 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -4,24 +4,18 @@ import 'package:dio/dio.dart'; import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; -import '../providers/plex_client_provider.dart'; -import '../providers/settings_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../widgets/app_bar_back_button.dart'; import '../widgets/context_menu_wrapper.dart'; -import '../widgets/filters_bottom_sheet.dart'; -import '../widgets/sort_bottom_sheet.dart'; import '../services/storage_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +import '../utils/error_message_utils.dart'; import 'library_tabs/library_browse_tab.dart'; import 'library_tabs/library_recommended_tab.dart'; import 'library_tabs/library_collections_tab.dart'; @@ -53,14 +47,9 @@ class _LibrariesScreenState extends State String? _selectedLibraryKey; bool _isInitialLoad = true; - // Legacy fields kept for compatibility with existing methods - // TODO: Clean up after refactoring _loadLibraryContent - List _filters = []; - List _sortOptions = []; Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; - bool _isLoadingItems = false; List _items = []; int _currentPage = 0; bool _hasMoreItems = true; @@ -95,28 +84,18 @@ class _LibrariesScreenState extends State super.dispose(); } + void _updateState(VoidCallback fn) { + if (!mounted) return; + setState(fn); + } + /// Helper method to get user-friendly error message from exception String _getErrorMessage(dynamic error, String context) { if (error is DioException) { - // Other Dio errors - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: - return t.errors.connectionTimeout(context: context); - case DioExceptionType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading $context', error: error); - return t.errors.failedToLoad( - context: context, - error: error.message ?? 'Unknown error', - ); - } + return mapDioErrorToMessage(error, context: context); } - // Generic error - appLogger.e('Unexpected error in $context', error: error); - return t.errors.failedToLoad(context: context, error: error.toString()); + return mapUnexpectedErrorToMessage(error, context: context); } Future _loadLibraries() async { @@ -154,7 +133,7 @@ class _LibrariesScreenState extends State savedOrder, ); - setState(() { + _updateState(() { _allLibraries = orderedLibraries; // Store all libraries with ordering applied _isLoadingLibraries = false; @@ -187,7 +166,7 @@ class _LibrariesScreenState extends State libraryKeyToLoad = visibleLibraries.first.key; } - if (libraryKeyToLoad != null) { + if (libraryKeyToLoad != null && mounted) { final savedFilters = storage.getLibraryFilters(sectionId: libraryKeyToLoad); if (savedFilters.isNotEmpty) { @@ -197,7 +176,7 @@ class _LibrariesScreenState extends State } } } catch (e) { - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'libraries'); _isLoadingLibraries = false; }); @@ -267,16 +246,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - setState(() { + _updateState(() { _errorMessage = t.errors.noClientAvailable; - _isLoadingItems = false; }); return; } - setState(() { + _updateState(() { _selectedLibraryKey = libraryKey; - _isLoadingItems = true; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) if (isChangingLibrary) { @@ -296,7 +273,7 @@ class _LibrariesScreenState extends State // Restore saved tab index for this library final savedTabIndex = storage.getLibraryTab(libraryKey); if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { - setState(() { + _updateState(() { _tabController.index = savedTabIndex; }); } @@ -315,24 +292,17 @@ class _LibrariesScreenState extends State final currentRequestId = ++_requestId; // Reset pagination state - setState(() { + _updateState(() { _currentPage = 0; _hasMoreItems = true; _items = []; }); try { - // Load filters and sort options for the new library - _loadFilters(libraryKey); + // Load sort options for the new library await _loadSortOptions(libraryKey); - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } + final filtersWithSort = _buildFiltersWithSort(); // Load pages sequentially await _loadAllPagesSequentially( @@ -347,9 +317,8 @@ class _LibrariesScreenState extends State return; } - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'library content'); - _isLoadingItems = false; }); } } @@ -376,15 +345,10 @@ class _LibrariesScreenState extends State return; // Request was superseded } - setState(() { + _updateState(() { _items.addAll(items); _currentPage++; _hasMoreItems = items.length >= _pageSize; - - // Mark as not loading if this is the last page - if (!_hasMoreItems) { - _isLoadingItems = false; - } }); } catch (e) { // Check if it's a cancellation @@ -393,8 +357,7 @@ class _LibrariesScreenState extends State } // For other errors, update state and rethrow - setState(() { - _isLoadingItems = false; + _updateState(() { _hasMoreItems = false; }); rethrow; @@ -402,26 +365,6 @@ class _LibrariesScreenState extends State } } - Future _loadFilters(String libraryKey) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - final filters = await client.getLibraryFilters(libraryKey); - setState(() { - _filters = filters; - }); - } catch (e) { - appLogger.w('Failed to load filters', error: e); - setState(() { - _filters = []; - }); - } - } - Future _loadSortOptions(String libraryKey) async { try { final clientProvider = context.plexClient; @@ -455,85 +398,26 @@ class _LibrariesScreenState extends State savedSort = sortOptions.first; } - setState(() { - _sortOptions = sortOptions; + _updateState(() { _selectedSort = savedSort; _isSortDescending = descending; }); } catch (e) { - setState(() { - _sortOptions = []; + _updateState(() { _selectedSort = null; _isSortDescending = false; }); } } - Future _applyFilters() async { - // Cancel any existing requests - _cancelToken?.cancel(); - _cancelToken = CancelToken(); - final currentRequestId = ++_requestId; - - setState(() { - _isLoadingItems = true; - _errorMessage = null; - _currentPage = 0; - _hasMoreItems = true; - _items = []; - }); - - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } - - // Load pages sequentially - await _loadAllPagesSequentially( - _selectedLibraryKey!, - filtersWithSort, - currentRequestId, - client, + Map _buildFiltersWithSort() { + final filtersWithSort = Map.from(_selectedFilters); + if (_selectedSort != null) { + filtersWithSort['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, ); - } catch (e) { - // Ignore cancellation errors - if (e is DioException && e.type == DioExceptionType.cancel) { - return; - } - - setState(() { - _errorMessage = t.messages.errorLoading(error: e.toString()); - _isLoadingItems = false; - }); } - } - - Future _applySort(PlexSort sort, bool descending) async { - setState(() { - _selectedSort = sort; - _isSortDescending = descending; - }); - - // Save sort preference for this library - final storage = await StorageService.getInstance(); - await storage.saveLibrarySort( - _selectedLibraryKey!, - sort.key, - descending: descending, - ); - - // Reload content with new sort - _applyFilters(); + return filtersWithSort; } @override @@ -554,27 +438,27 @@ class _LibrariesScreenState extends State void _refreshCurrentTab() { switch (_tabController.index) { case 0: // Recommended tab - final state = _recommendedTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _recommendedTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 1: // Browse tab - final state = _browseTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _browseTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 2: // Collections tab - final state = _collectionsTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _collectionsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 3: // Playlists tab - final state = _playlistsTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _playlistsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; } @@ -713,7 +597,13 @@ class _LibrariesScreenState extends State ); } - Future _scanLibrary(PlexLibrary library) async { + Future _performLibraryAction({ + required PlexLibrary library, + required Future Function(PlexClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, + }) async { try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -721,32 +611,31 @@ class _LibrariesScreenState extends State throw Exception(t.errors.noClientAvailable); } - // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanning(title: library.title)), + content: Text(progressMessage), duration: const Duration(seconds: 2), ), ); } - await client.scanLibrary(library.key); + await action(client); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanStarted(title: library.title)), + content: Text(successMessage), duration: const Duration(seconds: 3), ), ); } } catch (e) { - appLogger.e('Failed to scan library', error: e); + appLogger.e('Library action failed', error: e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanFailed(error: e.toString())), + content: Text(failureMessage(e)), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -755,134 +644,46 @@ class _LibrariesScreenState extends State } } + Future _scanLibrary(PlexLibrary library) async { + return _performLibraryAction( + library: library, + action: (client) => client.scanLibrary(library.key), + progressMessage: t.messages.libraryScanning(title: library.title), + successMessage: t.messages.libraryScanStarted(title: library.title), + failureMessage: (error) => + t.messages.libraryScanFailed(error: error.toString()), + ); + } + Future _refreshLibraryMetadata(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.metadataRefreshing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.refreshLibraryMetadata(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshStarted(title: library.title), - ), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to refresh library metadata', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshFailed(error: e.toString()), - ), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.refreshLibraryMetadata(library.key), + progressMessage: t.messages.metadataRefreshing(title: library.title), + successMessage: t.messages.metadataRefreshStarted(title: library.title), + failureMessage: (error) => + t.messages.metadataRefreshFailed(error: error.toString()), + ); } Future _emptyLibraryTrash(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.emptyingTrash(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.emptyLibraryTrash(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.trashEmptied(title: library.title)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to empty library trash', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToEmptyTrash(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.emptyLibraryTrash(library.key), + progressMessage: t.libraries.emptyingTrash(title: library.title), + successMessage: t.libraries.trashEmptied(title: library.title), + failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), + ); } Future _analyzeLibrary(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analyzing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.analyzeLibrary(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analysisStarted(title: library.title)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to analyze library', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToAnalyze(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.analyzeLibrary(library.key), + progressMessage: t.libraries.analyzing(title: library.title), + successMessage: t.libraries.analysisStarted(title: library.title), + failureMessage: (error) => t.libraries.failedToAnalyze(error: error), + ); } Widget _buildTabChip(String label, int index) { diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index b2234110..2fd87712 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -9,15 +9,14 @@ import '../../models/plex_sort.dart'; import '../../providers/plex_client_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/provider_extensions.dart'; -import '../../utils/app_logger.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/grid_size_calculator.dart'; import '../../widgets/media_card.dart'; -import '../../widgets/app_bar_back_button.dart'; import '../../widgets/folder_tree_view.dart'; import '../../widgets/filters_bottom_sheet.dart'; import '../../widgets/sort_bottom_sheet.dart'; import '../../services/storage_service.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../services/settings_service.dart' show ViewMode; import '../../mixins/item_updatable.dart'; import '../../mixins/refreshable.dart'; import '../../i18n/strings.g.dart'; @@ -301,24 +300,11 @@ class _LibraryBrowseTabState extends State String _getErrorMessage(dynamic error) { if (error is DioException) { - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: - return t.errors.connectionTimeout(context: t.libraries.content); - case DioExceptionType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading library content', error: error); - return t.errors.failedToLoad( - context: t.libraries.content, - error: error.message ?? t.common.unknown, - ); - } + return mapDioErrorToMessage(error, context: t.libraries.content); } - appLogger.e('Unexpected error loading library content', error: error); - return t.errors.failedToLoad( + return mapUnexpectedErrorToMessage( + error, context: t.libraries.content, - error: error.toString(), ); } diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index 37207f84..f5e0f6a4 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -4,14 +4,12 @@ import 'package:provider/provider.dart'; import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; import '../../providers/plex_client_provider.dart'; -import '../../providers/settings_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; -import '../../widgets/media_card.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; -import '../../utils/grid_size_calculator.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; +import '../../widgets/adaptive_media_grid.dart'; /// Collections tab for library screen /// Shows collections for the current library @@ -113,82 +111,19 @@ class _LibraryCollectionsTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _collections.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _collections.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadCollections, - child: Text(t.common.retry), - ), - ], + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _collections, + emptyIcon: Icons.collections, + emptyMessage: t.libraries.noCollections, + onRetry: _loadCollections, + builder: (items) => RefreshIndicator( + onRefresh: _loadCollections, + child: AdaptiveMediaGrid( + items: items, + onRefresh: _loadCollections, ), - ); - } - - if (_collections.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.collections, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.libraries.noCollections), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadCollections, - child: Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _collections.length, - itemBuilder: (context, index) { - final collection = _collections[index]; - return MediaCard( - key: Key(collection.ratingKey), - item: collection, - onListRefresh: _loadCollections, - ); - }, - ); - } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: _collections.length, - itemBuilder: (context, index) { - final collection = _collections[index]; - return MediaCard( - key: Key(collection.ratingKey), - item: collection, - onListRefresh: _loadCollections, - ); - }, - ); - } - }, ), ); } diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index f2d0e2b4..8ef5156b 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -7,11 +7,12 @@ import '../../providers/plex_client_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../services/settings_service.dart' show ViewMode; import '../../utils/grid_size_calculator.dart'; import '../../widgets/media_card.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library @@ -117,82 +118,55 @@ class _LibraryPlaylistsTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _playlists.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _playlists.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadPlaylists, - child: Text(t.common.retry), - ), - ], - ), - ); - } - - if (_playlists.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.playlist_play, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.playlists.noPlaylists), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadPlaylists, - child: Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _playlists.length, - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return MediaCard( - key: Key(playlist.ratingKey), - item: playlist, - onListRefresh: _loadPlaylists, - ); - }, - ); - } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _playlists, + emptyIcon: Icons.playlist_play, + emptyMessage: t.playlists.noPlaylists, + onRetry: _loadPlaylists, + builder: (items) => RefreshIndicator( + onRefresh: _loadPlaylists, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: _playlists.length, - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return MediaCard( - key: Key(playlist.ratingKey), - item: playlist, - onListRefresh: _loadPlaylists, - ); - }, - ); - } - }, + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } + }, + ), ), ); } diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 53b82423..84ae7c77 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -7,6 +7,7 @@ import '../../utils/app_logger.dart'; import '../../widgets/hub_section.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; /// Recommended tab for library screen /// Shows library-specific hubs and recommendations @@ -109,53 +110,26 @@ class _LibraryRecommendedTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _hubs.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _hubs.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadHubs, - child: Text(t.common.retry), - ), - ], + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _hubs, + emptyIcon: Icons.recommend, + emptyMessage: t.libraries.noRecommendations, + onRetry: _loadHubs, + builder: (items) => RefreshIndicator( + onRefresh: _loadHubs, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + return HubSection( + hub: hub, + icon: _getHubIcon(hub), + ); + }, ), - ); - } - - if (_hubs.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.recommend, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.libraries.noRecommendations), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadHubs, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _hubs.length, - itemBuilder: (context, index) { - final hub = _hubs[index]; - return HubSection( - hub: hub, - icon: _getHubIcon(hub), - ); - }, ), ); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index e75851a2..5af3ed2f 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -1,21 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; -import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/collection_playlist_play_helper.dart'; +import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/grid_size_calculator.dart'; import '../widgets/media_card.dart'; import '../widgets/playlist_item_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; +import '../utils/dialogs.dart'; +import 'base_media_list_detail_screen.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -27,71 +24,56 @@ class PlaylistDetailScreen extends StatefulWidget { State createState() => _PlaylistDetailScreenState(); } -class _PlaylistDetailScreenState extends State - with Refreshable, ItemUpdatable { +class _PlaylistDetailScreenState + extends BaseMediaListDetailScreen { @override - PlexClient get client => context.clientSafe; - - List _items = []; - bool _isLoading = false; - String? _errorMessage; + dynamic get mediaItem => widget.playlist; @override - void initState() { - super.initState(); - _loadPlaylistItems(); - } + String get title => widget.playlist.title; - Future _loadPlaylistItems() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + @override + String get emptyMessage => t.playlists.emptyPlaylist; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); + final client = this.client; + final newItems = await client.getPlaylist(widget.playlist.ratingKey); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); } - final items = await client.getPlaylist(widget.playlist.ratingKey); - - setState(() { - _items = items; - _isLoading = false; - }); - appLogger.d( - 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + 'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}', ); } catch (e) { appLogger.e('Failed to load playlist items', error: e); - setState(() { - _errorMessage = 'Failed to load playlist items: ${e.toString()}'; - _isLoading = false; - }); + if (mounted) { + setState(() { + errorMessage = 'Failed to load playlist items: ${e.toString()}'; + isLoading = false; + }); + } } } Future _deletePlaylist() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.playlists.deleteConfirm, + message: t.playlists.deleteMessage(name: widget.playlist.title), ); if (confirmed == true && mounted) { @@ -121,7 +103,7 @@ class _PlaylistDetailScreenState extends State // Can't reorder if indices are the same if (oldIndex == newIndex) return; - final movedItem = _items[oldIndex]; + final movedItem = items[oldIndex]; // Check if item has playlistItemID (required for reordering) if (movedItem.playlistItemID == null) { @@ -141,7 +123,7 @@ class _PlaylistDetailScreenState extends State if (newIndex == 0) { afterPlaylistItemId = 0; // Move to top } else { - final afterItem = _items[newIndex - 1]; + final afterItem = items[newIndex - 1]; if (afterItem.playlistItemID == null) { appLogger.e('Cannot reorder: after item missing playlistItemID'); if (mounted) { @@ -160,8 +142,8 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - final item = _items.removeAt(oldIndex); - _items.insert(newIndex, item); + final item = items.removeAt(oldIndex); + items.insert(newIndex, item); }); // Call API to persist the change @@ -176,8 +158,8 @@ class _PlaylistDetailScreenState extends State appLogger.e('Failed to reorder playlist item, reverting UI'); if (mounted) { setState(() { - final item = _items.removeAt(newIndex); - _items.insert(oldIndex, item); + final item = items.removeAt(newIndex); + items.insert(oldIndex, item); }); ScaffoldMessenger.of( @@ -188,7 +170,7 @@ class _PlaylistDetailScreenState extends State } Future _removeItem(int index) async { - final item = _items[index]; + final item = items[index]; // Check if item has playlistItemID (required for removal) if (item.playlistItemID == null) { @@ -207,7 +189,7 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - _items.removeAt(index); + items.removeAt(index); }); // Call API to persist the change @@ -225,7 +207,7 @@ class _PlaylistDetailScreenState extends State // Revert on failure appLogger.e('Failed to remove playlist item, reverting UI'); setState(() { - _items.insert(index, item); + items.insert(index, item); }); ScaffoldMessenger.of( @@ -235,72 +217,16 @@ class _PlaylistDetailScreenState extends State } } - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } - } - - @override - void refresh() { - _loadPlaylistItems(); - } - - Future _playPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.playlist, - shuffle: false, - ); - } - - Future _shufflePlayPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.playlist, - shuffle: true, - ); - } Future _playFromItem(int index) async { - if (_items.isEmpty || index < 0 || index >= _items.length) return; + if (items.isEmpty || index < 0 || index >= items.length) return; try { final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) return; - final selectedItem = _items[index]; + final selectedItem = items[index]; // Create play queue from playlist, starting at the selected item final playQueue = await client.createPlayQueue( @@ -385,18 +311,18 @@ class _PlaylistDetailScreenState extends State pinned: true, actions: [ // Play button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.play_arrow), tooltip: t.discover.play, - onPressed: _playPlaylist, + onPressed: playItems, ), // Shuffle button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.shuffle), tooltip: t.playlists.shuffle, - onPressed: _shufflePlayPlaylist, + onPressed: shufflePlayItems, ), // Delete button for non-smart playlists if (!widget.playlist.smart) @@ -408,7 +334,7 @@ class _PlaylistDetailScreenState extends State ), ], ), - if (_errorMessage != null) + if (errorMessage != null) SliverFillRemaining( child: Center( child: Column( @@ -420,21 +346,21 @@ class _PlaylistDetailScreenState extends State color: Colors.red, ), const SizedBox(height: 16), - Text(_errorMessage!), + Text(errorMessage!), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadPlaylistItems, + onPressed: loadItems, child: Text(t.common.retry), ), ], ), ), ) - else if (_items.isEmpty && _isLoading) + else if (items.isEmpty && isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_items.isEmpty) + else if (items.isEmpty) SliverFillRemaining( child: Center( child: Column( @@ -460,7 +386,7 @@ class _PlaylistDetailScreenState extends State padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, context.watch().libraryDensity, ), @@ -469,15 +395,15 @@ class _PlaylistDetailScreenState extends State mainAxisSpacing: 0, ), delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard(item: _items[index], onRefresh: updateItem); - }, childCount: _items.length), + return MediaCard(item: items[index], onRefresh: updateItem); + }, childCount: items.length), ), ) else // Regular playlists: Use reorderable list view SliverReorderableList( itemBuilder: (context, index) { - final item = _items[index]; + final item = items[index]; return PlaylistItemCard( key: ValueKey(item.playlistItemID ?? item.ratingKey), item: item, @@ -487,75 +413,11 @@ class _PlaylistDetailScreenState extends State canReorder: !widget.playlist.smart, ); }, - itemCount: _items.length, + itemCount: items.length, onReorder: _onReorder, ), ], ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 16166ef8..26fc1fd8 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -3,9 +3,10 @@ import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_size_calculator.dart'; +import '../utils/dialogs.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; @@ -141,7 +142,7 @@ class _PlaylistsScreenState extends State with Refreshable { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, context.watch().libraryDensity, ), @@ -170,70 +171,6 @@ class _PlaylistsScreenState extends State with Refreshable { ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } } /// Widget to display a single playlist card @@ -249,23 +186,10 @@ class _PlaylistCard extends StatelessWidget { }); Future _showDeleteDialog(BuildContext context) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.playlists.deleteConfirm, + message: t.playlists.deleteMessage(name: playlist.title), ); if (confirmed == true && context.mounted) { diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 8f142ca2..2740e7a7 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dio/dio.dart'; import '../i18n/strings.g.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; @@ -9,6 +10,7 @@ import '../widgets/server_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../utils/error_message_utils.dart'; import 'main_screen.dart'; class ServerSelectionScreen extends StatefulWidget { @@ -74,6 +76,12 @@ class _ServerSelectionScreenState extends State { } String _getErrorMessage(dynamic error) { + if (error is DioException) { + return mapDioErrorToMessage( + error, + context: t.serverSelection.noServersFound, + ); + } if (error is ServerParsingException) { return t.serverSelection.malformedServerData( count: error.invalidServerData.length, diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart new file mode 100644 index 00000000..ac1840a5 --- /dev/null +++ b/lib/utils/dialogs.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// Utility functions for showing common dialogs + +/// Shows a delete confirmation dialog +/// Returns true if user confirmed, false if cancelled +Future showDeleteConfirmation( + BuildContext context, { + required String title, + required String message, +}) async { + final confirmed = await showDialog( + 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: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + return confirmed ?? false; +} diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart new file mode 100644 index 00000000..7290d374 --- /dev/null +++ b/lib/utils/error_message_utils.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import '../i18n/strings.g.dart'; +import 'app_logger.dart'; + +/// Shared helpers for translating network errors into user-friendly messages. +String mapDioErrorToMessage(DioException error, {required String context}) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + return t.errors.connectionTimeout(context: context); + case DioExceptionType.connectionError: + return t.errors.connectionFailed; + default: + appLogger.e('Error loading $context', error: error); + return t.errors.failedToLoad( + context: context, + error: error.message ?? t.common.unknown, + ); + } +} + +/// Generic fallback for unexpected errors. +String mapUnexpectedErrorToMessage(dynamic error, {required String context}) { + appLogger.e('Unexpected error in $context', error: error); + return t.errors.failedToLoad( + context: context, + error: error.toString(), + ); +} diff --git a/lib/widgets/adaptive_media_grid.dart b/lib/widgets/adaptive_media_grid.dart new file mode 100644 index 00000000..cf8b37eb --- /dev/null +++ b/lib/widgets/adaptive_media_grid.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart' show ViewMode; +import '../utils/grid_size_calculator.dart'; +import 'media_card.dart'; + +/// A widget that automatically switches between grid and list view +/// based on user settings, providing a consistent layout pattern +/// across all library screens +class AdaptiveMediaGrid extends StatelessWidget { + /// The list of media items to display + final List items; + + /// Callback when the list needs to be refreshed + final VoidCallback? onRefresh; + + /// Optional padding around the grid/list + final EdgeInsets padding; + + /// Child aspect ratio for grid items (width / height) + final double childAspectRatio; + + const AdaptiveMediaGrid({ + super.key, + required this.items, + this.onRefresh, + this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), + this.childAspectRatio = 2 / 3.3, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: padding, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } else { + return GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } + }, + ); + } +} diff --git a/lib/widgets/content_state_builder.dart b/lib/widgets/content_state_builder.dart new file mode 100644 index 00000000..0cae5805 --- /dev/null +++ b/lib/widgets/content_state_builder.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// A widget that handles loading, error, empty, and content states +/// Provides a consistent UI pattern across the app for data-driven screens +class ContentStateBuilder extends StatelessWidget { + /// Whether data is currently loading + final bool isLoading; + + /// Error message to display (null if no error) + final String? errorMessage; + + /// The list of items to display + final List items; + + /// Icon to display when the list is empty + final IconData emptyIcon; + + /// Message to display when the list is empty + final String emptyMessage; + + /// Callback when user taps retry button + final VoidCallback onRetry; + + /// Builder for the content when items are available + final Widget Function(List items) builder; + + const ContentStateBuilder({ + super.key, + required this.isLoading, + required this.errorMessage, + required this.items, + required this.emptyIcon, + required this.emptyMessage, + required this.onRetry, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + // Loading state (only show loading indicator if items list is empty) + if (isLoading && items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + // Error state (only show error if items list is empty) + if (errorMessage != null && items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onRetry, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + // Empty state + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(emptyIcon, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + emptyMessage, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ); + } + + // Content state - delegate to builder + return builder(items); + } +} diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 40354f17..e28043f7 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { @@ -9,27 +10,13 @@ class AudioTrackSheet extends StatelessWidget { const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(AudioTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -46,108 +33,73 @@ class AudioTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.audiotrack, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.audioLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.audioLabel, + icon: Icons.audiotrack, + child: audioTracks.isEmpty + ? const Center( + child: Text( + 'No audio tracks available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (audioTracks.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No audio tracks available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.audio; - final selectedId = selectedTrack.id; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.audio; + final selectedId = selectedTrack.id; - return ListView.builder( - itemCount: audioTracks.length, - itemBuilder: (context, index) { - final audioTrack = audioTracks[index]; - final isSelected = audioTrack.id == selectedId; + return ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final audioTrack = audioTracks[index]; + final isSelected = audioTrack.id == selectedId; - final parts = []; - if (audioTrack.title != null && - audioTrack.title!.isNotEmpty) { - parts.add(audioTrack.title!); - } - if (audioTrack.language != null && - audioTrack.language!.isNotEmpty) { - parts.add(audioTrack.language!.toUpperCase()); - } - if (audioTrack.codec != null && - audioTrack.codec!.isNotEmpty) { - parts.add(audioTrack.codec!.toUpperCase()); - } - if (audioTrack.channelscount != null) { - parts.add('${audioTrack.channelscount}ch'); - } + final parts = []; + if (audioTrack.title != null && + audioTrack.title!.isNotEmpty) { + parts.add(audioTrack.title!); + } + if (audioTrack.language != null && + audioTrack.language!.isNotEmpty) { + parts.add(audioTrack.language!.toUpperCase()); + } + if (audioTrack.codec != null && + audioTrack.codec!.isNotEmpty) { + parts.add(audioTrack.codec!.toUpperCase()); + } + if (audioTrack.channelscount != null) { + parts.add('${audioTrack.channelscount}ch'); + } - final label = parts.isEmpty - ? 'Audio Track ${index + 1}' - : parts.join(' · '); + final label = parts.isEmpty + ? 'Audio Track ${index + 1}' + : parts.join(' · '); - 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.setAudioTrack(audioTrack); - onTrackChanged?.call(audioTrack); - Navigator.pop(context); - }, - ); + 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.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart new file mode 100644 index 00000000..d02a875a --- /dev/null +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +/// Base class for video control bottom sheets providing common UI structure +class BaseVideoControlSheet extends StatelessWidget { + final String title; + final IconData icon; + final Widget child; + final Color? iconColor; + + const BaseVideoControlSheet({ + super.key, + required this.title, + required this.icon, + required this.child, + this.iconColor, + }); + + /// Get consistent bottom sheet constraints across all video control sheets + static BoxConstraints getBottomSheetConstraints(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + /// Helper method to show a modal bottom sheet with consistent styling + static Future showSheet({ + required BuildContext context, + required WidgetBuilder builder, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: getBottomSheetConstraints(context), + builder: builder, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + _buildHeader(context), + const Divider(color: Colors.white24, height: 1), + Expanded(child: child), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: iconColor ?? Colors.white), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index f77ea04a..a89a9e53 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters class ChapterSheet extends StatelessWidget { @@ -17,28 +18,14 @@ class ChapterSheet extends StatelessWidget { required this.chaptersLoaded, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, List chapters, bool chaptersLoaded, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => ChapterSheet( player: player, chapters: chapters, @@ -73,8 +60,7 @@ class ChapterSheet extends StatelessWidget { for (int i = 0; i < chapters.length; i++) { final chapter = chapters[i]; final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? + final endMs = chapter.endTimeOffset ?? (i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); @@ -85,148 +71,114 @@ class ChapterSheet extends StatelessWidget { } } - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_library, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Chapters', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + Widget content; + if (!chaptersLoaded) { + content = const Center(child: CircularProgressIndicator()); + } else if (chapters.isEmpty) { + content = const Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ); + } else { + content = ListView.builder( + itemCount: chapters.length, + itemBuilder: (context, index) { + final chapter = chapters[index]; + final isCurrentChapter = currentChapterIndex == index; + + return ListTile( + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ); + } + return Image.network( + client.getThumbnailUrl( + chapter.thumb, + ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ); + }, + ), + ), + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 4, + ), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter ? Colors.blue : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, ), ), - const Divider(color: Colors.white24, height: 1), - if (!chaptersLoaded) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else if (chapters.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No chapters available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: chapters.length, - itemBuilder: (context, index) { - final chapter = chapters[index]; - final isCurrentChapter = currentChapterIndex == index; - - return ListTile( - leading: chapter.thumb != null - ? Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Consumer( - builder: - (context, clientProvider, child) { - final client = - clientProvider.client; - if (client == null) { - return const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ); - } - return Image.network( - client.getThumbnailUrl( - chapter.thumb, - ), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: - ( - context, - error, - stackTrace, - ) => const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), - ); - }, - ), - ), - if (isCurrentChapter) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 4, - ), - border: Border.all( - color: Colors.blue, - width: 2, - ), - ), - ), - ), - ], - ) - : null, - title: Text( - chapter.label, - style: TextStyle( - color: isCurrentChapter - ? Colors.blue - : Colors.white, - fontWeight: isCurrentChapter - ? FontWeight.bold - : FontWeight.normal, - ), - ), - subtitle: Text( - _formatDuration(chapter.startTime), - style: TextStyle( - color: isCurrentChapter - ? Colors.blue.withValues(alpha: 0.7) - : Colors.white70, - fontSize: 12, - ), - ), - trailing: isCurrentChapter - ? const Icon( - Icons.play_circle_filled, - color: Colors.blue, - ) - : null, - onTap: () { - player.seek(chapter.startTime); - Navigator.pop(context); - }, - ); - }, - ), + subtitle: Text( + _formatDuration(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, ), - ], - ), - ), + ), + trailing: isCurrentChapter + ? const Icon( + Icons.play_circle_filled, + color: Colors.blue, + ) + : null, + onTap: () { + player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ); + } + + return BaseVideoControlSheet( + title: 'Chapters', + icon: Icons.video_library, + child: content, ); }, ); diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart index ea96e225..ad11ea0a 100644 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart @@ -1,5 +1,6 @@ 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 { @@ -7,23 +8,9 @@ class PlaybackSpeedSheet extends StatelessWidget { const PlaybackSpeedSheet({super.key, required this.player}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => PlaybackSpeedSheet(player: player), ); } @@ -39,66 +26,35 @@ class PlaybackSpeedSheet extends StatelessWidget { // Define available playback speeds final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.speed, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Playback Speed', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + 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, ), ), - const Divider(color: Colors.white24, height: 1), - Expanded( - 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); - }, - ); - }, - ), - ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setRate(speed); + Navigator.pop(context); + }, + ); + }, ), ); }, diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index b2786ba3..14c768ad 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; -import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; +import '../widgets/sleep_timer_content.dart'; /// Bottom sheet for sleep timer configuration class SleepTimerSheet extends StatelessWidget { @@ -15,47 +16,19 @@ class SleepTimerSheet extends StatelessWidget { required this.defaultDuration, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) async { final settingsService = await SettingsService.getInstance(); final defaultDuration = settingsService.getSleepTimerDuration(); if (!context.mounted) return; - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SleepTimerSheet(player: player, defaultDuration: defaultDuration), ); } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); @@ -63,169 +36,15 @@ class SleepTimerSheet extends StatelessWidget { return ListenableBuilder( listenable: sleepTimer, builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - // Add default duration if not in list - if (!durations.contains(defaultDuration)) { - durations.add(defaultDuration); - durations.sort(); - } - final remainingTime = sleepTimer.remainingTime; - - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon( - sleepTimer.isActive - ? Icons.bedtime - : Icons.bedtime_outlined, - color: sleepTimer.isActive - ? Colors.amber - : Colors.white, - ), - const SizedBox(width: 12), - const Text( - 'Sleep Timer', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - - // Show current timer status if active - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime( - amount: "15", - unit: " min", - ), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer( - const Duration(minutes: 15), - ); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration selection list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.normal, - ), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - // Pause playback when timer completes - player.pause(); - - // Show a snackbar notification - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); - - // Show confirmation snackbar - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.sleepTimerSet(label: label), - ), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ), + return BaseVideoControlSheet( + title: 'Sleep Timer', + 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), ), ); }, diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index 4f375627..faf01b97 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { @@ -13,27 +14,13 @@ class SubtitleTrackSheet extends StatelessWidget { this.onTrackChanged, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(SubtitleTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -50,146 +37,108 @@ class SubtitleTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.subtitles, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.subtitlesLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.subtitlesLabel, + icon: Icons.subtitles, + child: subtitles.isEmpty + ? const Center( + child: Text( + 'No subtitles available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (subtitles.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No subtitles available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.subtitle; - final selectedId = selectedTrack.id; - final isOffSelected = selectedId == 'no'; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.subtitle; + final selectedId = selectedTrack.id; + final isOffSelected = selectedId == 'no'; - return ListView.builder( - itemCount: - subtitles.length + 1, // +1 for "Off" option - itemBuilder: (context, index) { - // First item is "Off" - if (index == 0) { - return ListTile( - title: Text( - 'Off', - style: TextStyle( - color: isOffSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isOffSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) - : null, - onTap: () { - player.setSubtitleTrack(SubtitleTrack.no()); - onTrackChanged?.call(SubtitleTrack.no()); - Navigator.pop(context); - }, - ); - } - - // Subsequent items are subtitle tracks - final subtitle = subtitles[index - 1]; - final isSelected = subtitle.id == selectedId; - - // Build label with available info - final parts = []; - if (subtitle.title != null && - subtitle.title!.isNotEmpty) { - parts.add(subtitle.title!); - } - if (subtitle.language != null && - subtitle.language!.isNotEmpty) { - parts.add(subtitle.language!.toUpperCase()); - } - if (subtitle.codec != null && - subtitle.codec!.isNotEmpty) { - // Format codec names nicely - String codecName = subtitle.codec!.toUpperCase(); - if (codecName == 'SUBRIP') { - codecName = 'SRT'; - } else if (codecName == 'DVD_SUBTITLE') { - codecName = 'DVD'; - } else if (codecName == 'ASS' || - codecName == 'SSA') { - codecName = codecName; // Keep as-is - } else if (codecName == 'WEBVTT') { - codecName = 'VTT'; - } - parts.add(codecName); - } - - final label = parts.isEmpty - ? 'Track $index' - : parts.join(' · '); - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.blue - : Colors.white, - ), + return ListView.builder( + itemCount: subtitles.length + 1, // +1 for "Off" option + itemBuilder: (context, index) { + // First item is "Off" + if (index == 0) { + return ListTile( + title: Text( + 'Off', + style: TextStyle( + color: + isOffSelected ? Colors.blue : Colors.white, ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setSubtitleTrack(subtitle); - onTrackChanged?.call(subtitle); - Navigator.pop(context); - }, - ); + ), + trailing: isOffSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); + Navigator.pop(context); + }, + ); + } + + // Subsequent items are subtitle tracks + final subtitle = subtitles[index - 1]; + final isSelected = subtitle.id == selectedId; + + // Build label with available info + final parts = []; + if (subtitle.title != null && + subtitle.title!.isNotEmpty) { + parts.add(subtitle.title!); + } + if (subtitle.language != null && + subtitle.language!.isNotEmpty) { + parts.add(subtitle.language!.toUpperCase()); + } + if (subtitle.codec != null && + subtitle.codec!.isNotEmpty) { + // Format codec names nicely + String codecName = subtitle.codec!.toUpperCase(); + if (codecName == 'SUBRIP') { + codecName = 'SRT'; + } else if (codecName == 'DVD_SUBTITLE') { + codecName = 'DVD'; + } else if (codecName == 'ASS' || + codecName == 'SSA') { + codecName = codecName; // Keep as-is + } else if (codecName == 'WEBVTT') { + codecName = 'VTT'; + } + parts.add(codecName); + } + + final label = + parts.isEmpty ? 'Track $index' : parts.join(' · '); + + 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.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index eafd4dfe..32540cce 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_version.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting video version class VersionSheet extends StatelessWidget { @@ -14,28 +15,14 @@ class VersionSheet extends StatelessWidget { required this.onVersionSelected, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, List availableVersions, int selectedMediaIndex, Function(int) onVersionSelected, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => VersionSheet( availableVersions: availableVersions, selectedMediaIndex: selectedMediaIndex, @@ -46,61 +33,31 @@ class VersionSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_file, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Video Version', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: ListView.builder( - itemCount: availableVersions.length, - itemBuilder: (context, index) { - final version = availableVersions[index]; - final isSelected = index == selectedMediaIndex; + return BaseVideoControlSheet( + title: 'Video Version', + icon: Icons.video_file, + child: ListView.builder( + itemCount: availableVersions.length, + itemBuilder: (context, index) { + final version = availableVersions[index]; + final isSelected = index == selectedMediaIndex; - return ListTile( - title: Text( - version.displayLabel, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - Navigator.pop(context); - onVersionSelected(index); - }, - ); - }, + return ListTile( + title: Text( + version.displayLabel, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, ), ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + Navigator.pop(context); + onVersionSelected(index); + }, + ); + }, ), ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index fb78e421..28ee1211 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -4,10 +4,65 @@ import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../widgets/sync_offset_control.dart'; +import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } +/// Reusable menu item widget for settings sheet +class _SettingsMenuItem extends StatelessWidget { + final IconData icon; + final String title; + final String valueText; + final VoidCallback onTap; + final bool isHighlighted; + final bool allowValueOverflow; + + const _SettingsMenuItem({ + required this.icon, + required this.title, + required this.valueText, + required this.onTap, + this.isHighlighted = false, + this.allowValueOverflow = false, + }); + + @override + Widget build(BuildContext context) { + final valueWidget = Text( + valueText, + style: TextStyle( + color: isHighlighted ? Colors.amber : Colors.white70, + fontSize: 14, + ), + overflow: allowValueOverflow ? TextOverflow.ellipsis : null, + ); + + return ListTile( + leading: Icon( + icon, + color: isHighlighted ? Colors.amber : Colors.white70, + ), + title: Text( + title, + style: const TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (allowValueOverflow) + Flexible(child: valueWidget) + else + valueWidget, + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: onTap, + ); + } +} + /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; @@ -139,19 +194,6 @@ class _VideoSettingsSheetState extends State { } } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } Widget _buildHeader() { final sleepTimer = SleepTimerService(); @@ -204,23 +246,10 @@ class _VideoSettingsSheetState extends State { initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; - return ListTile( - leading: const Icon(Icons.speed, color: Colors.white70), - title: const Text( - 'Playback Speed', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSpeed(currentRate), - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speed, + title: 'Playback Speed', + valueText: _formatSpeed(currentRate), onTap: () => _navigateTo(_SettingsView.speed), ); }, @@ -231,87 +260,31 @@ class _VideoSettingsSheetState extends State { listenable: sleepTimer, builder: (context, _) { final isActive = sleepTimer.isActive; - return ListTile( - leading: Icon( - isActive ? Icons.bedtime : Icons.bedtime_outlined, - color: isActive ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Sleep Timer', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSleepTimer(sleepTimer), - style: TextStyle( - color: isActive ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: isActive ? Icons.bedtime : Icons.bedtime_outlined, + title: 'Sleep Timer', + valueText: _formatSleepTimer(sleepTimer), + isHighlighted: isActive, onTap: () => _navigateTo(_SettingsView.sleep), ); }, ), // Audio Sync - ListTile( - leading: Icon( - Icons.sync, - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Audio Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_audioSyncOffset), - style: TextStyle( - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.sync, + title: 'Audio Sync', + valueText: _formatAudioSync(_audioSyncOffset), + isHighlighted: _audioSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.audioSync), ), // Subtitle Sync - ListTile( - leading: Icon( - Icons.subtitles, - color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Subtitle Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_subtitleSyncOffset), - style: TextStyle( - color: _subtitleSyncOffset != 0 - ? Colors.amber - : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.subtitles, + title: 'Subtitle Sync', + valueText: _formatAudioSync(_subtitleSyncOffset), + isHighlighted: _subtitleSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.subtitleSync), ), @@ -327,29 +300,11 @@ class _VideoSettingsSheetState extends State { ? currentDevice.name : currentDevice.description; - return ListTile( - leading: const Icon(Icons.speaker, color: Colors.white70), - title: const Text( - 'Audio Output', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - deviceLabel, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speaker, + title: 'Audio Output', + valueText: deviceLabel, + allowValueOverflow: true, onTap: () => _navigateTo(_SettingsView.audioDevice), ); }, @@ -399,119 +354,10 @@ class _VideoSettingsSheetState extends State { Widget _buildSleepView() { final sleepTimer = SleepTimerService(); - return ListenableBuilder( - listenable: sleepTimer, - builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - final remainingTime = sleepTimer.remainingTime; - - return Column( - children: [ - // Active timer status - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime(amount: "15", unit: " min"), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer(const Duration(minutes: 15)); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); // Close after cancel - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle(color: Colors.white), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - widget.player.pause(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); // Close after selection - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.sleepTimerSet(label: label)), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ); - }, + return SleepTimerContent( + player: widget.player, + sleepTimer: sleepTimer, + onCancel: () => Navigator.pop(context), ); } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart new file mode 100644 index 00000000..b218aa9a --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; + +/// Widget displaying active sleep timer status with extend/cancel actions +class SleepTimerActiveStatus extends StatelessWidget { + final SleepTimerService sleepTimer; + final Duration remainingTime; + final VoidCallback? onCancel; + + const SleepTimerActiveStatus({ + super.key, + required this.sleepTimer, + required this.remainingTime, + this.onCancel, + }); + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.amber.withValues(alpha: 0.1), + child: Column( + children: [ + const Text( + 'Timer Active', + style: TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Playback will pause in ${_formatDuration(remainingTime)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text( + t.videoControls.addTime( + amount: "15", + unit: " min", + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + onPressed: () { + sleepTimer.extendTimer( + const Duration(minutes: 15), + ); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + icon: const Icon(Icons.cancel), + label: Text(t.common.cancel), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + sleepTimer.cancelTimer(); + onCancel?.call(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_content.dart b/lib/widgets/video_controls/widgets/sleep_timer_content.dart new file mode 100644 index 00000000..c23611d0 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_content.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import 'sleep_timer_active_status.dart'; +import 'sleep_timer_duration_list.dart'; + +/// Shared UI for sleep timer selection and active status. +class SleepTimerContent extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + final VoidCallback? onCancel; + + const SleepTimerContent({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final remainingTime = sleepTimer.remainingTime; + + return Column( + children: [ + if (sleepTimer.isActive && remainingTime != null) ...[ + SleepTimerActiveStatus( + sleepTimer: sleepTimer, + remainingTime: remainingTime, + onCancel: onCancel, + ), + const Divider(color: Colors.white24, height: 1), + ], + Expanded( + child: SleepTimerDurationList( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart new file mode 100644 index 00000000..f1a674bf --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; + +/// Widget displaying list of sleep timer durations for selection +class SleepTimerDurationList extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + + const SleepTimerDurationList({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + }); + + String _formatLabel(int minutes) { + if (minutes < 60) { + return '$minutes minutes'; + } + final hours = minutes / 60; + final isWholeHour = minutes % 60 == 0; + return '${hours.toStringAsFixed(isWholeHour ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; + } + + @override + Widget build(BuildContext context) { + final durations = [5, 10, 15, 30, 45, 60, 90, 120]; + // Add default duration if provided and not already in list + if (defaultDuration != null && !durations.contains(defaultDuration)) { + durations.add(defaultDuration!); + durations.sort(); + } + + return ListView.builder( + itemCount: durations.length, + itemBuilder: (context, index) { + final minutes = durations[index]; + final label = _formatLabel(minutes); + + return ListTile( + leading: const Icon(Icons.timer, color: Colors.white70), + title: Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.normal, + ), + ), + onTap: () { + sleepTimer.startTimer(Duration(minutes: minutes), () { + // Pause playback when timer completes + player.pause(); + + // Show a snackbar notification + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sleep timer completed - playback paused', + ), + duration: Duration(seconds: 3), + ), + ); + } + }); + Navigator.pop(context); + + // Show confirmation snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.sleepTimerSet(label: label), + ), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, + ); + } +}