From 0c23de9edecd19778c7cc818a94d22769e3ee325 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 20:53:59 +0100 Subject: [PATCH] feat: library overhaul add collections, library specific home screen, and more --- lib/client/plex_client.dart | 342 +++++- lib/constants/layout_constants.dart | 98 ++ lib/i18n/strings.g.dart | 772 +++++++++++- lib/i18n/strings.i18n.json | 61 +- lib/i18n/strings_de.i18n.json | 63 +- lib/i18n/strings_it.i18n.json | 63 +- lib/i18n/strings_nl.i18n.json | 63 +- lib/i18n/strings_sv.i18n.json | 63 +- lib/i18n/strings_zh.i18n.json | 63 +- lib/models/play_queue_response.dart | 77 ++ lib/models/play_queue_response.g.dart | 28 + lib/models/plex_metadata.dart | 12 + lib/models/plex_metadata.g.dart | 6 + lib/providers/playback_state_provider.dart | 269 ++++- lib/screens/collection_detail_screen.dart | 314 +++++ lib/screens/discover_screen.dart | 46 +- lib/screens/libraries_screen.dart | 1046 +++++------------ .../library_tabs/library_browse_tab.dart | 602 ++++++++++ .../library_tabs/library_collections_tab.dart | 195 +++ .../library_tabs/library_playlists_tab.dart | 199 ++++ .../library_tabs/library_recommended_tab.dart | 162 +++ lib/screens/media_detail_screen.dart | 2 +- lib/screens/playlist_detail_screen.dart | 93 +- lib/screens/season_detail_screen.dart | 2 +- lib/screens/video_player_screen.dart | 23 +- lib/services/storage_service.dart | 62 +- .../collection_playlist_play_helper.dart | 116 ++ lib/utils/grid_size_calculator.dart | 53 + lib/utils/library_refresh_notifier.dart | 38 + lib/widgets/empty_state_widget.dart | 64 + lib/widgets/error_state_widget.dart | 61 + lib/widgets/filters_bottom_sheet.dart | 320 +++++ lib/widgets/folder_tree_item.dart | 124 ++ lib/widgets/folder_tree_view.dart | 268 +++++ lib/widgets/hub_section.dart | 136 +++ lib/widgets/media_card.dart | 354 ++++-- lib/widgets/media_context_menu.dart | 701 ++++++++++- lib/widgets/sort_bottom_sheet.dart | 11 +- 38 files changed, 5891 insertions(+), 1081 deletions(-) create mode 100644 lib/constants/layout_constants.dart create mode 100644 lib/models/play_queue_response.dart create mode 100644 lib/models/play_queue_response.g.dart create mode 100644 lib/screens/collection_detail_screen.dart create mode 100644 lib/screens/library_tabs/library_browse_tab.dart create mode 100644 lib/screens/library_tabs/library_collections_tab.dart create mode 100644 lib/screens/library_tabs/library_playlists_tab.dart create mode 100644 lib/screens/library_tabs/library_recommended_tab.dart create mode 100644 lib/utils/collection_playlist_play_helper.dart create mode 100644 lib/utils/grid_size_calculator.dart create mode 100644 lib/utils/library_refresh_notifier.dart create mode 100644 lib/widgets/empty_state_widget.dart create mode 100644 lib/widgets/error_state_widget.dart create mode 100644 lib/widgets/filters_bottom_sheet.dart create mode 100644 lib/widgets/folder_tree_item.dart create mode 100644 lib/widgets/folder_tree_view.dart create mode 100644 lib/widgets/hub_section.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 0b31a98a..3bc42b2e 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -13,6 +13,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; +import '../models/play_queue_response.dart'; import '../network/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; import '../utils/log_redaction_manager.dart'; @@ -546,7 +547,10 @@ class PlexClient { // Remove leading slash if present final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; - return '${config.baseUrl}/$path?X-Plex-Token=${config.token}'; + // Check if path already has query parameters + final separator = path.contains('?') ? '&' : '?'; + + return '${config.baseUrl}/$path${separator}X-Plex-Token=${config.token}'; } /// Get video URL for direct playback @@ -1484,6 +1488,342 @@ class PlexClient { } } + // ============================================================================ + // Collection Methods + // ============================================================================ + + /// Get all collections for a library section + /// Returns collections as PlexMetadata objects with type="collection" + Future> getLibraryCollections(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/collections', + queryParameters: { + 'includeGuids': 1, + }, + ); + final allItems = _extractMetadataList(response); + + // Collections should have type="collection" + return allItems.where((item) { + return item.type.toLowerCase() == 'collection'; + }).toList(); + } catch (e) { + appLogger.e('Failed to get library collections: $e'); + return []; + } + } + + /// Get items in a collection + /// Returns the list of metadata items in the collection + Future> getCollectionItems(String collectionId) async { + try { + final response = await _dio.get('/library/collections/$collectionId/children'); + return _extractMetadataList(response); + } catch (e) { + appLogger.e('Failed to get collection items: $e'); + return []; + } + } + + /// Delete a collection + /// Deletes a library collection from the server + Future deleteCollection(String sectionId, String collectionId) async { + try { + appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); + final response = await _dio.delete('/library/collections/$collectionId'); + appLogger.d('Delete collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to delete collection', error: e); + return false; + } + } + + /// Create a new collection + /// Creates a new collection and optionally adds items to it + /// Returns the created collection ID or null if failed + Future createCollection({ + required String sectionId, + required String title, + required String uri, + int? type, + }) async { + try { + appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); + final response = await _dio.post( + '/library/collections', + queryParameters: { + if (type != null) 'type': type, + 'title': title, + 'smart': 0, + 'sectionId': sectionId, + 'uri': uri, + }, + ); + appLogger.d('Create collection response: ${response.statusCode}'); + + // Extract the collection ID from the response + // The response should contain the created collection metadata + if (response.data != null && response.data['MediaContainer'] != null) { + final metadata = response.data['MediaContainer']['Metadata']; + if (metadata != null && metadata.isNotEmpty) { + final collectionId = metadata[0]['ratingKey']?.toString(); + appLogger.d('Created collection with ID: $collectionId'); + return collectionId; + } + } + + return null; + } catch (e) { + appLogger.e('Failed to create collection', error: e); + return null; + } + } + + /// Add items to an existing collection + /// Adds one or more items (specified by URI) to an existing collection + Future addToCollection({ + required String collectionId, + required String uri, + }) async { + try { + appLogger.d('Adding items to collection: collectionId=$collectionId'); + final response = await _dio.put( + '/library/collections/$collectionId/items', + queryParameters: { + 'uri': uri, + }, + ); + appLogger.d('Add to collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to add items to collection', error: e); + return false; + } + } + + /// Remove an item from a collection + /// Removes a single item from an existing collection + Future removeFromCollection({ + required String collectionId, + required String itemId, + }) async { + try { + appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); + final response = await _dio.delete( + '/library/collections/$collectionId/items/$itemId', + ); + appLogger.d('Remove from collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to remove item from collection', error: e); + return false; + } + } + + // ============================================================================ + // Play Queue Methods + // ============================================================================ + + /// Create a new play queue + /// Either uri or playlistID must be specified + Future createPlayQueue({ + String? uri, + int? playlistID, + required String type, + String? key, + int shuffle = 0, + int repeat = 0, + int continuous = 0, + }) async { + try { + final queryParams = { + 'type': type, + 'shuffle': shuffle, + 'repeat': repeat, + 'continuous': continuous, + }; + + if (uri != null) { + queryParams['uri'] = uri; + } + if (playlistID != null) { + queryParams['playlistID'] = playlistID; + } + if (key != null) { + queryParams['key'] = key; + } + + final response = await _dio.post( + '/playQueues', + queryParameters: queryParams, + ); + + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to create play queue', error: e); + return null; + } + } + + /// Get a play queue with optional windowing + /// Can request a window of items around a specific item + Future getPlayQueue( + int playQueueId, { + String? center, + int window = 50, + int includeBefore = 1, + int includeAfter = 1, + }) async { + try { + final queryParams = { + 'window': window, + 'includeBefore': includeBefore, + 'includeAfter': includeAfter, + }; + + if (center != null) { + queryParams['center'] = center; + } + + final response = await _dio.get( + '/playQueues/$playQueueId', + queryParameters: queryParams, + ); + + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to get play queue: $e'); + return null; + } + } + + /// Shuffle a play queue + /// The currently selected item is maintained + Future shufflePlayQueue(int playQueueId) async { + try { + final response = await _dio.put('/playQueues/$playQueueId/shuffle'); + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to shuffle play queue: $e'); + return null; + } + } + + /// Clear all items from a play queue + Future clearPlayQueue(int playQueueId) async { + try { + await _dio.delete('/playQueues/$playQueueId/items'); + return true; + } catch (e) { + appLogger.e('Failed to clear play queue: $e'); + return false; + } + } + + /// Extract both Metadata and Directory entries from response + /// Folders can come back as either type + List _extractMetadataAndDirectories(Response response) { + final List items = []; + final container = _getMediaContainer(response); + + if (container != null) { + // Extract Metadata entries - try full parsing first + if (container['Metadata'] != null) { + for (final json in container['Metadata'] as List) { + try { + // Try to parse with full PlexMetadata.fromJson first + items.add(PlexMetadata.fromJson(json)); + } catch (e) { + // If full parsing fails, use minimal safe parsing + appLogger.d('Using minimal parsing for metadata item: $e'); + try { + items.add(PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + year: json['year'], + )); + } catch (e2) { + appLogger.e('Failed to parse metadata item: $e2'); + } + } + } + } + + // Extract Directory entries (folders) + if (container['Directory'] != null) { + for (final json in container['Directory'] as List) { + try { + // Try to parse as PlexMetadata first + items.add(PlexMetadata.fromJson(json)); + } catch (e) { + // If that fails, use minimal folder representation + try { + items.add(PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + )); + } catch (e2) { + appLogger.e('Failed to parse directory item: $e2'); + } + } + } + } + } + + return items; + } + + /// Get root folders for a library section + /// Returns the top-level folder structure for filesystem-based browsing + Future> getLibraryFolders(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/folder', + queryParameters: { + 'includeCollections': 0, + }, + ); + return _extractMetadataAndDirectories(response); + } catch (e) { + appLogger.e('Failed to get library folders: $e'); + return []; + } + } + + /// Get children of a specific folder + /// Returns files and subfolders within the given folder + Future> getFolderChildren(String folderKey) async { + try { + final response = await _dio.get(folderKey); + return _extractMetadataAndDirectories(response); + } catch (e) { + appLogger.e('Failed to get folder children: $e'); + return []; + } + } + + /// Get library-specific playlists + /// Filters playlists by checking if they contain items from the specified library + /// This is a client-side filter since the API doesn't support sectionId for playlists + Future> getLibraryPlaylists({ + required String sectionId, + String playlistType = 'video', + }) async { + // For now, return all video playlists + // Future enhancement: filter by checking playlist items' library + return getPlaylists(playlistType: playlistType); + } + // ============================================================================ // Library Management Methods // ============================================================================ diff --git a/lib/constants/layout_constants.dart b/lib/constants/layout_constants.dart new file mode 100644 index 00000000..68c62510 --- /dev/null +++ b/lib/constants/layout_constants.dart @@ -0,0 +1,98 @@ +/// Layout and sizing constants used throughout the application + +/// Screen width breakpoints for responsive design +class ScreenBreakpoints { + /// Breakpoint for tablet devices (600px) + static const double tablet = 600; + + /// Breakpoint for desktop devices (1200px) + static const double desktop = 1200; + + /// Breakpoint for large desktop devices (1600px) + static const double largeDesktop = 1600; +} + +/// Pagination constants +class PaginationConstants { + /// Default page size for library items + static const int defaultPageSize = 1000; + + /// Page size for search results + static const int searchPageSize = 50; +} + +/// Grid layout constants +class GridLayoutConstants { + /// Maximum cross-axis extent for grid items in comfortable density mode + static const double comfortableDesktop = 280; + static const double comfortableTablet = 240; + static const double comfortableMobile = 200; + + /// Maximum cross-axis extent for grid items in compact density mode + static const double compactDesktop = 200; + static const double compactTablet = 170; + static const double compactMobile = 140; + + /// Maximum cross-axis extent for grid items in normal density mode + static const double normalDesktop = 240; + static const double normalTablet = 200; + static const double normalMobile = 170; + + /// Default aspect ratio for media cards (poster) + static const double posterAspectRatio = 2 / 3.3; + + /// Grid spacing + static const double crossAxisSpacing = 0; + static const double mainAxisSpacing = 0; +} + +/// Padding and spacing constants +class SpacingConstants { + /// Extra small spacing (4px) + static const double xs = 4; + + /// Small spacing (8px) + static const double sm = 8; + + /// Medium spacing (12px) + static const double md = 12; + + /// Large spacing (16px) + static const double lg = 16; + + /// Extra large spacing (24px) + static const double xl = 24; + + /// Double extra large spacing (32px) + static const double xxl = 32; +} + +/// Icon size constants +class IconSizeConstants { + /// Small icon size (16px) + static const double sm = 16; + + /// Medium icon size (24px) + static const double md = 24; + + /// Large icon size (32px) + static const double lg = 32; + + /// Extra large icon size (48px) + static const double xl = 48; +} + +/// Border radius constants +class BorderRadiusConstants { + /// Small border radius (4px) + static const double sm = 4; + + /// Medium border radius (8px) + static const double md = 8; + + /// Large border radius (12px) + static const double lg = 12; + + /// Extra large border radius (16px) + static const double xl = 16; +} diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d95497f8..67236e1d 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2130 (355 per locale) +/// Strings: 2424 (404 per locale) /// -/// Built on 2025-11-15 at 11:48 UTC +/// Built on 2025-11-16 at 19:45 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -179,6 +179,7 @@ class Translations implements BaseTranslations { late final _StringsLogsEn logs = _StringsLogsEn._(_root); late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); + late final _StringsCollectionsEn collections = _StringsCollectionsEn._(_root); late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root); } @@ -241,6 +242,9 @@ class _StringsCommonEn { String get yes => 'Yes'; String get no => 'No'; String get server => 'Server'; + String get delete => 'Delete'; + String get shuffle => 'Shuffle'; + String get addTo => 'Add to...'; } // Path: screens @@ -521,6 +525,10 @@ class _StringsMessagesEn { String get noResultsFound => 'No results found'; String sleepTimerSet({required Object label}) => 'Sleep timer set for ${label}'; String failedToSwitchProfile({required Object displayName}) => 'Failed to switch to ${displayName}'; + String get noItemsAvailable => 'No items available'; + String get failedToCreatePlayQueue => 'Failed to create play queue'; + String get failedToCreatePlayQueueNoItems => 'Failed to create play queue - no items'; + String failedPlayback({required Object action, required Object error}) => 'Failed to ${action}: ${error}'; } // Path: profile @@ -653,6 +661,15 @@ class _StringsLibrariesEn { String get showLibrary => 'Show library'; String get hideLibrary => 'Hide library'; String get libraryOptions => 'Library options'; + String get content => 'library content'; + String get selectLibrary => 'Select library'; + String filtersWithCount({required Object count}) => 'Filters (${count})'; + String get noRecommendations => 'No recommendations available'; + String get noCollections => 'No collections in this library'; + String get noFoldersFound => 'No folders found'; + String get folders => 'folders'; + late final _StringsLibrariesTabsEn tabs = _StringsLibrariesTabsEn._(_root); + late final _StringsLibrariesGroupingsEn groupings = _StringsLibrariesGroupingsEn._(_root); } // Path: about @@ -746,6 +763,39 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: collections +class _StringsCollectionsEn { + _StringsCollectionsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Collections'; + String get collection => 'Collection'; + String get empty => 'Collection is empty'; + String get noItems => 'No items in this collection'; + String get unknownLibrarySection => 'Cannot delete: Unknown library section'; + String get deleteCollection => 'Delete Collection'; + String deleteConfirm({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.'; + String get deleted => 'Collection deleted'; + String get deleteFailed => 'Failed to delete collection'; + String deleteFailedWithError({required Object error}) => 'Failed to delete collection: ${error}'; + String failedToLoadItems({required Object error}) => 'Failed to load collection items: ${error}'; + String get addTo => 'Add to collection'; + String get selectCollection => 'Select Collection'; + String get createNewCollection => 'Create New Collection'; + String get collectionName => 'Collection Name'; + String get enterCollectionName => 'Enter collection name'; + String get addedToCollection => 'Added to collection'; + String get errorAddingToCollection => 'Failed to add to collection'; + String get created => 'Collection created'; + String get removeFromCollection => 'Remove from collection'; + String removeFromCollectionConfirm({required Object title}) => 'Remove "${title}" from this collection?'; + String get removedFromCollection => 'Removed from collection'; + String get removeFromCollectionFailed => 'Failed to remove from collection'; + String removeFromCollectionError({required Object error}) => 'Error removing from collection: ${error}'; +} + // Path: playlists class _StringsPlaylistsEn { _StringsPlaylistsEn._(this._root); @@ -754,6 +804,7 @@ class _StringsPlaylistsEn { // Translations String get title => 'Playlists'; + String get playlist => 'Playlist'; String get noPlaylists => 'No playlists found'; String get create => 'Create Playlist'; String get newPlaylist => 'New Playlist'; @@ -789,6 +840,34 @@ class _StringsPlaylistsEn { String get errorRemoving => 'Failed to remove from playlist'; } +// Path: libraries.tabs +class _StringsLibrariesTabsEn { + _StringsLibrariesTabsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get recommended => 'Recommended'; + String get browse => 'Browse'; + String get collections => 'Collections'; + String get playlists => 'Playlists'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get all => 'All'; + String get movies => 'Movies'; + String get shows => 'TV Shows'; + String get seasons => 'Seasons'; + String get episodes => 'Episodes'; + String get folders => 'Folders'; +} + // Path: class _StringsDe implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -841,6 +920,7 @@ class _StringsDe implements Translations { @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); @override late final _StringsPlaylistsDe playlists = _StringsPlaylistsDe._(_root); + @override late final _StringsCollectionsDe collections = _StringsCollectionsDe._(_root); } // Path: app @@ -902,6 +982,9 @@ class _StringsCommonDe implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nein'; @override String get server => 'Server'; + @override String get delete => 'Löschen'; + @override String get shuffle => 'Zufall'; + @override String get addTo => 'Hinzufügen zu...'; } // Path: screens @@ -1182,6 +1265,10 @@ class _StringsMessagesDe implements _StringsMessagesEn { @override String get noResultsFound => 'Keine Ergebnisse gefunden'; @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String get noItemsAvailable => 'Keine Elemente verfügbar'; + @override String get failedToCreatePlayQueue => 'Wiedergabewarteschlange konnte nicht erstellt werden'; + @override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; + @override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; } // Path: profile @@ -1314,6 +1401,15 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override String get showLibrary => 'Mediathek anzeigen'; @override String get hideLibrary => 'Mediathek ausblenden'; @override String get libraryOptions => 'Mediatheksoptionen'; + @override String get content => 'Bibliotheksinhalt'; + @override String get selectLibrary => 'Bibliothek auswählen'; + @override String filtersWithCount({required Object count}) => 'Filter (${count})'; + @override String get noRecommendations => 'Keine Empfehlungen verfügbar'; + @override String get noCollections => 'Keine Sammlungen in dieser Mediathek'; + @override String get noFoldersFound => 'Keine Ordner gefunden'; + @override String get folders => 'Ordner'; + @override late final _StringsLibrariesTabsDe tabs = _StringsLibrariesTabsDe._(_root); + @override late final _StringsLibrariesGroupingsDe groupings = _StringsLibrariesGroupingsDe._(_root); } // Path: about @@ -1448,6 +1544,68 @@ class _StringsPlaylistsDe implements _StringsPlaylistsEn { @override String get errorAdding => 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; @override String get errorReordering => 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; @override String get errorRemoving => 'Konnte nicht aus der Wiedergabeliste entfernt werden'; + @override String get playlist => 'Wiedergabeliste'; +} + +// Path: collections +class _StringsCollectionsDe implements _StringsCollectionsEn { + _StringsCollectionsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Sammlungen'; + @override String get collection => 'Sammlung'; + @override String get empty => 'Sammlung ist leer'; + @override String get noItems => 'Keine Elemente in dieser Sammlung'; + @override String get unknownLibrarySection => 'Löschen nicht möglich: Unbekannte Bibliothekssektion'; + @override String get deleteCollection => 'Sammlung löschen'; + @override String deleteConfirm({required Object title}) => 'Sind Sie sicher, dass Sie "${title}" löschen möchten? Dies kann nicht rückgängig gemacht werden.'; + @override String get deleted => 'Sammlung gelöscht'; + @override String get deleteFailed => 'Sammlung konnte nicht gelöscht werden'; + @override String deleteFailedWithError({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}'; + @override String get addTo => 'Zur Sammlung hinzufügen'; + @override String get selectCollection => 'Sammlung auswählen'; + @override String get createNewCollection => 'Neue Sammlung erstellen'; + @override String get collectionName => 'Sammlungsname'; + @override String get enterCollectionName => 'Sammlungsnamen eingeben'; + @override String get addedToCollection => 'Zur Sammlung hinzugefügt'; + @override String get errorAddingToCollection => 'Fehler beim Hinzufügen zur Sammlung'; + @override String get created => 'Sammlung erstellt'; + @override String get removeFromCollection => 'Aus Sammlung entfernen'; + @override String removeFromCollectionConfirm({required Object title}) => '"${title}" aus dieser Sammlung entfernen?'; + @override String get removedFromCollection => 'Aus Sammlung entfernt'; + @override String get removeFromCollectionFailed => 'Entfernen aus Sammlung fehlgeschlagen'; + @override String removeFromCollectionError({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsDe implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Empfohlen'; + @override String get browse => 'Durchsuchen'; + @override String get collections => 'Sammlungen'; + @override String get playlists => 'Wiedergabelisten'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsDe implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get all => 'Alle'; + @override String get movies => 'Filme'; + @override String get shows => 'Serien'; + @override String get seasons => 'Staffeln'; + @override String get episodes => 'Episoden'; + @override String get folders => 'Ordner'; } // Path: @@ -1502,6 +1660,7 @@ class _StringsIt implements Translations { @override late final _StringsLicensesIt licenses = _StringsLicensesIt._(_root); @override late final _StringsNavigationIt navigation = _StringsNavigationIt._(_root); @override late final _StringsPlaylistsIt playlists = _StringsPlaylistsIt._(_root); + @override late final _StringsCollectionsIt collections = _StringsCollectionsIt._(_root); } // Path: app @@ -1563,6 +1722,9 @@ class _StringsCommonIt implements _StringsCommonEn { @override String get yes => 'Sì'; @override String get no => 'No'; @override String get server => 'Server'; + @override String get delete => 'Elimina'; + @override String get shuffle => 'Casuale'; + @override String get addTo => 'Aggiungi a...'; } // Path: screens @@ -1843,6 +2005,10 @@ class _StringsMessagesIt implements _StringsMessagesEn { @override String get noResultsFound => 'Nessun risultato'; @override String sleepTimerSet({required Object label}) => 'Imposta timer spegnimento per ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Impossibile passare a ${displayName}'; + @override String get noItemsAvailable => 'Nessun elemento disponibile'; + @override String get failedToCreatePlayQueue => 'Impossibile creare la coda di riproduzione'; + @override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento'; + @override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; } // Path: profile @@ -1975,6 +2141,15 @@ class _StringsLibrariesIt implements _StringsLibrariesEn { @override String get showLibrary => 'Mostra libreria'; @override String get hideLibrary => 'Nascondi libreria'; @override String get libraryOptions => 'Opzioni libreria'; + @override String get content => 'contenuto della libreria'; + @override String get selectLibrary => 'Seleziona libreria'; + @override String filtersWithCount({required Object count}) => 'Filtri (${count})'; + @override String get noRecommendations => 'Nessun consiglio disponibile'; + @override String get noCollections => 'Nessuna raccolta in questa libreria'; + @override String get noFoldersFound => 'Nessuna cartella trovata'; + @override String get folders => 'cartelle'; + @override late final _StringsLibrariesTabsIt tabs = _StringsLibrariesTabsIt._(_root); + @override late final _StringsLibrariesGroupingsIt groupings = _StringsLibrariesGroupingsIt._(_root); } // Path: about @@ -2109,6 +2284,68 @@ class _StringsPlaylistsIt implements _StringsPlaylistsEn { @override String get errorAdding => 'Errore durante l\'aggiunta alla playlist'; @override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist'; @override String get errorRemoving => 'Errore durante la rimozione dalla playlist'; + @override String get playlist => 'Playlist'; +} + +// Path: collections +class _StringsCollectionsIt implements _StringsCollectionsEn { + _StringsCollectionsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Raccolte'; + @override String get collection => 'Raccolta'; + @override String get empty => 'La raccolta è vuota'; + @override String get noItems => 'Nessun elemento in questa raccolta'; + @override String get unknownLibrarySection => 'Impossibile eliminare: sezione libreria sconosciuta'; + @override String get deleteCollection => 'Elimina raccolta'; + @override String deleteConfirm({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Questa azione non può essere annullata.'; + @override String get deleted => 'Raccolta eliminata'; + @override String get deleteFailed => 'Impossibile eliminare la raccolta'; + @override String deleteFailedWithError({required Object error}) => 'Impossibile eliminare la raccolta: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}'; + @override String get addTo => 'Aggiungi alla raccolta'; + @override String get selectCollection => 'Seleziona raccolta'; + @override String get createNewCollection => 'Crea nuova raccolta'; + @override String get collectionName => 'Nome raccolta'; + @override String get enterCollectionName => 'Inserisci nome raccolta'; + @override String get addedToCollection => 'Aggiunto alla raccolta'; + @override String get errorAddingToCollection => 'Errore nell\'aggiunta alla raccolta'; + @override String get created => 'Raccolta creata'; + @override String get removeFromCollection => 'Rimuovi dalla raccolta'; + @override String removeFromCollectionConfirm({required Object title}) => 'Rimuovere "${title}" da questa raccolta?'; + @override String get removedFromCollection => 'Rimosso dalla raccolta'; + @override String get removeFromCollectionFailed => 'Impossibile rimuovere dalla raccolta'; + @override String removeFromCollectionError({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsIt implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Consigliati'; + @override String get browse => 'Esplora'; + @override String get collections => 'Raccolte'; + @override String get playlists => 'Playlist'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsIt implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get all => 'Tutti'; + @override String get movies => 'Film'; + @override String get shows => 'Serie TV'; + @override String get seasons => 'Stagioni'; + @override String get episodes => 'Episodi'; + @override String get folders => 'Cartelle'; } // Path: @@ -2163,6 +2400,7 @@ class _StringsNl implements Translations { @override late final _StringsLicensesNl licenses = _StringsLicensesNl._(_root); @override late final _StringsNavigationNl navigation = _StringsNavigationNl._(_root); @override late final _StringsPlaylistsNl playlists = _StringsPlaylistsNl._(_root); + @override late final _StringsCollectionsNl collections = _StringsCollectionsNl._(_root); } // Path: app @@ -2224,6 +2462,9 @@ class _StringsCommonNl implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nee'; @override String get server => 'Server'; + @override String get delete => 'Verwijderen'; + @override String get shuffle => 'Shuffle'; + @override String get addTo => 'Toevoegen aan...'; } // Path: screens @@ -2504,6 +2745,10 @@ class _StringsMessagesNl implements _StringsMessagesEn { @override String get noResultsFound => 'Geen resultaten gevonden'; @override String sleepTimerSet({required Object label}) => 'Slaap timer ingesteld voor ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Kon niet wisselen naar ${displayName}'; + @override String get noItemsAvailable => 'Geen items beschikbaar'; + @override String get failedToCreatePlayQueue => 'Kan afspeelwachtrij niet maken'; + @override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items'; + @override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; } // Path: profile @@ -2636,6 +2881,15 @@ class _StringsLibrariesNl implements _StringsLibrariesEn { @override String get showLibrary => 'Toon bibliotheek'; @override String get hideLibrary => 'Verberg bibliotheek'; @override String get libraryOptions => 'Bibliotheek opties'; + @override String get content => 'bibliotheekinhoud'; + @override String get selectLibrary => 'Bibliotheek kiezen'; + @override String filtersWithCount({required Object count}) => 'Filters (${count})'; + @override String get noRecommendations => 'Geen aanbevelingen beschikbaar'; + @override String get noCollections => 'Geen collecties in deze bibliotheek'; + @override String get noFoldersFound => 'Geen mappen gevonden'; + @override String get folders => 'mappen'; + @override late final _StringsLibrariesTabsNl tabs = _StringsLibrariesTabsNl._(_root); + @override late final _StringsLibrariesGroupingsNl groupings = _StringsLibrariesGroupingsNl._(_root); } // Path: about @@ -2770,6 +3024,68 @@ class _StringsPlaylistsNl implements _StringsPlaylistsEn { @override String get errorAdding => 'Fout bij toevoegen aan afspeellijst'; @override String get errorReordering => 'Fout bij herschikken van afspeellijstitem'; @override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst'; + @override String get playlist => 'Afspeellijst'; +} + +// Path: collections +class _StringsCollectionsNl implements _StringsCollectionsEn { + _StringsCollectionsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Collecties'; + @override String get collection => 'Collectie'; + @override String get empty => 'Collectie is leeg'; + @override String get noItems => 'Geen items in deze collectie'; + @override String get unknownLibrarySection => 'Kan niet verwijderen: onbekende bibliotheeksectie'; + @override String get deleteCollection => 'Collectie verwijderen'; + @override String deleteConfirm({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.'; + @override String get deleted => 'Collectie verwijderd'; + @override String get deleteFailed => 'Collectie verwijderen mislukt'; + @override String deleteFailedWithError({required Object error}) => 'Collectie verwijderen mislukt: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Collectie-items laden mislukt: ${error}'; + @override String get addTo => 'Toevoegen aan collectie'; + @override String get selectCollection => 'Selecteer collectie'; + @override String get createNewCollection => 'Nieuwe collectie maken'; + @override String get collectionName => 'Collectienaam'; + @override String get enterCollectionName => 'Voer collectienaam in'; + @override String get addedToCollection => 'Toegevoegd aan collectie'; + @override String get errorAddingToCollection => 'Fout bij toevoegen aan collectie'; + @override String get created => 'Collectie gemaakt'; + @override String get removeFromCollection => 'Verwijderen uit collectie'; + @override String removeFromCollectionConfirm({required Object title}) => '"${title}" uit deze collectie verwijderen?'; + @override String get removedFromCollection => 'Uit collectie verwijderd'; + @override String get removeFromCollectionFailed => 'Verwijderen uit collectie mislukt'; + @override String removeFromCollectionError({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsNl implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Aanbevolen'; + @override String get browse => 'Bladeren'; + @override String get collections => 'Collecties'; + @override String get playlists => 'Afspeellijsten'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsNl implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get all => 'Alles'; + @override String get movies => 'Films'; + @override String get shows => 'Series'; + @override String get seasons => 'Seizoenen'; + @override String get episodes => 'Afleveringen'; + @override String get folders => 'Mappen'; } // Path: @@ -2824,6 +3140,7 @@ class _StringsSv implements Translations { @override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root); @override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root); @override late final _StringsPlaylistsSv playlists = _StringsPlaylistsSv._(_root); + @override late final _StringsCollectionsSv collections = _StringsCollectionsSv._(_root); } // Path: app @@ -2885,6 +3202,9 @@ class _StringsCommonSv implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nej'; @override String get server => 'Server'; + @override String get delete => 'Ta bort'; + @override String get shuffle => 'Blanda'; + @override String get addTo => 'Lägg till i...'; } // Path: screens @@ -3165,6 +3485,10 @@ class _StringsMessagesSv implements _StringsMessagesEn { @override String get noResultsFound => 'Inga resultat hittades'; @override String sleepTimerSet({required Object label}) => 'Sovtimer inställd för ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + @override String get noItemsAvailable => 'Inga objekt tillgängliga'; + @override String get failedToCreatePlayQueue => 'Det gick inte att skapa uppspelningskö'; + @override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö – inga objekt'; + @override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; } // Path: profile @@ -3297,6 +3621,15 @@ class _StringsLibrariesSv implements _StringsLibrariesEn { @override String get showLibrary => 'Visa bibliotek'; @override String get hideLibrary => 'Dölj bibliotek'; @override String get libraryOptions => 'Biblioteksalternativ'; + @override String get content => 'bibliotekets innehåll'; + @override String get selectLibrary => 'Välj bibliotek'; + @override String filtersWithCount({required Object count}) => 'Filter (${count})'; + @override String get noRecommendations => 'Inga rekommendationer tillgängliga'; + @override String get noCollections => 'Inga samlingar i det här biblioteket'; + @override String get noFoldersFound => 'Inga mappar hittades'; + @override String get folders => 'mappar'; + @override late final _StringsLibrariesTabsSv tabs = _StringsLibrariesTabsSv._(_root); + @override late final _StringsLibrariesGroupingsSv groupings = _StringsLibrariesGroupingsSv._(_root); } // Path: about @@ -3431,6 +3764,68 @@ class _StringsPlaylistsSv implements _StringsPlaylistsEn { @override String get errorAdding => 'Det gick inte att lägga till i spellista'; @override String get errorReordering => 'Det gick inte att omordna spellisteobjekt'; @override String get errorRemoving => 'Det gick inte att ta bort från spellista'; + @override String get playlist => 'Spellista'; +} + +// Path: collections +class _StringsCollectionsSv implements _StringsCollectionsEn { + _StringsCollectionsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Samlingar'; + @override String get collection => 'Samling'; + @override String get empty => 'Samlingen är tom'; + @override String get noItems => 'Inga objekt i den här samlingen'; + @override String get unknownLibrarySection => 'Kan inte ta bort: okänd bibliotekssektion'; + @override String get deleteCollection => 'Ta bort samling'; + @override String deleteConfirm({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Detta går inte att ångra.'; + @override String get deleted => 'Samling borttagen'; + @override String get deleteFailed => 'Det gick inte att ta bort samlingen'; + @override String deleteFailedWithError({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}'; + @override String get addTo => 'Lägg till i samling'; + @override String get selectCollection => 'Välj samling'; + @override String get createNewCollection => 'Skapa ny samling'; + @override String get collectionName => 'Samlingsnamn'; + @override String get enterCollectionName => 'Ange samlingsnamn'; + @override String get addedToCollection => 'Tillagd i samling'; + @override String get errorAddingToCollection => 'Fel vid tillägg i samling'; + @override String get created => 'Samling skapad'; + @override String get removeFromCollection => 'Ta bort från samling'; + @override String removeFromCollectionConfirm({required Object title}) => 'Ta bort "${title}" från denna samling?'; + @override String get removedFromCollection => 'Borttagen från samling'; + @override String get removeFromCollectionFailed => 'Misslyckades med att ta bort från samling'; + @override String removeFromCollectionError({required Object error}) => 'Fel vid borttagning från samling: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsSv implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Rekommenderat'; + @override String get browse => 'Bläddra'; + @override String get collections => 'Samlingar'; + @override String get playlists => 'Spellistor'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsSv implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get all => 'Alla'; + @override String get movies => 'Filmer'; + @override String get shows => 'Serier'; + @override String get seasons => 'Säsonger'; + @override String get episodes => 'Avsnitt'; + @override String get folders => 'Mappar'; } // Path: @@ -3485,6 +3880,7 @@ class _StringsZh implements Translations { @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); @override late final _StringsPlaylistsZh playlists = _StringsPlaylistsZh._(_root); + @override late final _StringsCollectionsZh collections = _StringsCollectionsZh._(_root); } // Path: app @@ -3546,6 +3942,9 @@ class _StringsCommonZh implements _StringsCommonEn { @override String get yes => '是'; @override String get no => '否'; @override String get server => '服务器'; + @override String get delete => '删除'; + @override String get shuffle => '随机播放'; + @override String get addTo => '添加到...'; } // Path: screens @@ -3826,6 +4225,10 @@ class _StringsMessagesZh implements _StringsMessagesEn { @override String get noResultsFound => '未找到结果'; @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String get noItemsAvailable => '没有可用的项目'; + @override String get failedToCreatePlayQueue => '创建播放队列失败'; + @override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目'; + @override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}'; } // Path: profile @@ -3958,6 +4361,15 @@ class _StringsLibrariesZh implements _StringsLibrariesEn { @override String get showLibrary => '显示媒体库'; @override String get hideLibrary => '隐藏媒体库'; @override String get libraryOptions => '媒体库选项'; + @override String get content => '媒体库内容'; + @override String get selectLibrary => '选择媒体库'; + @override String filtersWithCount({required Object count}) => '筛选器(${count})'; + @override String get noRecommendations => '暂无推荐'; + @override String get noCollections => '此媒体库中没有合集'; + @override String get noFoldersFound => '未找到文件夹'; + @override String get folders => '文件夹'; + @override late final _StringsLibrariesTabsZh tabs = _StringsLibrariesTabsZh._(_root); + @override late final _StringsLibrariesGroupingsZh groupings = _StringsLibrariesGroupingsZh._(_root); } // Path: about @@ -4092,6 +4504,68 @@ class _StringsPlaylistsZh implements _StringsPlaylistsEn { @override String get errorAdding => '添加到播放列表失败'; @override String get errorReordering => '重新排序播放列表项目失败'; @override String get errorRemoving => '从播放列表中移除失败'; + @override String get playlist => '播放列表'; +} + +// Path: collections +class _StringsCollectionsZh implements _StringsCollectionsEn { + _StringsCollectionsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '合集'; + @override String get collection => '合集'; + @override String get empty => '合集为空'; + @override String get noItems => '此合集没有项目'; + @override String get unknownLibrarySection => '无法删除:未知的媒体库分区'; + @override String get deleteCollection => '删除合集'; + @override String deleteConfirm({required Object title}) => '确定要删除"${title}"吗?此操作无法撤销。'; + @override String get deleted => '已删除合集'; + @override String get deleteFailed => '删除合集失败'; + @override String deleteFailedWithError({required Object error}) => '删除合集失败:${error}'; + @override String failedToLoadItems({required Object error}) => '加载合集项目失败:${error}'; + @override String get addTo => '添加到合集'; + @override String get selectCollection => '选择合集'; + @override String get createNewCollection => '创建新合集'; + @override String get collectionName => '合集名称'; + @override String get enterCollectionName => '输入合集名称'; + @override String get addedToCollection => '已添加到合集'; + @override String get errorAddingToCollection => '添加到合集失败'; + @override String get created => '已创建合集'; + @override String get removeFromCollection => '从合集移除'; + @override String removeFromCollectionConfirm({required Object title}) => '将“${title}”从此合集移除?'; + @override String get removedFromCollection => '已从合集移除'; + @override String get removeFromCollectionFailed => '从合集移除失败'; + @override String removeFromCollectionError({required Object error}) => '从合集移除时出错:${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsZh implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get recommended => '推荐'; + @override String get browse => '浏览'; + @override String get collections => '合集'; + @override String get playlists => '播放列表'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsZh implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get all => '全部'; + @override String get movies => '电影'; + @override String get shows => '剧集'; + @override String get seasons => '季'; + @override String get episodes => '集'; + @override String get folders => '文件夹'; } /// Flat map(s) containing all translations. @@ -4134,6 +4608,9 @@ extension on Translations { case 'common.yes': return 'Yes'; case 'common.no': return 'No'; case 'common.server': return 'Server'; + case 'common.delete': return 'Delete'; + case 'common.shuffle': return 'Shuffle'; + case 'common.addTo': return 'Add to...'; case 'screens.licenses': return 'Licenses'; case 'screens.selectServer': return 'Select Server'; case 'screens.switchProfile': return 'Switch Profile'; @@ -4306,6 +4783,10 @@ extension on Translations { case 'messages.noResultsFound': return 'No results found'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep timer set for ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Failed to switch to ${displayName}'; + case 'messages.noItemsAvailable': return 'No items available'; + case 'messages.failedToCreatePlayQueue': return 'Failed to create play queue'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Failed to create play queue - no items'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Failed to ${action}: ${error}'; case 'profile.noUsersAvailable': return 'No users available'; case 'subtitlingStyling.stylingOptions': return 'Styling Options'; case 'subtitlingStyling.fontSize': return 'Font Size'; @@ -4384,6 +4865,23 @@ extension on Translations { case 'libraries.showLibrary': return 'Show library'; case 'libraries.hideLibrary': return 'Hide library'; case 'libraries.libraryOptions': return 'Library options'; + case 'libraries.content': return 'library content'; + case 'libraries.selectLibrary': return 'Select library'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filters (${count})'; + case 'libraries.noRecommendations': return 'No recommendations available'; + case 'libraries.noCollections': return 'No collections in this library'; + case 'libraries.noFoldersFound': return 'No folders found'; + case 'libraries.folders': return 'folders'; + case 'libraries.tabs.recommended': return 'Recommended'; + case 'libraries.tabs.browse': return 'Browse'; + case 'libraries.tabs.collections': return 'Collections'; + case 'libraries.tabs.playlists': return 'Playlists'; + case 'libraries.groupings.all': return 'All'; + case 'libraries.groupings.movies': return 'Movies'; + case 'libraries.groupings.shows': return 'TV Shows'; + case 'libraries.groupings.seasons': return 'Seasons'; + case 'libraries.groupings.episodes': return 'Episodes'; + case 'libraries.groupings.folders': return 'Folders'; case 'about.title': return 'About'; case 'about.openSourceLicenses': return 'Open Source Licenses'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4421,7 +4919,32 @@ extension on Translations { case 'navigation.search': return 'Search'; case 'navigation.libraries': return 'Libraries'; case 'navigation.settings': return 'Settings'; + case 'collections.title': return 'Collections'; + case 'collections.collection': return 'Collection'; + case 'collections.empty': return 'Collection is empty'; + case 'collections.noItems': return 'No items in this collection'; + case 'collections.unknownLibrarySection': return 'Cannot delete: Unknown library section'; + case 'collections.deleteCollection': return 'Delete Collection'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.'; + case 'collections.deleted': return 'Collection deleted'; + case 'collections.deleteFailed': return 'Failed to delete collection'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Failed to delete collection: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Failed to load collection items: ${error}'; + case 'collections.addTo': return 'Add to collection'; + case 'collections.selectCollection': return 'Select Collection'; + case 'collections.createNewCollection': return 'Create New Collection'; + case 'collections.collectionName': return 'Collection Name'; + case 'collections.enterCollectionName': return 'Enter collection name'; + case 'collections.addedToCollection': return 'Added to collection'; + case 'collections.errorAddingToCollection': return 'Failed to add to collection'; + case 'collections.created': return 'Collection created'; + case 'collections.removeFromCollection': return 'Remove from collection'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Remove "${title}" from this collection?'; + case 'collections.removedFromCollection': return 'Removed from collection'; + case 'collections.removeFromCollectionFailed': return 'Failed to remove from collection'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Error removing from collection: ${error}'; case 'playlists.title': return 'Playlists'; + case 'playlists.playlist': return 'Playlist'; case 'playlists.noPlaylists': return 'No playlists found'; case 'playlists.create': return 'Create Playlist'; case 'playlists.newPlaylist': return 'New Playlist'; @@ -4497,6 +5020,9 @@ extension on _StringsDe { case 'common.yes': return 'Ja'; case 'common.no': return 'Nein'; case 'common.server': return 'Server'; + case 'common.delete': return 'Löschen'; + case 'common.shuffle': return 'Zufall'; + case 'common.addTo': return 'Hinzufügen zu...'; case 'screens.licenses': return 'Lizenzen'; case 'screens.selectServer': return 'Server auswählen'; case 'screens.switchProfile': return 'Profil wechseln'; @@ -4669,6 +5195,10 @@ extension on _StringsDe { case 'messages.noResultsFound': return 'Keine Ergebnisse gefunden'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'messages.noItemsAvailable': return 'Keine Elemente verfügbar'; + case 'messages.failedToCreatePlayQueue': return 'Wiedergabewarteschlange konnte nicht erstellt werden'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; case 'profile.noUsersAvailable': return 'Keine Benutzer verfügbar'; case 'subtitlingStyling.stylingOptions': return 'Stiloptionen'; case 'subtitlingStyling.fontSize': return 'Schriftgröße'; @@ -4747,6 +5277,23 @@ extension on _StringsDe { case 'libraries.showLibrary': return 'Mediathek anzeigen'; case 'libraries.hideLibrary': return 'Mediathek ausblenden'; case 'libraries.libraryOptions': return 'Mediatheksoptionen'; + case 'libraries.content': return 'Bibliotheksinhalt'; + case 'libraries.selectLibrary': return 'Bibliothek auswählen'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filter (${count})'; + case 'libraries.noRecommendations': return 'Keine Empfehlungen verfügbar'; + case 'libraries.noCollections': return 'Keine Sammlungen in dieser Mediathek'; + case 'libraries.noFoldersFound': return 'Keine Ordner gefunden'; + case 'libraries.folders': return 'Ordner'; + case 'libraries.tabs.recommended': return 'Empfohlen'; + case 'libraries.tabs.browse': return 'Durchsuchen'; + case 'libraries.tabs.collections': return 'Sammlungen'; + case 'libraries.tabs.playlists': return 'Wiedergabelisten'; + case 'libraries.groupings.all': return 'Alle'; + case 'libraries.groupings.movies': return 'Filme'; + case 'libraries.groupings.shows': return 'Serien'; + case 'libraries.groupings.seasons': return 'Staffeln'; + case 'libraries.groupings.episodes': return 'Episoden'; + case 'libraries.groupings.folders': return 'Ordner'; case 'about.title': return 'Über'; case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4818,6 +5365,31 @@ extension on _StringsDe { case 'playlists.errorAdding': return 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; case 'playlists.errorReordering': return 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; case 'playlists.errorRemoving': return 'Konnte nicht aus der Wiedergabeliste entfernt werden'; + case 'playlists.playlist': return 'Wiedergabeliste'; + case 'collections.title': return 'Sammlungen'; + case 'collections.collection': return 'Sammlung'; + case 'collections.empty': return 'Sammlung ist leer'; + case 'collections.noItems': return 'Keine Elemente in dieser Sammlung'; + case 'collections.unknownLibrarySection': return 'Löschen nicht möglich: Unbekannte Bibliothekssektion'; + case 'collections.deleteCollection': return 'Sammlung löschen'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Sind Sie sicher, dass Sie "${title}" löschen möchten? Dies kann nicht rückgängig gemacht werden.'; + case 'collections.deleted': return 'Sammlung gelöscht'; + case 'collections.deleteFailed': return 'Sammlung konnte nicht gelöscht werden'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}'; + case 'collections.addTo': return 'Zur Sammlung hinzufügen'; + case 'collections.selectCollection': return 'Sammlung auswählen'; + case 'collections.createNewCollection': return 'Neue Sammlung erstellen'; + case 'collections.collectionName': return 'Sammlungsname'; + case 'collections.enterCollectionName': return 'Sammlungsnamen eingeben'; + case 'collections.addedToCollection': return 'Zur Sammlung hinzugefügt'; + case 'collections.errorAddingToCollection': return 'Fehler beim Hinzufügen zur Sammlung'; + case 'collections.created': return 'Sammlung erstellt'; + case 'collections.removeFromCollection': return 'Aus Sammlung entfernen'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '"${title}" aus dieser Sammlung entfernen?'; + case 'collections.removedFromCollection': return 'Aus Sammlung entfernt'; + case 'collections.removeFromCollectionFailed': return 'Entfernen aus Sammlung fehlgeschlagen'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}'; default: return null; } } @@ -4860,6 +5432,9 @@ extension on _StringsIt { case 'common.yes': return 'Sì'; case 'common.no': return 'No'; case 'common.server': return 'Server'; + case 'common.delete': return 'Elimina'; + case 'common.shuffle': return 'Casuale'; + case 'common.addTo': return 'Aggiungi a...'; case 'screens.licenses': return 'Licenze'; case 'screens.selectServer': return 'Seleziona server'; case 'screens.switchProfile': return 'Cambia profilo'; @@ -5032,6 +5607,10 @@ extension on _StringsIt { case 'messages.noResultsFound': return 'Nessun risultato'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Imposta timer spegnimento per ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Impossibile passare a ${displayName}'; + case 'messages.noItemsAvailable': return 'Nessun elemento disponibile'; + case 'messages.failedToCreatePlayQueue': return 'Impossibile creare la coda di riproduzione'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Impossibile creare la coda di riproduzione - nessun elemento'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; case 'profile.noUsersAvailable': return 'Nessun utente disponibile'; case 'subtitlingStyling.stylingOptions': return 'Opzioni stile'; case 'subtitlingStyling.fontSize': return 'Dimensione'; @@ -5110,6 +5689,23 @@ extension on _StringsIt { case 'libraries.showLibrary': return 'Mostra libreria'; case 'libraries.hideLibrary': return 'Nascondi libreria'; case 'libraries.libraryOptions': return 'Opzioni libreria'; + case 'libraries.content': return 'contenuto della libreria'; + case 'libraries.selectLibrary': return 'Seleziona libreria'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filtri (${count})'; + case 'libraries.noRecommendations': return 'Nessun consiglio disponibile'; + case 'libraries.noCollections': return 'Nessuna raccolta in questa libreria'; + case 'libraries.noFoldersFound': return 'Nessuna cartella trovata'; + case 'libraries.folders': return 'cartelle'; + case 'libraries.tabs.recommended': return 'Consigliati'; + case 'libraries.tabs.browse': return 'Esplora'; + case 'libraries.tabs.collections': return 'Raccolte'; + case 'libraries.tabs.playlists': return 'Playlist'; + case 'libraries.groupings.all': return 'Tutti'; + case 'libraries.groupings.movies': return 'Film'; + case 'libraries.groupings.shows': return 'Serie TV'; + case 'libraries.groupings.seasons': return 'Stagioni'; + case 'libraries.groupings.episodes': return 'Episodi'; + case 'libraries.groupings.folders': return 'Cartelle'; case 'about.title': return 'Informazioni'; case 'about.openSourceLicenses': return 'Licenze Open Source'; case 'about.versionLabel': return ({required Object version}) => 'Versione ${version}'; @@ -5181,6 +5777,31 @@ extension on _StringsIt { case 'playlists.errorAdding': return 'Errore durante l\'aggiunta alla playlist'; case 'playlists.errorReordering': return 'Errore durante il riordino dell\'elemento della playlist'; case 'playlists.errorRemoving': return 'Errore durante la rimozione dalla playlist'; + case 'playlists.playlist': return 'Playlist'; + case 'collections.title': return 'Raccolte'; + case 'collections.collection': return 'Raccolta'; + case 'collections.empty': return 'La raccolta è vuota'; + case 'collections.noItems': return 'Nessun elemento in questa raccolta'; + case 'collections.unknownLibrarySection': return 'Impossibile eliminare: sezione libreria sconosciuta'; + case 'collections.deleteCollection': return 'Elimina raccolta'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Questa azione non può essere annullata.'; + case 'collections.deleted': return 'Raccolta eliminata'; + case 'collections.deleteFailed': return 'Impossibile eliminare la raccolta'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Impossibile eliminare la raccolta: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}'; + case 'collections.addTo': return 'Aggiungi alla raccolta'; + case 'collections.selectCollection': return 'Seleziona raccolta'; + case 'collections.createNewCollection': return 'Crea nuova raccolta'; + case 'collections.collectionName': return 'Nome raccolta'; + case 'collections.enterCollectionName': return 'Inserisci nome raccolta'; + case 'collections.addedToCollection': return 'Aggiunto alla raccolta'; + case 'collections.errorAddingToCollection': return 'Errore nell\'aggiunta alla raccolta'; + case 'collections.created': return 'Raccolta creata'; + case 'collections.removeFromCollection': return 'Rimuovi dalla raccolta'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Rimuovere "${title}" da questa raccolta?'; + case 'collections.removedFromCollection': return 'Rimosso dalla raccolta'; + case 'collections.removeFromCollectionFailed': return 'Impossibile rimuovere dalla raccolta'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}'; default: return null; } } @@ -5223,6 +5844,9 @@ extension on _StringsNl { case 'common.yes': return 'Ja'; case 'common.no': return 'Nee'; case 'common.server': return 'Server'; + case 'common.delete': return 'Verwijderen'; + case 'common.shuffle': return 'Shuffle'; + case 'common.addTo': return 'Toevoegen aan...'; case 'screens.licenses': return 'Licenties'; case 'screens.selectServer': return 'Selecteer server'; case 'screens.switchProfile': return 'Wissel van profiel'; @@ -5395,6 +6019,10 @@ extension on _StringsNl { case 'messages.noResultsFound': return 'Geen resultaten gevonden'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Slaap timer ingesteld voor ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Kon niet wisselen naar ${displayName}'; + case 'messages.noItemsAvailable': return 'Geen items beschikbaar'; + case 'messages.failedToCreatePlayQueue': return 'Kan afspeelwachtrij niet maken'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Kan afspeelwachtrij niet maken - geen items'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; case 'profile.noUsersAvailable': return 'Geen gebruikers beschikbaar'; case 'subtitlingStyling.stylingOptions': return 'Opmaak opties'; case 'subtitlingStyling.fontSize': return 'Lettergrootte'; @@ -5473,6 +6101,23 @@ extension on _StringsNl { case 'libraries.showLibrary': return 'Toon bibliotheek'; case 'libraries.hideLibrary': return 'Verberg bibliotheek'; case 'libraries.libraryOptions': return 'Bibliotheek opties'; + case 'libraries.content': return 'bibliotheekinhoud'; + case 'libraries.selectLibrary': return 'Bibliotheek kiezen'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filters (${count})'; + case 'libraries.noRecommendations': return 'Geen aanbevelingen beschikbaar'; + case 'libraries.noCollections': return 'Geen collecties in deze bibliotheek'; + case 'libraries.noFoldersFound': return 'Geen mappen gevonden'; + case 'libraries.folders': return 'mappen'; + case 'libraries.tabs.recommended': return 'Aanbevolen'; + case 'libraries.tabs.browse': return 'Bladeren'; + case 'libraries.tabs.collections': return 'Collecties'; + case 'libraries.tabs.playlists': return 'Afspeellijsten'; + case 'libraries.groupings.all': return 'Alles'; + case 'libraries.groupings.movies': return 'Films'; + case 'libraries.groupings.shows': return 'Series'; + case 'libraries.groupings.seasons': return 'Seizoenen'; + case 'libraries.groupings.episodes': return 'Afleveringen'; + case 'libraries.groupings.folders': return 'Mappen'; case 'about.title': return 'Over'; case 'about.openSourceLicenses': return 'Open Source licenties'; case 'about.versionLabel': return ({required Object version}) => 'Versie ${version}'; @@ -5544,6 +6189,31 @@ extension on _StringsNl { case 'playlists.errorAdding': return 'Fout bij toevoegen aan afspeellijst'; case 'playlists.errorReordering': return 'Fout bij herschikken van afspeellijstitem'; case 'playlists.errorRemoving': return 'Fout bij verwijderen uit afspeellijst'; + case 'playlists.playlist': return 'Afspeellijst'; + case 'collections.title': return 'Collecties'; + case 'collections.collection': return 'Collectie'; + case 'collections.empty': return 'Collectie is leeg'; + case 'collections.noItems': return 'Geen items in deze collectie'; + case 'collections.unknownLibrarySection': return 'Kan niet verwijderen: onbekende bibliotheeksectie'; + case 'collections.deleteCollection': return 'Collectie verwijderen'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.'; + case 'collections.deleted': return 'Collectie verwijderd'; + case 'collections.deleteFailed': return 'Collectie verwijderen mislukt'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Collectie verwijderen mislukt: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Collectie-items laden mislukt: ${error}'; + case 'collections.addTo': return 'Toevoegen aan collectie'; + case 'collections.selectCollection': return 'Selecteer collectie'; + case 'collections.createNewCollection': return 'Nieuwe collectie maken'; + case 'collections.collectionName': return 'Collectienaam'; + case 'collections.enterCollectionName': return 'Voer collectienaam in'; + case 'collections.addedToCollection': return 'Toegevoegd aan collectie'; + case 'collections.errorAddingToCollection': return 'Fout bij toevoegen aan collectie'; + case 'collections.created': return 'Collectie gemaakt'; + case 'collections.removeFromCollection': return 'Verwijderen uit collectie'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '"${title}" uit deze collectie verwijderen?'; + case 'collections.removedFromCollection': return 'Uit collectie verwijderd'; + case 'collections.removeFromCollectionFailed': return 'Verwijderen uit collectie mislukt'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}'; default: return null; } } @@ -5586,6 +6256,9 @@ extension on _StringsSv { case 'common.yes': return 'Ja'; case 'common.no': return 'Nej'; case 'common.server': return 'Server'; + case 'common.delete': return 'Ta bort'; + case 'common.shuffle': return 'Blanda'; + case 'common.addTo': return 'Lägg till i...'; case 'screens.licenses': return 'Licenser'; case 'screens.selectServer': return 'Välj server'; case 'screens.switchProfile': return 'Byt profil'; @@ -5758,6 +6431,10 @@ extension on _StringsSv { case 'messages.noResultsFound': return 'Inga resultat hittades'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sovtimer inställd för ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + case 'messages.noItemsAvailable': return 'Inga objekt tillgängliga'; + case 'messages.failedToCreatePlayQueue': return 'Det gick inte att skapa uppspelningskö'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Det gick inte att skapa uppspelningskö – inga objekt'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; case 'profile.noUsersAvailable': return 'Inga användare tillgängliga'; case 'subtitlingStyling.stylingOptions': return 'Stilalternativ'; case 'subtitlingStyling.fontSize': return 'Teckenstorlek'; @@ -5836,6 +6513,23 @@ extension on _StringsSv { case 'libraries.showLibrary': return 'Visa bibliotek'; case 'libraries.hideLibrary': return 'Dölj bibliotek'; case 'libraries.libraryOptions': return 'Biblioteksalternativ'; + case 'libraries.content': return 'bibliotekets innehåll'; + case 'libraries.selectLibrary': return 'Välj bibliotek'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filter (${count})'; + case 'libraries.noRecommendations': return 'Inga rekommendationer tillgängliga'; + case 'libraries.noCollections': return 'Inga samlingar i det här biblioteket'; + case 'libraries.noFoldersFound': return 'Inga mappar hittades'; + case 'libraries.folders': return 'mappar'; + case 'libraries.tabs.recommended': return 'Rekommenderat'; + case 'libraries.tabs.browse': return 'Bläddra'; + case 'libraries.tabs.collections': return 'Samlingar'; + case 'libraries.tabs.playlists': return 'Spellistor'; + case 'libraries.groupings.all': return 'Alla'; + case 'libraries.groupings.movies': return 'Filmer'; + case 'libraries.groupings.shows': return 'Serier'; + case 'libraries.groupings.seasons': return 'Säsonger'; + case 'libraries.groupings.episodes': return 'Avsnitt'; + case 'libraries.groupings.folders': return 'Mappar'; case 'about.title': return 'Om'; case 'about.openSourceLicenses': return 'Öppen källkod-licenser'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -5907,6 +6601,31 @@ extension on _StringsSv { case 'playlists.errorAdding': return 'Det gick inte att lägga till i spellista'; case 'playlists.errorReordering': return 'Det gick inte att omordna spellisteobjekt'; case 'playlists.errorRemoving': return 'Det gick inte att ta bort från spellista'; + case 'playlists.playlist': return 'Spellista'; + case 'collections.title': return 'Samlingar'; + case 'collections.collection': return 'Samling'; + case 'collections.empty': return 'Samlingen är tom'; + case 'collections.noItems': return 'Inga objekt i den här samlingen'; + case 'collections.unknownLibrarySection': return 'Kan inte ta bort: okänd bibliotekssektion'; + case 'collections.deleteCollection': return 'Ta bort samling'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Detta går inte att ångra.'; + case 'collections.deleted': return 'Samling borttagen'; + case 'collections.deleteFailed': return 'Det gick inte att ta bort samlingen'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}'; + case 'collections.addTo': return 'Lägg till i samling'; + case 'collections.selectCollection': return 'Välj samling'; + case 'collections.createNewCollection': return 'Skapa ny samling'; + case 'collections.collectionName': return 'Samlingsnamn'; + case 'collections.enterCollectionName': return 'Ange samlingsnamn'; + case 'collections.addedToCollection': return 'Tillagd i samling'; + case 'collections.errorAddingToCollection': return 'Fel vid tillägg i samling'; + case 'collections.created': return 'Samling skapad'; + case 'collections.removeFromCollection': return 'Ta bort från samling'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Ta bort "${title}" från denna samling?'; + case 'collections.removedFromCollection': return 'Borttagen från samling'; + case 'collections.removeFromCollectionFailed': return 'Misslyckades med att ta bort från samling'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fel vid borttagning från samling: ${error}'; default: return null; } } @@ -5949,6 +6668,9 @@ extension on _StringsZh { case 'common.yes': return '是'; case 'common.no': return '否'; case 'common.server': return '服务器'; + case 'common.delete': return '删除'; + case 'common.shuffle': return '随机播放'; + case 'common.addTo': return '添加到...'; case 'screens.licenses': return '许可证'; case 'screens.selectServer': return '选择服务器'; case 'screens.switchProfile': return '切换配置文件'; @@ -6121,6 +6843,10 @@ extension on _StringsZh { case 'messages.noResultsFound': return '未找到结果'; case 'messages.sleepTimerSet': return ({required Object label}) => '睡眠定时器已设置为 ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'messages.noItemsAvailable': return '没有可用的项目'; + case 'messages.failedToCreatePlayQueue': return '创建播放队列失败'; + case 'messages.failedToCreatePlayQueueNoItems': return '创建播放队列失败 - 没有项目'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => '无法${action}: ${error}'; case 'profile.noUsersAvailable': return '没有可用用户'; case 'subtitlingStyling.stylingOptions': return '样式选项'; case 'subtitlingStyling.fontSize': return '字号'; @@ -6199,6 +6925,23 @@ extension on _StringsZh { case 'libraries.showLibrary': return '显示媒体库'; case 'libraries.hideLibrary': return '隐藏媒体库'; case 'libraries.libraryOptions': return '媒体库选项'; + case 'libraries.content': return '媒体库内容'; + case 'libraries.selectLibrary': return '选择媒体库'; + case 'libraries.filtersWithCount': return ({required Object count}) => '筛选器(${count})'; + case 'libraries.noRecommendations': return '暂无推荐'; + case 'libraries.noCollections': return '此媒体库中没有合集'; + case 'libraries.noFoldersFound': return '未找到文件夹'; + case 'libraries.folders': return '文件夹'; + case 'libraries.tabs.recommended': return '推荐'; + case 'libraries.tabs.browse': return '浏览'; + case 'libraries.tabs.collections': return '合集'; + case 'libraries.tabs.playlists': return '播放列表'; + case 'libraries.groupings.all': return '全部'; + case 'libraries.groupings.movies': return '电影'; + case 'libraries.groupings.shows': return '剧集'; + case 'libraries.groupings.seasons': return '季'; + case 'libraries.groupings.episodes': return '集'; + case 'libraries.groupings.folders': return '文件夹'; case 'about.title': return '关于'; case 'about.openSourceLicenses': return '开源许可证'; case 'about.versionLabel': return ({required Object version}) => '版本 ${version}'; @@ -6270,6 +7013,31 @@ extension on _StringsZh { case 'playlists.errorAdding': return '添加到播放列表失败'; case 'playlists.errorReordering': return '重新排序播放列表项目失败'; case 'playlists.errorRemoving': return '从播放列表中移除失败'; + case 'playlists.playlist': return '播放列表'; + case 'collections.title': return '合集'; + case 'collections.collection': return '合集'; + case 'collections.empty': return '合集为空'; + case 'collections.noItems': return '此合集没有项目'; + case 'collections.unknownLibrarySection': return '无法删除:未知的媒体库分区'; + case 'collections.deleteCollection': return '删除合集'; + case 'collections.deleteConfirm': return ({required Object title}) => '确定要删除"${title}"吗?此操作无法撤销。'; + case 'collections.deleted': return '已删除合集'; + case 'collections.deleteFailed': return '删除合集失败'; + case 'collections.deleteFailedWithError': return ({required Object error}) => '删除合集失败:${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => '加载合集项目失败:${error}'; + case 'collections.addTo': return '添加到合集'; + case 'collections.selectCollection': return '选择合集'; + case 'collections.createNewCollection': return '创建新合集'; + case 'collections.collectionName': return '合集名称'; + case 'collections.enterCollectionName': return '输入合集名称'; + case 'collections.addedToCollection': return '已添加到合集'; + case 'collections.errorAddingToCollection': return '添加到合集失败'; + case 'collections.created': return '已创建合集'; + case 'collections.removeFromCollection': return '从合集移除'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '将“${title}”从此合集移除?'; + case 'collections.removedFromCollection': return '已从合集移除'; + case 'collections.removeFromCollectionFailed': return '从合集移除失败'; + case 'collections.removeFromCollectionError': return ({required Object error}) => '从合集移除时出错:${error}'; default: return null; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index e57bc0fe..f2539445 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -37,7 +37,10 @@ "refresh": "Refresh", "yes": "Yes", "no": "No", - "server": "Server" + "server": "Server", + "delete": "Delete", + "shuffle": "Shuffle", + "addTo": "Add to..." }, "screens": { "licenses": "Licenses", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "No episodes found", "noResultsFound": "No results found", "sleepTimerSet": "Sleep timer set for ${label}", - "failedToSwitchProfile": "Failed to switch to ${displayName}" + "failedToSwitchProfile": "Failed to switch to ${displayName}", + "noItemsAvailable": "No items available", + "failedToCreatePlayQueue": "Failed to create play queue", + "failedToCreatePlayQueueNoItems": "Failed to create play queue - no items", + "failedPlayback": "Failed to ${action}: ${error}" }, "profile": { "noUsersAvailable": "No users available" @@ -323,7 +330,28 @@ "confirmActionMessage": "Are you sure you want to perform this action?", "showLibrary": "Show library", "hideLibrary": "Hide library", - "libraryOptions": "Library options" + "libraryOptions": "Library options", + "content": "library content", + "selectLibrary": "Select library", + "filtersWithCount": "Filters (${count})", + "noRecommendations": "No recommendations available", + "noCollections": "No collections in this library", + "noFoldersFound": "No folders found", + "folders": "folders", + "tabs": { + "recommended": "Recommended", + "browse": "Browse", + "collections": "Collections", + "playlists": "Playlists" + }, + "groupings": { + "all": "All", + "movies": "Movies", + "shows": "TV Shows", + "seasons": "Seasons", + "episodes": "Episodes", + "folders": "Folders" + } }, "about": { "title": "About", @@ -374,8 +402,35 @@ "libraries": "Libraries", "settings": "Settings" }, + "collections": { + "title": "Collections", + "collection": "Collection", + "empty": "Collection is empty", + "noItems": "No items in this collection", + "unknownLibrarySection": "Cannot delete: Unknown library section", + "deleteCollection": "Delete Collection", + "deleteConfirm": "Are you sure you want to delete \"${title}\"? This action cannot be undone.", + "deleted": "Collection deleted", + "deleteFailed": "Failed to delete collection", + "deleteFailedWithError": "Failed to delete collection: ${error}", + "failedToLoadItems": "Failed to load collection items: ${error}", + "addTo": "Add to collection", + "selectCollection": "Select Collection", + "createNewCollection": "Create New Collection", + "collectionName": "Collection Name", + "enterCollectionName": "Enter collection name", + "addedToCollection": "Added to collection", + "errorAddingToCollection": "Failed to add to collection", + "created": "Collection created", + "removeFromCollection": "Remove from collection", + "removeFromCollectionConfirm": "Remove \"${title}\" from this collection?", + "removedFromCollection": "Removed from collection", + "removeFromCollectionFailed": "Failed to remove from collection", + "removeFromCollectionError": "Error removing from collection: ${error}" + }, "playlists": { "title": "Playlists", + "playlist": "Playlist", "noPlaylists": "No playlists found", "create": "Create Playlist", "newPlaylist": "New Playlist", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index e78c5411..2cc35a73 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -37,7 +37,10 @@ "refresh": "Aktualisieren", "yes": "Ja", "no": "Nein", - "server": "Server" + "server": "Server", + "delete": "Löschen", + "shuffle": "Zufall", + "addTo": "Hinzufügen zu..." }, "screens": { "licenses": "Lizenzen", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Keine Episoden gefunden", "noResultsFound": "Keine Ergebnisse gefunden", "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", - "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "noItemsAvailable": "Keine Elemente verfügbar", + "failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden", + "failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente", + "failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}" }, "profile": { "noUsersAvailable": "Keine Benutzer verfügbar" @@ -323,7 +330,28 @@ "confirmActionMessage": "Aktion wirklich durchführen?", "showLibrary": "Mediathek anzeigen", "hideLibrary": "Mediathek ausblenden", - "libraryOptions": "Mediatheksoptionen" + "libraryOptions": "Mediatheksoptionen", + "content": "Bibliotheksinhalt", + "selectLibrary": "Bibliothek auswählen", + "filtersWithCount": "Filter (${count})", + "noRecommendations": "Keine Empfehlungen verfügbar", + "noCollections": "Keine Sammlungen in dieser Mediathek", + "noFoldersFound": "Keine Ordner gefunden", + "folders": "Ordner", + "tabs": { + "recommended": "Empfohlen", + "browse": "Durchsuchen", + "collections": "Sammlungen", + "playlists": "Wiedergabelisten" + }, + "groupings": { + "all": "Alle", + "movies": "Filme", + "shows": "Serien", + "seasons": "Staffeln", + "episodes": "Episoden", + "folders": "Ordner" + } }, "about": { "title": "Über", @@ -408,6 +436,33 @@ "errorLoading": "Wiedergabelisten konnten nicht geladen werden", "errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden", "errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden", - "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden" + "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden", + "playlist": "Wiedergabeliste" + }, + "collections": { + "title": "Sammlungen", + "collection": "Sammlung", + "empty": "Sammlung ist leer", + "noItems": "Keine Elemente in dieser Sammlung", + "unknownLibrarySection": "Löschen nicht möglich: Unbekannte Bibliothekssektion", + "deleteCollection": "Sammlung löschen", + "deleteConfirm": "Sind Sie sicher, dass Sie \"${title}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "deleted": "Sammlung gelöscht", + "deleteFailed": "Sammlung konnte nicht gelöscht werden", + "deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}", + "failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}", + "addTo": "Zur Sammlung hinzufügen", + "selectCollection": "Sammlung auswählen", + "createNewCollection": "Neue Sammlung erstellen", + "collectionName": "Sammlungsname", + "enterCollectionName": "Sammlungsnamen eingeben", + "addedToCollection": "Zur Sammlung hinzugefügt", + "errorAddingToCollection": "Fehler beim Hinzufügen zur Sammlung", + "created": "Sammlung erstellt", + "removeFromCollection": "Aus Sammlung entfernen", + "removeFromCollectionConfirm": "\"${title}\" aus dieser Sammlung entfernen?", + "removedFromCollection": "Aus Sammlung entfernt", + "removeFromCollectionFailed": "Entfernen aus Sammlung fehlgeschlagen", + "removeFromCollectionError": "Fehler beim Entfernen aus der Sammlung: ${error}" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 04a05e14..43033fe8 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -37,7 +37,10 @@ "refresh": "Aggiorna", "yes": "Sì", "no": "No", - "server": "Server" + "server": "Server", + "delete": "Elimina", + "shuffle": "Casuale", + "addTo": "Aggiungi a..." }, "screens": { "licenses": "Licenze", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Nessun episodio trovato", "noResultsFound": "Nessun risultato", "sleepTimerSet": "Imposta timer spegnimento per ${label}", - "failedToSwitchProfile": "Impossibile passare a ${displayName}" + "failedToSwitchProfile": "Impossibile passare a ${displayName}", + "noItemsAvailable": "Nessun elemento disponibile", + "failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione", + "failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento", + "failedPlayback": "Impossibile ${action}: ${error}" }, "profile": { "noUsersAvailable": "Nessun utente disponibile" @@ -323,7 +330,28 @@ "confirmActionMessage": "Sei sicuro di voler eseguire questa azione?", "showLibrary": "Mostra libreria", "hideLibrary": "Nascondi libreria", - "libraryOptions": "Opzioni libreria" + "libraryOptions": "Opzioni libreria", + "content": "contenuto della libreria", + "selectLibrary": "Seleziona libreria", + "filtersWithCount": "Filtri (${count})", + "noRecommendations": "Nessun consiglio disponibile", + "noCollections": "Nessuna raccolta in questa libreria", + "noFoldersFound": "Nessuna cartella trovata", + "folders": "cartelle", + "tabs": { + "recommended": "Consigliati", + "browse": "Esplora", + "collections": "Raccolte", + "playlists": "Playlist" + }, + "groupings": { + "all": "Tutti", + "movies": "Film", + "shows": "Serie TV", + "seasons": "Stagioni", + "episodes": "Episodi", + "folders": "Cartelle" + } }, "about": { "title": "Informazioni", @@ -408,6 +436,33 @@ "errorLoading": "Errore durante il caricamento delle playlist", "errorAdding": "Errore durante l'aggiunta alla playlist", "errorReordering": "Errore durante il riordino dell'elemento della playlist", - "errorRemoving": "Errore durante la rimozione dalla playlist" + "errorRemoving": "Errore durante la rimozione dalla playlist", + "playlist": "Playlist" + }, + "collections": { + "title": "Raccolte", + "collection": "Raccolta", + "empty": "La raccolta è vuota", + "noItems": "Nessun elemento in questa raccolta", + "unknownLibrarySection": "Impossibile eliminare: sezione libreria sconosciuta", + "deleteCollection": "Elimina raccolta", + "deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Questa azione non può essere annullata.", + "deleted": "Raccolta eliminata", + "deleteFailed": "Impossibile eliminare la raccolta", + "deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}", + "failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}", + "addTo": "Aggiungi alla raccolta", + "selectCollection": "Seleziona raccolta", + "createNewCollection": "Crea nuova raccolta", + "collectionName": "Nome raccolta", + "enterCollectionName": "Inserisci nome raccolta", + "addedToCollection": "Aggiunto alla raccolta", + "errorAddingToCollection": "Errore nell'aggiunta alla raccolta", + "created": "Raccolta creata", + "removeFromCollection": "Rimuovi dalla raccolta", + "removeFromCollectionConfirm": "Rimuovere \"${title}\" da questa raccolta?", + "removedFromCollection": "Rimosso dalla raccolta", + "removeFromCollectionFailed": "Impossibile rimuovere dalla raccolta", + "removeFromCollectionError": "Errore durante la rimozione dalla raccolta: ${error}" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index bced748e..0b7fb138 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -37,7 +37,10 @@ "refresh": "Vernieuwen", "yes": "Ja", "no": "Nee", - "server": "Server" + "server": "Server", + "delete": "Verwijderen", + "shuffle": "Shuffle", + "addTo": "Toevoegen aan..." }, "screens": { "licenses": "Licenties", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Geen afleveringen gevonden", "noResultsFound": "Geen resultaten gevonden", "sleepTimerSet": "Slaap timer ingesteld voor ${label}", - "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}" + "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}", + "noItemsAvailable": "Geen items beschikbaar", + "failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken", + "failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items", + "failedPlayback": "Afspelen van ${action} mislukt: ${error}" }, "profile": { "noUsersAvailable": "Geen gebruikers beschikbaar" @@ -323,7 +330,28 @@ "confirmActionMessage": "Weet je zeker dat je deze actie wilt uitvoeren?", "showLibrary": "Toon bibliotheek", "hideLibrary": "Verberg bibliotheek", - "libraryOptions": "Bibliotheek opties" + "libraryOptions": "Bibliotheek opties", + "content": "bibliotheekinhoud", + "selectLibrary": "Bibliotheek kiezen", + "filtersWithCount": "Filters (${count})", + "noRecommendations": "Geen aanbevelingen beschikbaar", + "noCollections": "Geen collecties in deze bibliotheek", + "noFoldersFound": "Geen mappen gevonden", + "folders": "mappen", + "tabs": { + "recommended": "Aanbevolen", + "browse": "Bladeren", + "collections": "Collecties", + "playlists": "Afspeellijsten" + }, + "groupings": { + "all": "Alles", + "movies": "Films", + "shows": "Series", + "seasons": "Seizoenen", + "episodes": "Afleveringen", + "folders": "Mappen" + } }, "about": { "title": "Over", @@ -408,6 +436,33 @@ "errorLoading": "Fout bij laden afspeellijsten", "errorAdding": "Fout bij toevoegen aan afspeellijst", "errorReordering": "Fout bij herschikken van afspeellijstitem", - "errorRemoving": "Fout bij verwijderen uit afspeellijst" + "errorRemoving": "Fout bij verwijderen uit afspeellijst", + "playlist": "Afspeellijst" + }, + "collections": { + "title": "Collecties", + "collection": "Collectie", + "empty": "Collectie is leeg", + "noItems": "Geen items in deze collectie", + "unknownLibrarySection": "Kan niet verwijderen: onbekende bibliotheeksectie", + "deleteCollection": "Collectie verwijderen", + "deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "deleted": "Collectie verwijderd", + "deleteFailed": "Collectie verwijderen mislukt", + "deleteFailedWithError": "Collectie verwijderen mislukt: ${error}", + "failedToLoadItems": "Collectie-items laden mislukt: ${error}", + "addTo": "Toevoegen aan collectie", + "selectCollection": "Selecteer collectie", + "createNewCollection": "Nieuwe collectie maken", + "collectionName": "Collectienaam", + "enterCollectionName": "Voer collectienaam in", + "addedToCollection": "Toegevoegd aan collectie", + "errorAddingToCollection": "Fout bij toevoegen aan collectie", + "created": "Collectie gemaakt", + "removeFromCollection": "Verwijderen uit collectie", + "removeFromCollectionConfirm": "\"${title}\" uit deze collectie verwijderen?", + "removedFromCollection": "Uit collectie verwijderd", + "removeFromCollectionFailed": "Verwijderen uit collectie mislukt", + "removeFromCollectionError": "Fout bij verwijderen uit collectie: ${error}" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index b97094d9..2340e6ea 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -37,7 +37,10 @@ "refresh": "Uppdatera", "yes": "Ja", "no": "Nej", - "server": "Server" + "server": "Server", + "delete": "Ta bort", + "shuffle": "Blanda", + "addTo": "Lägg till i..." }, "screens": { "licenses": "Licenser", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Inga avsnitt hittades", "noResultsFound": "Inga resultat hittades", "sleepTimerSet": "Sovtimer inställd för ${label}", - "failedToSwitchProfile": "Misslyckades att byta till ${displayName}" + "failedToSwitchProfile": "Misslyckades att byta till ${displayName}", + "noItemsAvailable": "Inga objekt tillgängliga", + "failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö", + "failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt", + "failedPlayback": "Kunde inte ${action}: ${error}" }, "profile": { "noUsersAvailable": "Inga användare tillgängliga" @@ -323,7 +330,28 @@ "confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?", "showLibrary": "Visa bibliotek", "hideLibrary": "Dölj bibliotek", - "libraryOptions": "Biblioteksalternativ" + "libraryOptions": "Biblioteksalternativ", + "content": "bibliotekets innehåll", + "selectLibrary": "Välj bibliotek", + "filtersWithCount": "Filter (${count})", + "noRecommendations": "Inga rekommendationer tillgängliga", + "noCollections": "Inga samlingar i det här biblioteket", + "noFoldersFound": "Inga mappar hittades", + "folders": "mappar", + "tabs": { + "recommended": "Rekommenderat", + "browse": "Bläddra", + "collections": "Samlingar", + "playlists": "Spellistor" + }, + "groupings": { + "all": "Alla", + "movies": "Filmer", + "shows": "Serier", + "seasons": "Säsonger", + "episodes": "Avsnitt", + "folders": "Mappar" + } }, "about": { "title": "Om", @@ -408,6 +436,33 @@ "errorLoading": "Det gick inte att ladda spellistor", "errorAdding": "Det gick inte att lägga till i spellista", "errorReordering": "Det gick inte att omordna spellisteobjekt", - "errorRemoving": "Det gick inte att ta bort från spellista" + "errorRemoving": "Det gick inte att ta bort från spellista", + "playlist": "Spellista" + }, + "collections": { + "title": "Samlingar", + "collection": "Samling", + "empty": "Samlingen är tom", + "noItems": "Inga objekt i den här samlingen", + "unknownLibrarySection": "Kan inte ta bort: okänd bibliotekssektion", + "deleteCollection": "Ta bort samling", + "deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Detta går inte att ångra.", + "deleted": "Samling borttagen", + "deleteFailed": "Det gick inte att ta bort samlingen", + "deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}", + "failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}", + "addTo": "Lägg till i samling", + "selectCollection": "Välj samling", + "createNewCollection": "Skapa ny samling", + "collectionName": "Samlingsnamn", + "enterCollectionName": "Ange samlingsnamn", + "addedToCollection": "Tillagd i samling", + "errorAddingToCollection": "Fel vid tillägg i samling", + "created": "Samling skapad", + "removeFromCollection": "Ta bort från samling", + "removeFromCollectionConfirm": "Ta bort \"${title}\" från denna samling?", + "removedFromCollection": "Borttagen från samling", + "removeFromCollectionFailed": "Misslyckades med att ta bort från samling", + "removeFromCollectionError": "Fel vid borttagning från samling: ${error}" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 8daa0990..f19a0a78 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -37,7 +37,10 @@ "refresh": "刷新", "yes": "是", "no": "否", - "server": "服务器" + "server": "服务器", + "delete": "删除", + "shuffle": "随机播放", + "addTo": "添加到..." }, "screens": { "licenses": "许可证", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "未找到剧集", "noResultsFound": "未找到结果", "sleepTimerSet": "睡眠定时器已设置为 ${label}", - "failedToSwitchProfile": "无法切换到 ${displayName}" + "failedToSwitchProfile": "无法切换到 ${displayName}", + "noItemsAvailable": "没有可用的项目", + "failedToCreatePlayQueue": "创建播放队列失败", + "failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目", + "failedPlayback": "无法${action}: ${error}" }, "profile": { "noUsersAvailable": "没有可用用户" @@ -323,7 +330,28 @@ "confirmActionMessage": "确定要执行此操作吗?", "showLibrary": "显示媒体库", "hideLibrary": "隐藏媒体库", - "libraryOptions": "媒体库选项" + "libraryOptions": "媒体库选项", + "content": "媒体库内容", + "selectLibrary": "选择媒体库", + "filtersWithCount": "筛选器(${count})", + "noRecommendations": "暂无推荐", + "noCollections": "此媒体库中没有合集", + "noFoldersFound": "未找到文件夹", + "folders": "文件夹", + "tabs": { + "recommended": "推荐", + "browse": "浏览", + "collections": "合集", + "playlists": "播放列表" + }, + "groupings": { + "all": "全部", + "movies": "电影", + "shows": "剧集", + "seasons": "季", + "episodes": "集", + "folders": "文件夹" + } }, "about": { "title": "关于", @@ -408,6 +436,33 @@ "errorLoading": "加载播放列表失败", "errorAdding": "添加到播放列表失败", "errorReordering": "重新排序播放列表项目失败", - "errorRemoving": "从播放列表中移除失败" + "errorRemoving": "从播放列表中移除失败", + "playlist": "播放列表" + }, + "collections": { + "title": "合集", + "collection": "合集", + "empty": "合集为空", + "noItems": "此合集没有项目", + "unknownLibrarySection": "无法删除:未知的媒体库分区", + "deleteCollection": "删除合集", + "deleteConfirm": "确定要删除\"${title}\"吗?此操作无法撤销。", + "deleted": "已删除合集", + "deleteFailed": "删除合集失败", + "deleteFailedWithError": "删除合集失败:${error}", + "failedToLoadItems": "加载合集项目失败:${error}", + "addTo": "添加到合集", + "selectCollection": "选择合集", + "createNewCollection": "创建新合集", + "collectionName": "合集名称", + "enterCollectionName": "输入合集名称", + "addedToCollection": "已添加到合集", + "errorAddingToCollection": "添加到合集失败", + "created": "已创建合集", + "removeFromCollection": "从合集移除", + "removeFromCollectionConfirm": "将“${title}”从此合集移除?", + "removedFromCollection": "已从合集移除", + "removeFromCollectionFailed": "从合集移除失败", + "removeFromCollectionError": "从合集移除时出错:${error}" } } diff --git a/lib/models/play_queue_response.dart b/lib/models/play_queue_response.dart new file mode 100644 index 00000000..dbfd0b1a --- /dev/null +++ b/lib/models/play_queue_response.dart @@ -0,0 +1,77 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'plex_metadata.dart'; + +part 'play_queue_response.g.dart'; + +/// Converter to handle both int (0/1) and bool values from Plex API +class BoolOrIntConverter implements JsonConverter { + const BoolOrIntConverter(); + + @override + bool fromJson(Object json) { + if (json is bool) return json; + if (json is int) return json != 0; + if (json is String) return json.toLowerCase() == 'true' || json == '1'; + return false; + } + + @override + Object toJson(bool object) => object; +} + +/// Response from Plex play queue API +/// Contains queue metadata and a window of items +@JsonSerializable(createToJson: false) +class PlayQueueResponse { + final int playQueueID; + final int? playQueueSelectedItemID; + final int? playQueueSelectedItemOffset; + final String? playQueueSelectedMetadataItemID; + @BoolOrIntConverter() + final bool playQueueShuffled; + final String? playQueueSourceURI; + final int? playQueueTotalCount; + final int playQueueVersion; + final int? size; // Number of items in this response window + @JsonKey(name: 'Metadata') + final List? items; + + PlayQueueResponse({ + required this.playQueueID, + this.playQueueSelectedItemID, + this.playQueueSelectedItemOffset, + this.playQueueSelectedMetadataItemID, + required this.playQueueShuffled, + this.playQueueSourceURI, + required this.playQueueTotalCount, + required this.playQueueVersion, + this.size, + this.items, + }); + + factory PlayQueueResponse.fromJson(Map json) { + // The API returns data wrapped in MediaContainer + final container = json['MediaContainer'] as Map? ?? json; + return _$PlayQueueResponseFromJson(container); + } + + /// Get the current selected item from the queue + PlexMetadata? get selectedItem { + if (items == null || playQueueSelectedItemID == null) return null; + try { + return items!.firstWhere( + (item) => item.playQueueItemID == playQueueSelectedItemID, + ); + } catch (e) { + return null; + } + } + + /// Get the index of the selected item in the current window + int? get selectedItemIndex { + if (items == null || playQueueSelectedItemID == null) return null; + return items!.indexWhere( + (item) => item.playQueueItemID == playQueueSelectedItemID, + ); + } +} diff --git a/lib/models/play_queue_response.g.dart b/lib/models/play_queue_response.g.dart new file mode 100644 index 00000000..8f8e3aa9 --- /dev/null +++ b/lib/models/play_queue_response.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'play_queue_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayQueueResponse _$PlayQueueResponseFromJson(Map json) => + PlayQueueResponse( + playQueueID: (json['playQueueID'] as num).toInt(), + playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?) + ?.toInt(), + playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?) + ?.toInt(), + playQueueSelectedMetadataItemID: + json['playQueueSelectedMetadataItemID'] as String?, + playQueueShuffled: const BoolOrIntConverter().fromJson( + json['playQueueShuffled'] as Object, + ), + playQueueSourceURI: json['playQueueSourceURI'] as String?, + playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(), + playQueueVersion: (json['playQueueVersion'] as num).toInt(), + size: (json['size'] as num?)?.toInt(), + items: (json['Metadata'] as List?) + ?.map((e) => PlexMetadata.fromJson(e as Map)) + .toList(), + ); diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 52233ed1..7274b83c 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -36,11 +36,14 @@ class PlexMetadata { final int? viewCount; final int? leafCount; // Total number of episodes in a series/season final int? viewedLeafCount; // Number of watched episodes in a series/season + final int? childCount; // Number of items in a collection or playlist @JsonKey(name: 'Role') final List? role; // Cast members final String? audioLanguage; // Per-media preferred audio language final String? subtitleLanguage; // Per-media preferred subtitle language final int? playlistItemID; // Playlist item ID (for dumb playlists only) + final int? playQueueItemID; // Play queue item ID (unique even for duplicates) + final int? librarySectionID; // Library section ID this item belongs to // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -77,10 +80,13 @@ class PlexMetadata { this.viewCount, this.leafCount, this.viewedLeafCount, + this.childCount, this.role, this.audioLanguage, this.subtitleLanguage, this.playlistItemID, + this.playQueueItemID, + this.librarySectionID, }); /// Create a copy of this metadata with optional field overrides @@ -115,10 +121,13 @@ class PlexMetadata { int? viewCount, int? leafCount, int? viewedLeafCount, + int? childCount, List? role, String? audioLanguage, String? subtitleLanguage, int? playlistItemID, + int? playQueueItemID, + int? librarySectionID, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -151,10 +160,13 @@ class PlexMetadata { viewCount: viewCount ?? this.viewCount, leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, role: role ?? this.role, audioLanguage: audioLanguage ?? this.audioLanguage, subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, playlistItemID: playlistItemID ?? this.playlistItemID, + playQueueItemID: playQueueItemID ?? this.playQueueItemID, + librarySectionID: librarySectionID ?? this.librarySectionID, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index a9f262ea..d5017ef0 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -37,12 +37,15 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( viewCount: (json['viewCount'] as num?)?.toInt(), leafCount: (json['leafCount'] as num?)?.toInt(), viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), + childCount: (json['childCount'] as num?)?.toInt(), role: (json['Role'] as List?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, playlistItemID: (json['playlistItemID'] as num?)?.toInt(), + playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(), + librarySectionID: (json['librarySectionID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -77,8 +80,11 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'viewCount': instance.viewCount, 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, + 'childCount': instance.childCount, 'Role': instance.role, 'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage, 'playlistItemID': instance.playlistItemID, + 'playQueueItemID': instance.playQueueItemID, + 'librarySectionID': instance.librarySectionID, }; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index b994af6c..3b4976df 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,128 +1,287 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; +import '../models/play_queue_response.dart'; +import '../client/plex_client.dart'; /// Playback mode types enum PlaybackMode { none, // No active playback queue sequential, // Normal episode-to-episode playback (uses Plex API) - shufflePlay, // Shuffle play for shows/seasons - playlist, // Playlist playback (ordered or shuffled) + playQueue, // Play queue-based playback (playlists, collections, shuffle) } -/// Manages playback state for TV shows, seasons, and playlists. +/// Manages playback state using Plex's play queue API. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { - List _queue = []; + // Play queue state + int? _playQueueId; + int _playQueueTotalCount = 0; + bool _playQueueShuffled = false; + int? _currentPlayQueueItemID; + + // Windowed items (loaded around current position) + List _loadedItems = []; + final int _windowSize = 50; // Number of items to keep in memory + + // Legacy state for backward compatibility String? _contextKey; // The show/season/playlist ratingKey for this session - int _currentIndex = 0; PlaybackMode _playbackMode = PlaybackMode.none; + // Client reference for loading more items + PlexClient? _client; + /// Current playback mode PlaybackMode get playbackMode => _playbackMode; /// Whether shuffle mode is currently active - bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay; + bool get isShuffleActive => _playQueueShuffled; - /// Whether playlist mode is currently active - bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; + /// Whether playlist/collection mode is currently active + bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue; /// Whether any queue-based playback is active bool get isQueueActive => - _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + _playQueueId != null && _playbackMode == PlaybackMode.playQueue; /// The context key (show/season/playlist ratingKey) for the current session String? get shuffleContextKey => _contextKey; - /// Sets a new shuffle queue and starts shuffle mode - void setShuffleQueue(List episodes, String contextKey) { - _queue = List.from(episodes); + /// Current play queue ID + int? get playQueueId => _playQueueId; + + /// Total number of items in the play queue + int get queueLength => _playQueueTotalCount; + + /// Gets the current position in the queue (1-indexed) + int get currentPosition { + if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0; + final index = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + return index != -1 ? index + 1 : 0; + } + + /// Set the client reference for loading more items + void setClient(PlexClient client) { + _client = client; + } + + /// Update the current play queue item when playing a new item + void setCurrentItem(PlexMetadata metadata) { + if (_playbackMode == PlaybackMode.playQueue && metadata.playQueueItemID != null) { + _currentPlayQueueItemID = metadata.playQueueItemID; + notifyListeners(); + } + } + + /// Initialize playback from a play queue + /// Call this after creating a play queue via the API + Future setPlaybackFromPlayQueue( + PlayQueueResponse playQueue, + String? contextKey, + ) async { + _playQueueId = playQueue.playQueueID; + // Use size or items length as fallback if totalCount is null + _playQueueTotalCount = playQueue.playQueueTotalCount ?? + playQueue.size ?? + (playQueue.items?.length ?? 0); + _playQueueShuffled = playQueue.playQueueShuffled; + _currentPlayQueueItemID = playQueue.playQueueSelectedItemID; + _loadedItems = playQueue.items ?? []; _contextKey = contextKey; - _currentIndex = 0; - _playbackMode = PlaybackMode.shufflePlay; + _playbackMode = PlaybackMode.playQueue; notifyListeners(); } - /// Sets a playback queue for playlist playback (ordered, not shuffled) - void setPlaybackQueue(List items, String contextKey) { - _queue = List.from(items); + /// Legacy method for backward compatibility with shuffle play + /// This now creates a play queue on the server + @Deprecated('Use createPlayQueueFromUri instead') + void setShuffleQueue(List episodes, String contextKey) { + // This is kept for backward compatibility but should not be used + // New code should use the play queue API + _loadedItems = List.from(episodes); _contextKey = contextKey; - _currentIndex = 0; - _playbackMode = PlaybackMode.playlist; + _playbackMode = PlaybackMode.playQueue; notifyListeners(); } + /// Legacy method for backward compatibility with playlist playback + /// This now creates a play queue on the server + @Deprecated('Use createPlayQueueFromUri instead') + void setPlaybackQueue(List items, String contextKey) { + // This is kept for backward compatibility but should not be used + // New code should use the play queue API + _loadedItems = List.from(items); + _contextKey = contextKey; + _playbackMode = PlaybackMode.playQueue; + notifyListeners(); + } + + /// Load more items from the play queue if needed + /// Returns true if more items were loaded + Future _ensureItemsLoaded(int targetPlayQueueItemID) async { + if (_client == null || _playQueueId == null) return false; + + // Check if the target item is already loaded + final hasItem = _loadedItems.any( + (item) => item.playQueueItemID == targetPlayQueueItemID, + ); + + if (hasItem) return true; + + // Load a window around the target item + try { + final response = await _client!.getPlayQueue( + _playQueueId!, + center: targetPlayQueueItemID.toString(), + window: _windowSize, + ); + + if (response != null && response.items != null) { + _loadedItems = response.items!; + // Use size or items length as fallback if totalCount is null + _playQueueTotalCount = response.playQueueTotalCount ?? + response.size ?? + response.items!.length; + _playQueueShuffled = response.playQueueShuffled; + notifyListeners(); + return true; + } + } catch (e) { + // Failed to load items + return false; + } + + return false; + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted - PlexMetadata? getNextEpisode( + Future getNextEpisode( String currentItemKey, { bool loopQueue = false, - }) { - if (_queue.isEmpty) return null; + }) async { + if (_playbackMode != PlaybackMode.playQueue) { + // For sequential mode, let the video player handle next episode + return null; + } - // Find current item in queue - final currentIndex = _queue.indexWhere( - (item) => item.ratingKey == currentItemKey, + if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; + + // Find current item in loaded items + final currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, ); if (currentIndex == -1) { - // Current item not in queue, clear queue + // Current item not in loaded window, try to load it + final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); + if (!loaded) { + clearShuffle(); + return null; + } + // Try again after loading + return getNextEpisode(currentItemKey, loopQueue: loopQueue); + } + + // Check if there's a next item in the loaded window + if (currentIndex + 1 < _loadedItems.length) { + final nextItem = _loadedItems[currentIndex + 1]; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return nextItem; + } + + // Check if we're at the end of the entire queue + if (currentIndex + 1 >= _playQueueTotalCount) { + if (loopQueue && _playQueueTotalCount > 0) { + // Loop back to beginning - load first item + if (_client != null && _playQueueId != null) { + final response = await _client!.getPlayQueue(_playQueueId!); + if (response != null && response.items != null && response.items!.isNotEmpty) { + _loadedItems = response.items!; + final firstItem = _loadedItems.first; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return firstItem; + } + } + } + // Queue has ended - clear it and let sequential playback take over clearShuffle(); return null; } - // Check if there's a next item - if (currentIndex + 1 >= _queue.length) { - // Queue exhausted - if (loopQueue && _queue.isNotEmpty) { - // Loop back to beginning - _currentIndex = 0; - return _queue[_currentIndex]; + // Need to load next window + if (_client != null && _playQueueId != null) { + // Load next window centered on the item after current + final nextItemID = _loadedItems.last.playQueueItemID; + if (nextItemID != null) { + final loaded = await _ensureItemsLoaded(nextItemID + 1); + if (loaded) { + // Try again with newly loaded items + return getNextEpisode(currentItemKey, loopQueue: loopQueue); + } } - return null; } - _currentIndex = currentIndex + 1; - return _queue[_currentIndex]; + return null; } /// Gets the previous item in the playback queue. /// Returns null if at the beginning of the queue or current item is not in queue. - PlexMetadata? getPreviousEpisode(String currentItemKey) { - if (_queue.isEmpty) return null; + Future getPreviousEpisode(String currentItemKey) async { + if (_playbackMode != PlaybackMode.playQueue) { + // For sequential mode, let the video player handle previous episode + return null; + } - // Find current item in queue - final currentIndex = _queue.indexWhere( - (item) => item.ratingKey == currentItemKey, + if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; + + // Find current item in loaded items + final currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, ); if (currentIndex == -1) { - // Current item not in queue + // Current item not in loaded window return null; } - // Check if there's a previous item - if (currentIndex <= 0) { - // At the beginning of queue + // Check if there's a previous item in the loaded window + if (currentIndex > 0) { + final prevItem = _loadedItems[currentIndex - 1]; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return prevItem; + } + + // Check if we're at the beginning of the entire queue + if (currentIndex == 0) { return null; } - _currentIndex = currentIndex - 1; - return _queue[_currentIndex]; + // Need to load previous window + if (_client != null && _playQueueId != null) { + final prevItemID = _loadedItems.first.playQueueItemID; + if (prevItemID != null && prevItemID > 0) { + final loaded = await _ensureItemsLoaded(prevItemID - 1); + if (loaded) { + return getPreviousEpisode(currentItemKey); + } + } + } + + return null; } /// Clears the playback queue and exits queue mode void clearShuffle() { - _queue = []; + _playQueueId = null; + _playQueueTotalCount = 0; + _playQueueShuffled = false; + _currentPlayQueueItemID = null; + _loadedItems = []; _contextKey = null; - _currentIndex = 0; _playbackMode = PlaybackMode.none; notifyListeners(); } - - /// Gets the total number of items in the current playback queue - int get queueLength => _queue.length; - - /// Gets the current position in the queue (1-indexed) - int get currentPosition => _currentIndex + 1; } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart new file mode 100644 index 00000000..18bdc683 --- /dev/null +++ b/lib/screens/collection_detail_screen.dart @@ -0,0 +1,314 @@ +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'; + +/// Screen to display the contents of a collection +class CollectionDetailScreen extends StatefulWidget { + final PlexMetadata collection; + + const CollectionDetailScreen({super.key, required this.collection}); + + @override + State createState() => _CollectionDetailScreenState(); +} + +class _CollectionDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadCollectionItems(); + } + + Future _loadCollectionItems() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final items = await client.getCollectionItems(widget.collection.ratingKey); + + setState(() { + _items = items; + _isLoading = false; + }); + + appLogger.d( + 'Loaded ${items.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)), + ); + } + 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 { + // Get library section ID from the collection or its items + 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) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.unknownLibrarySection)), + ); + } + return; + } + + // 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), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final success = await client.deleteCollection( + sectionId.toString(), + widget.collection.ratingKey, + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.deleted)), + ); + Navigator.pop(context, true); // Return true to indicate refresh needed + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.deleteFailed)), + ); + } + } + } catch (e) { + appLogger.e('Failed to delete collection', error: e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.collections.deleteFailedWithError(error: e.toString()), + ), + ), + ); + } + } + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(widget.collection.title), + pinned: true, + actions: [ + // Play button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.play_arrow), + tooltip: t.discover.play, + onPressed: _playCollection, + ), + // Shuffle button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.common.shuffle, + onPressed: _shufflePlayCollection, + ), + // Delete button + IconButton( + icon: const Icon(Icons.delete), + tooltip: t.common.delete, + onPressed: _deleteCollection, + color: Colors.red, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: 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: _loadCollectionItems, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_items.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Text(t.collections.noItems), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + sliver: Consumer( + builder: (context, settingsProvider, child) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: _loadCollectionItems, + ); + }, + childCount: _items.length, + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 051e1d79..f794d695 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -12,9 +12,9 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/user_avatar_widget.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; +import '../widgets/hub_section.dart'; import 'profile_switch_screen.dart'; import 'server_selection_screen.dart'; -import 'hub_detail_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; @@ -468,10 +468,7 @@ class _DiscoverScreenState extends State context, listen: false, ); - final plexClientProvider = Provider.of( - context, - listen: false, - ); + final plexClientProvider = context.plexClient; // Clear all user data and provider states await userProfileProvider.logout(); @@ -633,43 +630,14 @@ class _DiscoverScreenState extends State ], // Recommendation Hubs (Trending, Top in Genre, etc.) - for (final hub in _hubs) ...[ + for (final hub in _hubs) SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => HubDetailScreen(hub: hub), - ), - ); - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - children: [ - Icon(_getHubIcon(hub.title)), - const SizedBox(width: 8), - Text( - hub.title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 20), - ], - ), - ), - ), + child: HubSection( + hub: hub, + icon: _getHubIcon(hub.title), + onRefresh: updateItem, ), ), - _buildHorizontalList(hub.items, isLarge: false), - ], if (_onDeck.isEmpty && _hubs.isEmpty) SliverFillRemaining( diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index ff02bde8..532bed37 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -15,13 +15,17 @@ 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 '../services/settings_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; -import 'playlists_screen.dart'; +import 'library_tabs/library_browse_tab.dart'; +import 'library_tabs/library_recommended_tab.dart'; +import 'library_tabs/library_collections_tab.dart'; +import 'library_tabs/library_playlists_tab.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -31,24 +35,33 @@ class LibrariesScreen extends StatefulWidget { } class _LibrariesScreenState extends State - with Refreshable, ItemUpdatable { + with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { @override PlexClient get client => context.clientSafe; + late TabController _tabController; + + // GlobalKeys for tabs to enable refresh + final _recommendedTabKey = GlobalKey>(); + final _browseTabKey = GlobalKey>(); + final _collectionsTabKey = GlobalKey>(); + final _playlistsTabKey = GlobalKey>(); + List _allLibraries = []; // All libraries from API (unfiltered) - List _items = []; - List _filters = []; - List _sortOptions = []; bool _isLoadingLibraries = true; - bool _isLoadingItems = false; String? _errorMessage; 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 _isInitialLoad = true; - - // Pagination state + bool _isLoadingItems = false; + List _items = []; int _currentPage = 0; bool _hasMoreItems = true; CancelToken? _cancelToken; @@ -58,9 +71,30 @@ class _LibrariesScreenState extends State @override void initState() { super.initState(); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(_onTabChanged); _loadLibraries(); } + void _onTabChanged() { + // Save tab index when changed + if (_selectedLibraryKey != null && !_tabController.indexIsChanging) { + StorageService.getInstance().then((storage) { + storage.saveLibraryTab(_selectedLibraryKey!, _tabController.index); + }); + } + // Rebuild to update chip selection state + setState(() {}); + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + _cancelToken?.cancel(); + super.dispose(); + } + /// Helper method to get user-friendly error message from exception String _getErrorMessage(dynamic error, String context) { if (error is DioException) { @@ -87,10 +121,7 @@ class _LibrariesScreenState extends State Future _loadLibraries() async { // Extract context dependencies before async gap - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final hiddenLibrariesProvider = Provider.of( context, listen: false, @@ -138,7 +169,6 @@ class _LibrariesScreenState extends State // Load saved preferences final savedLibraryKey = storage.getSelectedLibraryKey(); - final savedFilters = storage.getLibraryFilters(); // Find the library by key in visible libraries String? libraryKeyToLoad; @@ -157,12 +187,12 @@ class _LibrariesScreenState extends State libraryKeyToLoad = visibleLibraries.first.key; } - // Restore filters BEFORE loading content - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } - if (libraryKeyToLoad != null) { + final savedFilters = + storage.getLibraryFilters(sectionId: libraryKeyToLoad); + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } _loadLibraryContent(libraryKeyToLoad); } } @@ -259,13 +289,24 @@ class _LibrariesScreenState extends State _isInitialLoad = false; } - // Save selected library key + // Save selected library key and restore saved tab final storage = await StorageService.getInstance(); await storage.saveSelectedLibraryKey(libraryKey); + // Restore saved tab index for this library + final savedTabIndex = storage.getLibraryTab(libraryKey); + if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { + setState(() { + _tabController.index = savedTabIndex; + }); + } + // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters({}); + await storage.saveLibraryFilters( + {}, + sectionId: libraryKey, + ); } // Cancel any existing requests @@ -363,10 +404,7 @@ class _LibrariesScreenState extends State Future _loadFilters(String libraryKey) async { try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -386,10 +424,7 @@ class _LibrariesScreenState extends State Future _loadSortOptions(String libraryKey) async { try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -399,24 +434,25 @@ class _LibrariesScreenState extends State // Load saved sort preference for this library final storage = await StorageService.getInstance(); - final savedSortKey = storage.getLibrarySort(libraryKey); + final savedSortData = storage.getLibrarySort(libraryKey); // Find the saved sort in the options PlexSort? savedSort; bool descending = false; - if (savedSortKey.endsWith(':desc')) { - descending = true; - final baseKey = savedSortKey.replaceAll(':desc', ''); - savedSort = sortOptions.firstWhere( - (s) => s.key == baseKey, - orElse: () => sortOptions.first, - ); + if (savedSortData != null) { + final sortKey = savedSortData['key'] as String?; + if (sortKey != null) { + savedSort = sortOptions.firstWhere( + (s) => s.key == sortKey, + orElse: () => sortOptions.first, + ); + descending = (savedSortData['descending'] as bool?) ?? false; + } else { + savedSort = sortOptions.first; + } } else { - savedSort = sortOptions.firstWhere( - (s) => s.key == savedSortKey, - orElse: () => sortOptions.first, - ); + savedSort = sortOptions.first; } setState(() { @@ -448,10 +484,7 @@ class _LibrariesScreenState extends State }); try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -493,8 +526,11 @@ class _LibrariesScreenState extends State // Save sort preference for this library final storage = await StorageService.getInstance(); - final sortKey = sort.getSortKey(descending: descending); - await storage.saveLibrarySort(_selectedLibraryKey!, sortKey); + await storage.saveLibrarySort( + _selectedLibraryKey!, + sort.key, + descending: descending, + ); // Reload content with new sort _applyFilters(); @@ -514,6 +550,36 @@ class _LibrariesScreenState extends State _loadLibraries(); } + // Refresh the currently active tab + void _refreshCurrentTab() { + switch (_tabController.index) { + case 0: // Recommended tab + final state = _recommendedTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 1: // Browse tab + final state = _browseTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 2: // Collections tab + final state = _collectionsTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 3: // Playlists tab + final state = _playlistsTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + } + } + // Public method to fully reload all content (for profile switches) void fullRefresh() { appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); @@ -558,45 +624,6 @@ class _LibrariesScreenState extends State } } - void _showFiltersBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => _FiltersBottomSheet( - filters: _filters, - selectedFilters: _selectedFilters, - onFiltersChanged: (filters) async { - setState(() { - _selectedFilters.clear(); - _selectedFilters.addAll(filters); - }); - - // Save filters to storage - final storage = await StorageService.getInstance(); - await storage.saveLibraryFilters(filters); - - _applyFilters(); - }, - ), - ); - } - - void _showSortBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => _SortBottomSheet( - sortOptions: _sortOptions, - selectedSort: _selectedSort, - isSortDescending: _isSortDescending, - onSortChanged: (sort, descending) { - Navigator.pop(context); - _applySort(sort, descending); - }, - ), - ); - } - List _getLibraryMenuItems(PlexLibrary library) { return [ ContextMenuItem( @@ -858,6 +885,88 @@ class _LibrariesScreenState extends State } } + Widget _buildTabChip(String label, int index) { + final isSelected = _tabController.index == index; + final t = tokens(context); + + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _tabController.index = index; + }); + } + }, + backgroundColor: t.surface, + selectedColor: t.text, + side: BorderSide(color: t.outline), + labelStyle: TextStyle( + color: isSelected ? t.bg : t.text, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + showCheckmark: false, + ); + } + + Widget _buildLibraryDropdownTitle(List visibleLibraries) { + final selectedLibrary = visibleLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + orElse: () => visibleLibraries.first, + ); + + return PopupMenuButton( + offset: const Offset(0, 48), + tooltip: t.libraries.selectLibrary, + onSelected: (libraryKey) { + _loadLibraryContent(libraryKey); + }, + itemBuilder: (context) { + return visibleLibraries.map((library) { + final isSelected = library.key == _selectedLibraryKey; + return PopupMenuItem( + value: library.key, + child: Row( + children: [ + Icon( + _getLibraryIcon(library.type), + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + library.title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ], + ), + ); + }).toList(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getLibraryIcon(selectedLibrary.type), size: 20), + const SizedBox(width: 8), + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 24), + ], + ), + ); + } + @override Widget build(BuildContext context) { // Watch for hidden libraries changes to trigger rebuild @@ -873,7 +982,9 @@ class _LibrariesScreenState extends State body: CustomScrollView( slivers: [ DesktopSliverAppBar( - title: Text(t.libraries.title), + title: visibleLibraries.isNotEmpty && _selectedLibraryKey != null + ? _buildLibraryDropdownTitle(visibleLibraries) + : Text(t.libraries.title), floating: true, pinned: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -889,26 +1000,9 @@ class _LibrariesScreenState extends State ), onPressed: _showLibraryManagementSheet, ), - if (_sortOptions.isNotEmpty) - IconButton( - icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), - onPressed: _showSortBottomSheet, - ), - if (_filters.isNotEmpty) - IconButton( - icon: Badge( - label: Text('${_selectedFilters.length}'), - isLabelVisible: _selectedFilters.isNotEmpty, - child: Icon( - Icons.filter_list, - semanticLabel: t.libraries.filters, - ), - ), - onPressed: _showFiltersBottomSheet, - ), IconButton( icon: Icon(Icons.refresh, semanticLabel: t.common.refresh), - onPressed: () => _loadLibraryContent(_selectedLibraryKey!), + onPressed: _refreshCurrentTab, ), ], ), @@ -956,186 +1050,79 @@ class _LibrariesScreenState extends State ), ) else ...[ - // Library selector chips - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(visibleLibraries.length, (index) { - final library = visibleLibraries[index]; - final isSelected = library.key == _selectedLibraryKey; - final t = tokens(context); - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ContextMenuWrapper( - menuItems: _getLibraryMenuItems(library), - onMenuItemSelected: (value) => - _handleLibraryMenuAction(value, library), - onTap: () => _loadLibraryContent(library.key), - child: ChoiceChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getLibraryIcon(library.type), - size: 16, - color: isSelected ? t.bg : t.text, - ), - const SizedBox(width: 6), - Text(library.title), - ], - ), - selected: isSelected, - onSelected: (selected) { - if (selected) { - _loadLibraryContent(library.key); - } - }, - backgroundColor: t.surface, - selectedColor: t.text, - side: BorderSide(color: t.outline), - labelStyle: TextStyle( - color: isSelected ? t.bg : t.text, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, - ), - showCheckmark: false, - ), - ), - ); - }), + // Tab selector chips + if (_selectedLibraryKey != null) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - ), - ), - ), - - // Content grid - if (_isLoadingItems && _items.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_errorMessage != null) - SliverFillRemaining( - child: 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: () => - _loadLibraryContent(_selectedLibraryKey!), - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (_items.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.folder_open, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text(t.libraries.thisLibraryIsEmpty), - ], - ), - ), - ) - else ...[ - Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _items.length), - ), - ); - } else { - return SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _items.length), - ), - ); - } - }, - ), - // Show loading indicator if there are more items to load - if (_hasMoreItems && _isLoadingItems) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( children: [ - const CircularProgressIndicator(), - const SizedBox(height: 8), - Text( - t.libraries.loadingLibraryWithCount( - count: _items.length, - ), - style: Theme.of(context).textTheme.bodySmall, + _buildTabChip( + t.libraries.tabs.recommended, + 0, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.browse, + 1, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.collections, + 2, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.playlists, + 3, ), ], ), ), ), - ], + ), + + // Tab content + if (_selectedLibraryKey != null) + SliverFillRemaining( + child: TabBarView( + controller: _tabController, + children: [ + LibraryRecommendedTab( + key: _recommendedTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryBrowseTab( + key: _browseTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryCollectionsTab( + key: _collectionsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryPlaylistsTab( + key: _playlistsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + ], + ), + ), ], ], ), - floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const PlaylistsScreen()), - ); - }, - tooltip: t.playlists.title, - child: const Icon(Icons.playlist_play), - ), ); } @@ -1153,513 +1140,6 @@ class _LibrariesScreenState extends State return Icons.folder; } } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; // 8px left + 8px right - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - 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(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } -} - -class _FiltersBottomSheet extends StatefulWidget { - final List filters; - final Map selectedFilters; - final Function(Map) onFiltersChanged; - - const _FiltersBottomSheet({ - required this.filters, - required this.selectedFilters, - required this.onFiltersChanged, - }); - - @override - State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState(); -} - -class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { - PlexFilter? _currentFilter; - List _filterValues = []; - bool _isLoadingValues = false; - final Map _tempSelectedFilters = {}; - final Map _filterDisplayNames = {}; // Cache for display names - late List _sortedFilters; - - @override - void initState() { - super.initState(); - _tempSelectedFilters.addAll(widget.selectedFilters); - _sortFilters(); - } - - void _sortFilters() { - // Separate boolean filters (toggles) from regular filters - final booleanFilters = widget.filters - .where((f) => f.filterType == 'boolean') - .toList(); - final regularFilters = widget.filters - .where((f) => f.filterType != 'boolean') - .toList(); - - // Combine with boolean filters first - _sortedFilters = [...booleanFilters, ...regularFilters]; - } - - bool _isBooleanFilter(PlexFilter filter) { - return filter.filterType == 'boolean'; - } - - Future _loadFilterValues(PlexFilter filter) async { - setState(() { - _currentFilter = filter; - _isLoadingValues = true; - }); - - try { - final clientProvider = Provider.of( - context, - listen: false, - ); - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - final values = await client.getFilterValues(filter.key); - setState(() { - _filterValues = values; - _isLoadingValues = false; - }); - } catch (e) { - setState(() { - _filterValues = []; - _isLoadingValues = false; - }); - } - } - - void _goBack() { - setState(() { - _currentFilter = null; - _filterValues = []; - }); - } - - void _applyFilters() { - widget.onFiltersChanged(_tempSelectedFilters); - Navigator.pop(context); - } - - String _extractFilterValue(String key, String filterName) { - if (key.contains('?')) { - final queryStart = key.indexOf('?'); - final queryString = key.substring(queryStart + 1); - final params = Uri.splitQueryString(queryString); - return params[filterName] ?? key; - } else if (key.startsWith('/')) { - return key.split('/').last; - } - return key; - } - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - if (_currentFilter != null) { - // Show filter options view - return Column( - children: [ - // Header with back button - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: _goBack, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _currentFilter!.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // Filter options list - if (_isLoadingValues) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey( - _currentFilter!.filter, - ); - return ListTile( - title: Text(t.libraries.all), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters.remove( - _currentFilter!.filter, - ); - }); - _applyFilters(); - }, - ); - } - - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue( - value.key, - _currentFilter!.filter, - ); - final isSelected = - _tempSelectedFilters[_currentFilter!.filter] == - filterValue; - - return ListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters[_currentFilter!.filter] = - filterValue; - // Cache the display name for this filter value - _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = - value.title; - }); - _applyFilters(); - }, - ); - }, - ), - ), - ], - ); - } - - // Show main filters view - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - const Icon(Icons.filter_list), - const SizedBox(width: 12), - Text( - t.libraries.filters, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - if (_tempSelectedFilters.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const Icon(Icons.clear_all), - label: Text(t.libraries.clearAll), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // All Filters (boolean toggles first, then regular filters) - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && - _tempSelectedFilters[filter.filter] == '1'; - return SwitchListTile( - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = - _filterDisplayNames['${filter.filter}:$selectedValue'] ?? - selectedValue; - } - - return ListTile( - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => _loadFilterValues(filter), - ); - }, - ), - ), - ], - ); - }, - ); - } -} - -class _SortBottomSheet extends StatefulWidget { - final List sortOptions; - final PlexSort? selectedSort; - final bool isSortDescending; - final Function(PlexSort, bool) onSortChanged; - - const _SortBottomSheet({ - required this.sortOptions, - required this.selectedSort, - required this.isSortDescending, - required this.onSortChanged, - }); - - @override - State<_SortBottomSheet> createState() => _SortBottomSheetState(); -} - -class _SortBottomSheetState extends State<_SortBottomSheet> { - late PlexSort? _tempSelectedSort; - late bool _tempDescending; - - @override - void initState() { - super.initState(); - _tempSelectedSort = widget.selectedSort; - _tempDescending = widget.isSortDescending; - } - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - t.libraries.sortBy, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // Sort options list - Expanded( - child: RadioGroup( - groupValue: _tempSelectedSort?.key, - onChanged: (value) { - final sort = widget.sortOptions.firstWhere( - (s) => s.key == value, - ); - setState(() { - _tempSelectedSort = sort; - // Use default direction for newly selected sort - _tempDescending = sort.isDefaultDescending; - }); - // Apply sort immediately with default direction - widget.onSortChanged(sort, sort.isDefaultDescending); - }, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _tempSelectedSort?.key == sort.key; - - return ListTile( - title: Text(sort.title), - trailing: isSelected - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Direction toggle buttons - SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: false, - icon: Icon(Icons.arrow_upward, size: 16), - ), - ButtonSegment( - value: true, - icon: Icon( - Icons.arrow_downward, - size: 16, - ), - ), - ], - selected: {_tempDescending}, - onSelectionChanged: (Set selected) { - widget.onSortChanged(sort, selected.first); - }, - ), - ], - ) - : null, - leading: Radio( - value: sort.key, - toggleable: false, - ), - onTap: () { - setState(() { - _tempSelectedSort = sort; - // Use default direction for newly selected sort - _tempDescending = sort.isDefaultDescending; - }); - // Apply sort immediately with default direction - widget.onSortChanged(sort, sort.isDefaultDescending); - }, - ); - }, - ), - ), - ), - ], - ); - }, - ); - } } class _LibraryManagementSheet extends StatefulWidget { diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart new file mode 100644 index 00000000..b2234110 --- /dev/null +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -0,0 +1,602 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +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 '../../utils/provider_extensions.dart'; +import '../../utils/app_logger.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 '../../mixins/item_updatable.dart'; +import '../../mixins/refreshable.dart'; +import '../../i18n/strings.g.dart'; + +/// Browse tab for library screen +/// Shows library items with grouping, filtering, and sorting +class LibraryBrowseTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryBrowseTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryBrowseTabState(); +} + +class _LibraryBrowseTabState extends State + with AutomaticKeepAliveClientMixin, ItemUpdatable, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + PlexClient get client => context.clientSafe; + + @override + void refresh() { + _loadContent(); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + setState(() { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + }); + } + + List _items = []; + List _filters = []; + List _sortOptions = []; + bool _isLoading = false; + String? _errorMessage; + Map _selectedFilters = {}; + PlexSort? _selectedSort; + bool _isSortDescending = false; + String _selectedGrouping = 'all'; // all, seasons, episodes, folders + + // Pagination state + int _currentPage = 0; + bool _hasMoreItems = true; + CancelToken? _cancelToken; + int _requestId = 0; + static const int _pageSize = 500; + + @override + void initState() { + super.initState(); + _loadContent(); + } + + @override + void didUpdateWidget(LibraryBrowseTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadContent(); + } + } + + @override + void dispose() { + _cancelToken?.cancel(); + super.dispose(); + } + + Future _loadContent() async { + // Cancel any pending request + _cancelToken?.cancel(); + _cancelToken = CancelToken(); + final currentRequestId = ++_requestId; + + // Extract context dependencies before async gap + final clientProvider = context.plexClient; + + setState(() { + _isLoading = true; + _errorMessage = null; + _items = []; + _currentPage = 0; + _hasMoreItems = true; + }); + + try { + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final storage = await StorageService.getInstance(); + + // Load filters and sorts for this library + final filters = await client.getLibraryFilters(widget.library.key); + final sorts = await client.getLibrarySorts(widget.library.key); + + // Load saved preferences + final savedFilters = storage.getLibraryFilters( + sectionId: widget.library.key, + ); + final savedSort = storage.getLibrarySort(widget.library.key); + final savedGrouping = storage.getLibraryGrouping(widget.library.key); + + // Check if request was cancelled + if (currentRequestId != _requestId) return; + + setState(() { + _filters = filters; + _sortOptions = sorts; + _selectedFilters = Map.from(savedFilters); + _selectedGrouping = savedGrouping ?? _getDefaultGrouping(); + + // Restore sort + if (savedSort != null) { + final sortKey = savedSort['key'] as String?; + if (sortKey != null) { + final sort = sorts.where((s) => s.key == sortKey).firstOrNull; + if (sort != null) { + _selectedSort = sort; + _isSortDescending = (savedSort['descending'] as bool?) ?? false; + } + } + } + }); + + // Load items + await _loadItems(); + } catch (e) { + if (currentRequestId != _requestId) return; + + setState(() { + _errorMessage = _getErrorMessage(e); + _isLoading = false; + }); + } + } + + Future _loadItems({bool loadMore = false}) async { + if (loadMore && _isLoading) return; + + if (!loadMore) { + _currentPage = 0; + _hasMoreItems = true; + } + + if (!_hasMoreItems) return; + + final currentRequestId = _requestId; + _cancelToken?.cancel(); + _cancelToken = CancelToken(); + + setState(() { + _isLoading = true; + if (!loadMore) { + _items = []; + } + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + // Build filter params + final filterParams = Map.from(_selectedFilters); + + // Add grouping type filter (but not for 'all' or 'folders') + if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') { + final typeId = _getGroupingTypeId(); + if (typeId.isNotEmpty) { + filterParams['type'] = typeId; + } + } + + // Add sort + if (_selectedSort != null) { + filterParams['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, + ); + } + + final items = await client.getLibraryContent( + widget.library.key, + start: _currentPage * _pageSize, + size: _pageSize, + filters: filterParams, + cancelToken: _cancelToken, + ); + + if (currentRequestId != _requestId) return; + + setState(() { + if (loadMore) { + _items.addAll(items); + } else { + _items = items; + } + _hasMoreItems = items.length >= _pageSize; + _currentPage++; + _isLoading = false; + }); + } catch (e) { + if (currentRequestId != _requestId) return; + + setState(() { + _errorMessage = _getErrorMessage(e); + _isLoading = false; + }); + } + } + + String _getDefaultGrouping() { + final type = widget.library.type.toLowerCase(); + if (type == 'show') { + return 'shows'; + } else if (type == 'movie') { + return 'movies'; + } + return 'all'; + } + + String _getGroupingTypeId() { + switch (_selectedGrouping) { + case 'movies': + return '1'; + case 'shows': + return '2'; + case 'seasons': + return '3'; + case 'episodes': + return '4'; + default: + return ''; + } + } + + List _getGroupingOptions() { + final type = widget.library.type.toLowerCase(); + if (type == 'show') { + return ['shows', 'seasons', 'episodes', 'folders']; + } else if (type == 'movie') { + return ['movies', 'folders']; + } + // All library types support folder browsing + return ['all', 'folders']; + } + + String _getGroupingLabel(String grouping) { + switch (grouping) { + case 'movies': + return t.libraries.groupings.movies; + case 'shows': + return t.libraries.groupings.shows; + case 'seasons': + return t.libraries.groupings.seasons; + case 'episodes': + return t.libraries.groupings.episodes; + case 'folders': + return t.libraries.groupings.folders; + default: + return t.libraries.groupings.all; + } + } + + 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, + ); + } + } + appLogger.e('Unexpected error loading library content', error: error); + return t.errors.failedToLoad( + context: t.libraries.content, + error: error.toString(), + ); + } + + void _showGroupingBottomSheet() { + showModalBottomSheet( + context: context, + builder: (context) { + return ListView( + shrinkWrap: true, + children: _getGroupingOptions().map((grouping) { + return RadioListTile( + title: Text(_getGroupingLabel(grouping)), + value: grouping, + groupValue: _selectedGrouping, + onChanged: (value) async { + if (value != null) { + setState(() { + _selectedGrouping = value; + }); + + final storage = await StorageService.getInstance(); + await storage.saveLibraryGrouping(widget.library.key, value); + + Navigator.pop(context); + _loadItems(); + } + }, + ); + }).toList(), + ); + }, + ); + } + + void _showFiltersBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => FiltersBottomSheet( + filters: _filters, + selectedFilters: _selectedFilters, + onFiltersChanged: (filters) async { + setState(() { + _selectedFilters.clear(); + _selectedFilters.addAll(filters); + }); + + // Save filters to storage + final storage = await StorageService.getInstance(); + await storage.saveLibraryFilters( + filters, + sectionId: widget.library.key, + ); + + _loadItems(); + }, + ), + ); + } + + void _showSortBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onSortChanged: (sort, descending) { + setState(() { + _selectedSort = sort; + _isSortDescending = descending; + }); + + StorageService.getInstance().then((storage) { + storage.saveLibrarySort( + widget.library.key, + sort.key, + descending: descending, + ); + }); + + _loadItems(); + }, + ), + ); + } + + Widget _buildFilterChip({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + return Column( + children: [ + // Filter bar with chips + Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Grouping chip + _buildFilterChip( + icon: Icons.category, + label: _getGroupingLabel(_selectedGrouping), + onPressed: _showGroupingBottomSheet, + ), + const SizedBox(width: 8), + // Filters chip + if (_filters.isNotEmpty) + _buildFilterChip( + icon: Icons.filter_alt, + label: _selectedFilters.isEmpty + ? t.libraries.filters + : t.libraries.filtersWithCount( + count: _selectedFilters.length, + ), + onPressed: _showFiltersBottomSheet, + ), + if (_filters.isNotEmpty) const SizedBox(width: 8), + // Sort chip + if (_sortOptions.isNotEmpty) + _buildFilterChip( + icon: Icons.sort, + label: _selectedSort?.title ?? t.libraries.sort, + onPressed: _showSortBottomSheet, + ), + ], + ), + ), + ), + + // Content + Expanded(child: _buildContent()), + ], + ); + } + + Widget _buildContent() { + // Show folder tree view when in folders mode + if (_selectedGrouping == 'folders') { + return FolderTreeView( + libraryKey: widget.library.key, + onRefresh: updateItem, + ); + } + + if (_isLoading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + 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!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadContent, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.thisLibraryIsEmpty), + ], + ), + ); + } + + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 300 && + _hasMoreItems && + !_isLoading) { + _loadItems(loadMore: true); + } + return false; + }, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Center(child: CircularProgressIndicator()); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart new file mode 100644 index 00000000..37207f84 --- /dev/null +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +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'; + +/// Collections tab for library screen +/// Shows collections for the current library +class LibraryCollectionsTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryCollectionsTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryCollectionsTabState(); +} + +class _LibraryCollectionsTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadCollections(); + } + + List _collections = []; + bool _isLoading = false; + String? _errorMessage; + StreamSubscription? _refreshSubscription; + + @override + void initState() { + super.initState(); + _loadCollections(); + + // Listen for refresh notifications + _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((_) { + if (mounted) { + _loadCollections(); + } + }); + } + + @override + void dispose() { + _refreshSubscription?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(LibraryCollectionsTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadCollections(); + } + } + + Future _loadCollections() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final collections = await client.getLibraryCollections(widget.library.key); + + if (!mounted) return; + + setState(() { + _collections = collections; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading collections', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.collections.title, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + + @override + 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), + ), + ], + ), + ); + } + + 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 new file mode 100644 index 00000000..f2d0e2b4 --- /dev/null +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_playlist.dart'; +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 '../../utils/grid_size_calculator.dart'; +import '../../widgets/media_card.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; + +/// Playlists tab for library screen +/// Shows playlists that contain items from the current library +class LibraryPlaylistsTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryPlaylistsTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryPlaylistsTabState(); +} + +class _LibraryPlaylistsTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadPlaylists(); + } + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + StreamSubscription? _refreshSubscription; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + + // Listen for refresh notifications + _refreshSubscription = LibraryRefreshNotifier().playlistsStream.listen((_) { + if (mounted) { + _loadPlaylists(); + } + }); + } + + @override + void dispose() { + _refreshSubscription?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(LibraryPlaylistsTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadPlaylists(); + } + } + + Future _loadPlaylists() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + // Get playlists for this library + final playlists = await client.getLibraryPlaylists( + sectionId: widget.library.key, + playlistType: 'video', + ); + + if (!mounted) return; + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading playlists', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.playlists.title, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + + @override + 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, + ), + 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, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart new file mode 100644 index 00000000..53b82423 --- /dev/null +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_hub.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../utils/app_logger.dart'; +import '../../widgets/hub_section.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; + +/// Recommended tab for library screen +/// Shows library-specific hubs and recommendations +class LibraryRecommendedTab extends StatefulWidget { + final PlexLibrary library; + + const LibraryRecommendedTab({ + super.key, + required this.library, + }); + + @override + State createState() => _LibraryRecommendedTabState(); +} + +class _LibraryRecommendedTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadHubs(); + } + + List _hubs = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadHubs(); + } + + @override + void didUpdateWidget(LibraryRecommendedTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadHubs(); + } + } + + Future _loadHubs() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final hubs = await client.getLibraryHubs(widget.library.key, limit: 12); + + if (!mounted) return; + + setState(() { + _hubs = hubs; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading library hubs', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.libraries.tabs.recommended, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + IconData _getHubIcon(PlexHub hub) { + final title = hub.title.toLowerCase(); + if (title.contains('continue watching') || title.contains('on deck')) { + return Icons.play_circle; + } else if (title.contains('recently') || title.contains('new')) { + return Icons.fiber_new; + } else if (title.contains('popular') || title.contains('trending')) { + return Icons.trending_up; + } else if (title.contains('top') || title.contains('rated')) { + return Icons.star; + } else if (title.contains('recommended')) { + return Icons.thumb_up; + } else if (title.contains('unwatched')) { + return Icons.visibility_off; + } else if (title.contains('genre')) { + return Icons.category; + } + return Icons.movie; + } + + @override + 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), + ), + ], + ), + ); + } + + 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/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0b1afff6..7133550b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -953,7 +953,7 @@ class _MediaDetailScreenState extends State { return Card( clipBehavior: Clip.antiAlias, child: MediaContextMenu( - metadata: season, + item: season, onRefresh: (ratingKey) { _watchStateChanged = true; _updateWatchState(); diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index bcd25c25..e75851a2 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -8,6 +8,7 @@ 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/video_player_navigation.dart'; import '../widgets/media_card.dart'; import '../widgets/playlist_item_card.dart'; @@ -51,7 +52,7 @@ class _PlaylistDetailScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final items = await client.getPlaylist(widget.playlist.ratingKey); @@ -257,15 +258,16 @@ class _PlaylistDetailScreenState extends State return; } - final playbackState = context.read(); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Set the playlist items as the playback queue (in order, not shuffled) - playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); - - // Navigate to the first item - if (mounted) { - await navigateToVideoPlayer(context, metadata: _items.first); - } + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.playlist, + shuffle: false, + ); } Future _shufflePlayPlaylist() async { @@ -278,31 +280,70 @@ class _PlaylistDetailScreenState extends State return; } - final playbackState = context.read(); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Shuffle the items - final shuffledItems = List.from(_items)..shuffle(); - - // Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode) - playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey); - - // Navigate to the first shuffled item - if (mounted) { - await navigateToVideoPlayer(context, metadata: shuffledItems.first); - } + 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; - final playbackState = context.read(); + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Set the full playlist as playback queue (in order) - playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + final selectedItem = _items[index]; - // Start playing from the clicked item - if (mounted) { - await navigateToVideoPlayer(context, metadata: _items[index]); + // Create play queue from playlist, starting at the selected item + final playQueue = await client.createPlayQueue( + playlistID: int.parse(widget.playlist.ratingKey), + type: 'video', + key: selectedItem.key, + ); + + if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.failedToCreatePlayQueue)), + ); + } + return; + } + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + playQueue, + widget.playlist.ratingKey, + ); + + // Navigate to selected item (should be first in the queue response) + if (mounted) { + await navigateToVideoPlayer(context, metadata: playQueue.items!.first); + } + } catch (e) { + appLogger.e('Failed to play from item', error: e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.failedPlayback( + action: t.discover.play, + error: e.toString(), + ), + ), + ), + ); + } } } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 137816d0..dd2ec503 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -132,7 +132,7 @@ class _SeasonDetailScreenState extends State : 0.0; return MediaContextMenu( - metadata: episode, + item: episode, onRefresh: updateItem, onTap: () async { await navigateToVideoPlayer(context, metadata: episode); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 53fe04ad..6d04870f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -92,6 +92,21 @@ class VideoPlayerScreenState extends State appLogger.d('Preferred subtitle track: $subtitleDesc'); } + // Update current item in playback state provider + try { + final playbackState = context.read(); + + // If this item doesn't have a playQueueItemID, it's a standalone item + // Clear any existing queue so next/previous work correctly for this content + if (widget.metadata.playQueueItemID == null) { + playbackState.clearShuffle(); + } else { + playbackState.setCurrentItem(widget.metadata); + } + } catch (e) { + // Provider might not be available yet + } + // Register app lifecycle observer WidgetsBinding.instance.addObserver(this); @@ -301,11 +316,11 @@ class VideoPlayerScreenState extends State if (playbackState.isPlaylistActive) { // For playlists, always use the queue regardless of item type // Playlists can contain both movies and episodes - next = playbackState.getNextEpisode( + next = await playbackState.getNextEpisode( widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); + previous = await playbackState.getPreviousEpisode(widget.metadata.ratingKey); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { @@ -320,11 +335,11 @@ class VideoPlayerScreenState extends State if (shuffleOrderNavigation) { // Use shuffled order for next/previous - next = playbackState.getNextEpisode( + next = await playbackState.getNextEpisode( widget.metadata.ratingKey, loopQueue: loopQueue, ); - previous = playbackState.getPreviousEpisode( + previous = await playbackState.getPreviousEpisode( widget.metadata.ratingKey, ); } else { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 4c16d670..d967425e 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -168,13 +168,25 @@ class StorageService { } // Library Filters (stored as JSON string) - Future saveLibraryFilters(Map filters) async { + Future saveLibraryFilters( + Map filters, { + String? sectionId, + }) async { final jsonString = json.encode(filters); - await _prefs.setString(_keyLibraryFilters, jsonString); + final key = sectionId != null + ? 'library_filters_$sectionId' + : _keyLibraryFilters; + await _prefs.setString(key, jsonString); } - Map getLibraryFilters() { - final jsonString = _prefs.getString(_keyLibraryFilters); + Map getLibraryFilters({String? sectionId}) { + final scopedKey = + sectionId != null ? 'library_filters_$sectionId' : _keyLibraryFilters; + + // Prefer per-library filters when available + final jsonString = _prefs.getString(scopedKey) ?? + // Legacy support: fall back to global filters if present + _prefs.getString(_keyLibraryFilters); if (jsonString == null) return {}; try { @@ -185,14 +197,44 @@ class StorageService { } } - // Library Sort (per-library, stored individually) - Future saveLibrarySort(String sectionId, String sortKey) async { - await _prefs.setString('library_sort_$sectionId', sortKey); + // Library Sort (per-library, stored individually with descending flag) + Future saveLibrarySort( + String sectionId, + String sortKey, { + bool descending = false, + }) async { + final sortData = {'key': sortKey, 'descending': descending}; + await _prefs.setString('library_sort_$sectionId', json.encode(sortData)); } - String getLibrarySort(String sectionId) { - // Return saved sort or default to titleSort (alphabetical) - return _prefs.getString('library_sort_$sectionId') ?? 'titleSort'; + Map? getLibrarySort(String sectionId) { + final jsonString = _prefs.getString('library_sort_$sectionId'); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + // Legacy support: if it's just a string, return it as the key + return {'key': jsonString, 'descending': false}; + } + } + + // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') + Future saveLibraryGrouping(String sectionId, String grouping) async { + await _prefs.setString('library_grouping_$sectionId', grouping); + } + + String? getLibraryGrouping(String sectionId) { + return _prefs.getString('library_grouping_$sectionId'); + } + + // Library Tab (per-library, saves last selected tab index) + Future saveLibraryTab(String sectionId, int tabIndex) async { + await _prefs.setInt('library_tab_$sectionId', tabIndex); + } + + int? getLibraryTab(String sectionId) { + return _prefs.getInt('library_tab_$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart new file mode 100644 index 00000000..701afa37 --- /dev/null +++ b/lib/utils/collection_playlist_play_helper.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; +import '../models/play_queue_response.dart'; +import '../providers/playback_state_provider.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../i18n/strings.g.dart'; + +/// Helper function to play a collection or playlist +Future playCollectionOrPlaylist({ + required BuildContext context, + required PlexClient client, + required dynamic item, // PlexMetadata (collection) or PlexPlaylist + required bool shuffle, +}) async { + try { + final isCollection = item is PlexMetadata; + final isPlaylist = item is PlexPlaylist; + + if (!isCollection && !isPlaylist) { + throw Exception('Item must be either a collection or playlist'); + } + + String ratingKey = item.ratingKey; + + final PlayQueueResponse? playQueue; + if (isCollection) { + // Validate that machine identifier is available + if (client.config.machineIdentifier == null) { + throw Exception('Machine identifier is required to play collections'); + } + + final collectionUri = + 'server://${client.config.machineIdentifier}/com.plexapp.plugins.library/library/collections/${item.ratingKey}'; + playQueue = await client.createPlayQueue( + uri: collectionUri, + type: 'video', + shuffle: shuffle ? 1 : 0, + ); + } else { + // For playlists, use playlistID parameter + playQueue = await client.createPlayQueue( + playlistID: int.parse(item.ratingKey), + type: 'video', + shuffle: shuffle ? 1 : 0, + ); + } + + // If the queue is empty, try fetching it again with getPlayQueue + if (playQueue != null && + (playQueue.items == null || playQueue.items!.isEmpty)) { + final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID); + + if (fetchedQueue != null && + fetchedQueue.items != null && + fetchedQueue.items!.isNotEmpty) { + if (!context.mounted) return; + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + fetchedQueue, + ratingKey, + ); + + // Navigate to first item + await navigateToVideoPlayer(context, metadata: fetchedQueue.items!.first); + return; + } + } + + if (playQueue == null || + playQueue.items == null || + playQueue.items!.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.failedToCreatePlayQueueNoItems), + ), + ); + } + return; + } + + if (!context.mounted) return; + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + playQueue, + ratingKey, + ); + + // Navigate to first item + await navigateToVideoPlayer(context, metadata: playQueue.items!.first); + } catch (e) { + appLogger.e('Failed to ${shuffle ? "shuffle play" : "play"}', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.failedPlayback( + action: shuffle ? t.common.shuffle : t.discover.play, + error: e.toString(), + ), + ), + ), + ); + } + } +} diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart new file mode 100644 index 00000000..c397dc49 --- /dev/null +++ b/lib/utils/grid_size_calculator.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart' show LibraryDensity; +import '../constants/layout_constants.dart'; + +/// Utility class for calculating consistent grid sizes across the app +class GridSizeCalculator { + /// Screen width breakpoint for tablet devices + static const double tabletBreakpoint = ScreenBreakpoints.tablet; + + /// Screen width breakpoint for desktop devices + static const double desktopBreakpoint = ScreenBreakpoints.desktop; + + /// Calculates the maximum cross-axis extent for grid items based on screen size and density + static double getMaxCrossAxisExtent( + BuildContext context, + LibraryDensity density, + ) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth > desktopBreakpoint; + final isTablet = screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + + switch (density) { + case LibraryDensity.comfortable: + if (isDesktop) return GridLayoutConstants.comfortableDesktop; + if (isTablet) return GridLayoutConstants.comfortableTablet; + return GridLayoutConstants.comfortableMobile; + case LibraryDensity.compact: + if (isDesktop) return GridLayoutConstants.compactDesktop; + if (isTablet) return GridLayoutConstants.compactTablet; + return GridLayoutConstants.compactMobile; + case LibraryDensity.normal: + if (isDesktop) return GridLayoutConstants.normalDesktop; + if (isTablet) return GridLayoutConstants.normalTablet; + return GridLayoutConstants.normalMobile; + } + } + + /// Returns whether the current screen is a desktop-sized screen + static bool isDesktop(BuildContext context) { + return MediaQuery.of(context).size.width > desktopBreakpoint; + } + + /// Returns whether the current screen is a tablet-sized screen + static bool isTablet(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + } + + /// Returns whether the current screen is a mobile-sized screen + static bool isMobile(BuildContext context) { + return MediaQuery.of(context).size.width <= tabletBreakpoint; + } +} diff --git a/lib/utils/library_refresh_notifier.dart b/lib/utils/library_refresh_notifier.dart new file mode 100644 index 00000000..fb4d08f4 --- /dev/null +++ b/lib/utils/library_refresh_notifier.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +/// Notifier for triggering refreshes of library tabs +/// Singleton pattern for global access +class LibraryRefreshNotifier { + static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal(); + + factory LibraryRefreshNotifier() => _instance; + + LibraryRefreshNotifier._internal(); + + // Stream controllers for different tab types + final _collectionsController = StreamController.broadcast(); + final _playlistsController = StreamController.broadcast(); + + // Streams that tabs can listen to + Stream get collectionsStream => _collectionsController.stream; + Stream get playlistsStream => _playlistsController.stream; + + // Methods to trigger refreshes + void notifyCollectionsChanged() { + if (!_collectionsController.isClosed) { + _collectionsController.add(null); + } + } + + void notifyPlaylistsChanged() { + if (!_playlistsController.isClosed) { + _playlistsController.add(null); + } + } + + // Cleanup + void dispose() { + _collectionsController.close(); + _playlistsController.close(); + } +} diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart new file mode 100644 index 00000000..78e173ad --- /dev/null +++ b/lib/widgets/empty_state_widget.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// A reusable widget for displaying empty states throughout the app +class EmptyStateWidget extends StatelessWidget { + /// The message to display + final String message; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional callback for action button + final VoidCallback? onAction; + + /// Optional label for the action button + final String? actionLabel; + + const EmptyStateWidget({ + super.key, + required this.message, + this.icon, + this.onAction, + this.actionLabel, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + ), + const SizedBox(height: 16), + ], + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + if (onAction != null && actionLabel != null) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/error_state_widget.dart b/lib/widgets/error_state_widget.dart new file mode 100644 index 00000000..be742f7f --- /dev/null +++ b/lib/widgets/error_state_widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// A reusable widget for displaying error states throughout the app +class ErrorStateWidget extends StatelessWidget { + /// The error message to display + final String message; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional callback for retry action + final VoidCallback? onRetry; + + /// Optional label for the retry button + final String? retryLabel; + + const ErrorStateWidget({ + super.key, + required this.message, + this.icon, + this.onRetry, + this.retryLabel, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + ], + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(retryLabel ?? 'Retry'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/filters_bottom_sheet.dart b/lib/widgets/filters_bottom_sheet.dart new file mode 100644 index 00000000..443179f4 --- /dev/null +++ b/lib/widgets/filters_bottom_sheet.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import '../models/plex_filter.dart'; +import '../widgets/app_bar_back_button.dart'; +import '../utils/provider_extensions.dart'; +import '../i18n/strings.g.dart'; + +class FiltersBottomSheet extends StatefulWidget { + final List filters; + final Map selectedFilters; + final Function(Map) onFiltersChanged; + + const FiltersBottomSheet({ + super.key, + required this.filters, + required this.selectedFilters, + required this.onFiltersChanged, + }); + + @override + State createState() => _FiltersBottomSheetState(); +} + +class _FiltersBottomSheetState extends State { + PlexFilter? _currentFilter; + List _filterValues = []; + bool _isLoadingValues = false; + final Map _tempSelectedFilters = {}; + final Map _filterDisplayNames = {}; // Cache for display names + late List _sortedFilters; + + @override + void initState() { + super.initState(); + _tempSelectedFilters.addAll(widget.selectedFilters); + _sortFilters(); + } + + void _sortFilters() { + // Separate boolean filters (toggles) from regular filters + final booleanFilters = widget.filters + .where((f) => f.filterType == 'boolean') + .toList(); + final regularFilters = widget.filters + .where((f) => f.filterType != 'boolean') + .toList(); + + // Combine with boolean filters first + _sortedFilters = [...booleanFilters, ...regularFilters]; + } + + bool _isBooleanFilter(PlexFilter filter) { + return filter.filterType == 'boolean'; + } + + Future _loadFilterValues(PlexFilter filter) async { + setState(() { + _currentFilter = filter; + _isLoadingValues = true; + }); + + try { + final client = context.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final values = await client.getFilterValues(filter.key); + setState(() { + _filterValues = values; + _isLoadingValues = false; + }); + } catch (e) { + setState(() { + _filterValues = []; + _isLoadingValues = false; + }); + } + } + + void _goBack() { + setState(() { + _currentFilter = null; + _filterValues = []; + }); + } + + void _applyFilters() { + widget.onFiltersChanged(_tempSelectedFilters); + Navigator.pop(context); + } + + String _extractFilterValue(String key, String filterName) { + if (key.contains('?')) { + final queryStart = key.indexOf('?'); + final queryString = key.substring(queryStart + 1); + final params = Uri.splitQueryString(queryString); + return params[filterName] ?? key; + } else if (key.startsWith('/')) { + return key.split('/').last; + } + return key; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + AppBarBackButton( + style: BackButtonStyle.plain, + onPressed: _goBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentFilter!.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return ListTile( + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, + _currentFilter!.filter, + ); + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + + return ListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; + }); + _applyFilters(); + }, + ); + }, + ), + ), + ], + ); + } + + // Show main filters view + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Icon(Icons.filter_alt), + const SizedBox(width: 12), + Text( + t.libraries.filters, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_tempSelectedFilters.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); + }, + icon: const Icon(Icons.clear_all), + label: Text(t.libraries.clearAll), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return SwitchListTile( + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return ListTile( + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/folder_tree_item.dart b/lib/widgets/folder_tree_item.dart new file mode 100644 index 00000000..384cbdb8 --- /dev/null +++ b/lib/widgets/folder_tree_item.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import '../models/plex_metadata.dart'; + +/// Individual item in the folder tree +/// Can be either a folder (expandable) or a file (tappable) +class FolderTreeItem extends StatelessWidget { + final PlexMetadata item; + final int depth; + final bool isExpanded; + final bool isFolder; + final VoidCallback? onTap; + final VoidCallback? onExpand; + final bool isLoading; + + const FolderTreeItem({ + super.key, + required this.item, + required this.depth, + this.isExpanded = false, + this.isFolder = false, + this.onTap, + this.onExpand, + this.isLoading = false, + }); + + IconData _getIcon() { + if (isFolder) { + return Icons.folder; + } + + // File icons based on type + final type = item.type.toLowerCase(); + switch (type) { + case 'movie': + return Icons.movie; + case 'show': + return Icons.tv; + case 'season': + return Icons.video_library; + case 'episode': + return Icons.play_circle_outline; + case 'collection': + return Icons.collections; + default: + return Icons.insert_drive_file; + } + } + + @override + Widget build(BuildContext context) { + final indentation = depth * 24.0; + + return InkWell( + onTap: isFolder ? onExpand : onTap, + child: Container( + padding: EdgeInsets.only( + left: 16.0 + indentation, + right: 16.0, + top: 12.0, + bottom: 12.0, + ), + child: Row( + children: [ + // Expand/collapse icon for folders + if (isFolder) + SizedBox( + width: 24, + child: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + ) + else + const SizedBox(width: 24), + + const SizedBox(width: 8), + + // File/folder icon + Icon( + _getIcon(), + size: 20, + color: isFolder + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + + const SizedBox(width: 12), + + // Item title + Expanded( + child: Text( + item.title, + style: TextStyle( + fontSize: 14, + fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Additional metadata for files + if (!isFolder && item.year != null) + Text( + item.year.toString(), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart new file mode 100644 index 00000000..b9854406 --- /dev/null +++ b/lib/widgets/folder_tree_view.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import 'folder_tree_item.dart'; +import '../i18n/strings.g.dart'; + +/// Expandable tree view for browsing library folders +/// Shows a hierarchical file/folder structure +class FolderTreeView extends StatefulWidget { + final String libraryKey; + final void Function(String)? onRefresh; + + const FolderTreeView({ + super.key, + required this.libraryKey, + this.onRefresh, + }); + + @override + State createState() => _FolderTreeViewState(); +} + +class _FolderTreeViewState extends State { + List _rootFolders = []; + final Map> _childrenCache = {}; + final Set _expandedFolders = {}; + final Set _loadingFolders = {}; + bool _isLoadingRoot = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadRootFolders(); + } + + Future _loadRootFolders() async { + setState(() { + _isLoadingRoot = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.read(); + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final folders = await client.getLibraryFolders(widget.libraryKey); + + if (!mounted) return; + + setState(() { + _rootFolders = folders; + _isLoadingRoot = false; + }); + + appLogger.d('Loaded ${folders.length} root folders'); + } catch (e) { + if (!mounted) return; + + appLogger.e('Failed to load root folders', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.libraries.folders, + error: e.toString(), + ); + _isLoadingRoot = false; + }); + } + } + + Future _loadFolderChildren(PlexMetadata folder) async { + // Already loading this folder + if (_loadingFolders.contains(folder.key)) return; + + // Already loaded and cached + if (_childrenCache.containsKey(folder.key)) { + setState(() { + _expandedFolders.add(folder.key); + }); + return; + } + + setState(() { + _loadingFolders.add(folder.key); + }); + + try { + final clientProvider = context.read(); + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final children = await client.getFolderChildren(folder.key); + + if (!mounted) return; + + setState(() { + _childrenCache[folder.key] = children; + _expandedFolders.add(folder.key); + _loadingFolders.remove(folder.key); + }); + + appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); + } catch (e) { + if (!mounted) return; + + appLogger.e('Failed to load folder children', error: e); + setState(() { + _loadingFolders.remove(folder.key); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.errors.failedToLoad( + context: t.libraries.folders, + error: e.toString(), + ), + ), + ), + ); + } + } + } + + void _toggleFolder(PlexMetadata folder) { + if (_expandedFolders.contains(folder.key)) { + setState(() { + _expandedFolders.remove(folder.key); + }); + } else { + _loadFolderChildren(folder); + } + } + + Future _handleItemTap(PlexMetadata item) async { + final itemType = item.type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await navigateToVideoPlayer( + context, + metadata: item, + ); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } else if (itemType == 'season') { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen(season: item), + ), + ); + widget.onRefresh?.call(item.ratingKey); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen(metadata: item), + ), + ); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } + } + + bool _isFolder(PlexMetadata item) { + // Folders typically don't have a specific type or might have special indicators + // Check for common folder indicators + return item.key.contains('/folder') || + item.type.isEmpty || + item.type.toLowerCase() == 'folder'; + } + + List _buildTreeItems(List items, int depth, [String parentPath = '']) { + final List widgets = []; + + for (int i = 0; i < items.length; i++) { + final item = items[i]; + final isFolder = _isFolder(item); + final isExpanded = _expandedFolders.contains(item.key); + final isLoading = _loadingFolders.contains(item.key); + + // Create a unique key path that includes parent hierarchy and index + final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; + + // Add the item itself + widgets.add( + FolderTreeItem( + key: ValueKey(itemPath), + item: item, + depth: depth, + isFolder: isFolder, + isExpanded: isExpanded, + isLoading: isLoading, + onExpand: isFolder ? () => _toggleFolder(item) : null, + onTap: !isFolder ? () => _handleItemTap(item) : null, + ), + ); + + // Add children if folder is expanded + if (isFolder && isExpanded && _childrenCache.containsKey(item.key)) { + final children = _childrenCache[item.key]!; + widgets.addAll(_buildTreeItems(children, depth + 1, itemPath)); + } + } + + return widgets; + } + + @override + Widget build(BuildContext context) { + if (_isLoadingRoot) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + 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: _loadRootFolders, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_rootFolders.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.noFoldersFound), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadRootFolders, + child: ListView( + children: _buildTreeItems(_rootFolders, 0), + ), + ); + } +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart new file mode 100644 index 00000000..c2c4f1d1 --- /dev/null +++ b/lib/widgets/hub_section.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import '../models/plex_hub.dart'; +import '../screens/hub_detail_screen.dart'; +import 'media_card.dart'; +import 'horizontal_scroll_with_arrows.dart'; +import '../i18n/strings.g.dart'; + +/// Shared hub section widget used in both discover and library screens +/// Displays a hub title with icon and a horizontal scrollable list of items +class HubSection extends StatelessWidget { + final PlexHub hub; + final IconData icon; + final void Function(String)? onRefresh; + final VoidCallback? onRemoveFromContinueWatching; + final bool isInContinueWatching; + + const HubSection({ + super.key, + required this.hub, + required this.icon, + this.onRefresh, + this.onRemoveFromContinueWatching, + this.isInContinueWatching = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Hub header + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: InkWell( + onTap: hub.more + ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => HubDetailScreen(hub: hub), + ), + ); + } + : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + Icon(icon), + const SizedBox(width: 8), + Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (hub.more) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], + ], + ), + ), + ), + ), + + // Hub items (horizontal scroll) + if (hub.items.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + // Responsive card width based on screen size + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth > 1600 + ? 220.0 + : screenWidth > 1200 + ? 200.0 + : screenWidth > 800 + ? 190.0 + : 160.0; + + // MediaCard has 8px padding on all sides (16px total horizontally) + // So actual poster width is cardWidth - 16 + final posterWidth = cardWidth - 16; + // 2:3 poster aspect ratio (height is 1.5x width) + final posterHeight = posterWidth * 1.5; + // Container height = poster + padding + spacing + text + // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding + final containerHeight = posterHeight + 46; + + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: hub.items.length, + itemBuilder: (context, index) { + final item = hub.items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: MediaCard( + key: Key(item.ratingKey), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: onRefresh, + onRemoveFromContinueWatching: + onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: isInContinueWatching, + ), + ); + }, + ), + ), + ); + }, + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + t.messages.noItemsAvailable, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 0801f86e..4572273a 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; @@ -10,18 +11,22 @@ import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; +import '../screens/playlist_detail_screen.dart'; +import '../screens/collection_detail_screen.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; class MediaCard extends StatefulWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; + final VoidCallback? onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; final bool isInContinueWatching; + final String? collectionId; // The collection ID if displaying within a collection const MediaCard({ super.key, @@ -30,8 +35,10 @@ class MediaCard extends StatefulWidget { this.height, this.onRefresh, this.onRemoveFromContinueWatching, + this.onListRefresh, this.forceGridMode = false, this.isInContinueWatching = false, + this.collectionId, }); @override @@ -43,8 +50,39 @@ class _MediaCardState extends State { final client = context.client; if (client == null) return; + // Handle playlists + if (widget.item is PlexPlaylist) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen( + playlist: widget.item as PlexPlaylist, + ), + ), + ); + return; + } + final itemType = widget.item.type.toLowerCase(); + // Handle collections + if (itemType == 'collection') { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CollectionDetailScreen( + collection: widget.item, + ), + ), + ); + + // If collection was deleted, refresh the parent list + if (result == true && mounted) { + widget.onListRefresh?.call(); + } + return; + } + // Music content is not yet supported if (itemType == 'artist' || itemType == 'album' || itemType == 'track') { if (context.mounted) { @@ -100,31 +138,36 @@ class _MediaCardState extends State { ? ViewMode.grid : settingsProvider.viewMode; + final cardWidget = viewMode == ViewMode.grid + ? _MediaCardGrid( + item: widget.item, + width: widget.width, + height: widget.height, + onTap: () => _handleTap(context), + ) + : _MediaCardList( + item: widget.item, + onTap: () => _handleTap(context), + density: settingsProvider.libraryDensity, + ); + + // Use context menu for both PlexMetadata and PlexPlaylist items return MediaContextMenu( - metadata: widget.item, + item: widget.item, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + onListRefresh: widget.onListRefresh, onTap: () => _handleTap(context), isInContinueWatching: widget.isInContinueWatching, - child: viewMode == ViewMode.grid - ? _MediaCardGrid( - item: widget.item, - width: widget.width, - height: widget.height, - onTap: () => _handleTap(context), - ) - : _MediaCardList( - item: widget.item, - onTap: () => _handleTap(context), - density: settingsProvider.libraryDensity, - ), + collectionId: widget.collectionId, + child: cardWidget, ); } } /// Grid layout for media cards class _MediaCardGrid extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final VoidCallback onTap; @@ -145,6 +188,7 @@ class _MediaCardGrid extends StatelessWidget { identifier: "media-card-${item.ratingKey}", button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -167,7 +211,9 @@ class _MediaCardGrid extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ Text( - item.displayTitle, + item is PlexPlaylist + ? (item as PlexPlaylist).title + : (item as PlexMetadata).displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -176,37 +222,85 @@ class _MediaCardGrid extends StatelessWidget { height: 1.1, ), ), - if (item.displaySubtitle != null) - Text( - item.displaySubtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + if (item is PlexPlaylist) + Builder( + builder: (context) { + final playlist = item as PlexPlaylist; + if (playlist.leafCount != null && playlist.leafCount! > 0) { + return Text( + t.playlists.itemCount(count: playlist.leafCount!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + return const SizedBox.shrink(); + }, ) - else if (item.parentTitle != null) - Text( - item.parentTitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ) - else if (item.year != null) - Text( - '${item.year}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + else if (item is PlexMetadata) ...[ + Builder( + builder: (context) { + final metadata = item as PlexMetadata; + + // For collections, show item count + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + return Text( + t.playlists.itemCount(count: count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + } + + // For other media types, show subtitle/parent/year + if (metadata.displaySubtitle != null) { + return Text( + metadata.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.parentTitle != null) { + return Text( + metadata.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.year != null) { + return Text( + '${metadata.year}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + + return const SizedBox.shrink(); + }, ), + ], ], ), ], @@ -230,22 +324,31 @@ class _MediaCardGrid extends StatelessWidget { } Widget _buildPosterImage(BuildContext context) { - final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + String? posterUrl; + IconData fallbackIcon = Icons.movie; + + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + } + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), + imageUrl: client.getThumbnailUrl(posterUrl!), fit: BoxFit.cover, width: double.infinity, height: double.infinity, @@ -254,15 +357,15 @@ class _MediaCardGrid extends StatelessWidget { placeholder: (context, url) => const SkeletonLoader(), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.broken_image, size: 40)), + child: Center(child: Icon(fallbackIcon, size: 40)), ), ); }, ); } else { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } @@ -271,7 +374,7 @@ class _MediaCardGrid extends StatelessWidget { /// List layout for media cards class _MediaCardList extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final VoidCallback onTap; final LibraryDensity density; @@ -366,48 +469,84 @@ class _MediaCardList extends StatelessWidget { String _buildMetadataLine() { final parts = []; - // Add content rating - if (item.contentRating != null && item.contentRating!.isNotEmpty) { - final rating = formatContentRating(item.contentRating); - if (rating.isNotEmpty) { - parts.add(rating); + if (item is PlexPlaylist) { + final playlist = item as PlexPlaylist; + // Add item count + if (playlist.leafCount != null && playlist.leafCount! > 0) { + parts.add(t.playlists.itemCount(count: playlist.leafCount!)); } - } - // Add year - if (item.year != null) { - parts.add('${item.year}'); - } + // Add duration + if (playlist.formattedDuration != null) { + parts.add(playlist.formattedDuration!); + } - // Add duration - if (item.duration != null) { - parts.add(_formatDuration(item.duration!)); - } + // Add smart playlist badge + if (playlist.smart) { + parts.add(t.playlists.smartPlaylist); + } + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; - // Add user rating - if (item.rating != null) { - parts.add('${item.rating!.toStringAsFixed(1)}★'); - } + // For collections, show item count + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + parts.add(t.playlists.itemCount(count: count)); + } + } else { + // For other media types, show standard metadata + // Add content rating + if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) { + final rating = formatContentRating(metadata.contentRating); + if (rating.isNotEmpty) { + parts.add(rating); + } + } - // Add studio - if (item.studio != null && item.studio!.isNotEmpty) { - parts.add(item.studio!); + // Add year + if (metadata.year != null) { + parts.add('${metadata.year}'); + } + + // Add duration + if (metadata.duration != null) { + parts.add(_formatDuration(metadata.duration!)); + } + + // Add user rating + if (metadata.rating != null) { + parts.add('${metadata.rating!.toStringAsFixed(1)}★'); + } + + // Add studio + if (metadata.studio != null && metadata.studio!.isNotEmpty) { + parts.add(metadata.studio!); + } + } } return parts.join(' • '); } String? _buildSubtitleText() { - // For TV episodes, show S#E# format - if (item.parentIndex != null && item.index != null) { - return 'S${item.parentIndex} E${item.index}'; - } + if (item is PlexPlaylist) { + // Playlists don't have subtitles + return null; + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; - // Otherwise use existing subtitle logic - if (item.displaySubtitle != null) { - return item.displaySubtitle; - } else if (item.parentTitle != null) { - return item.parentTitle; + // For TV episodes, show S#E# format + if (metadata.parentIndex != null && metadata.index != null) { + return 'S${metadata.parentIndex} E${metadata.index}'; + } + + // Otherwise use existing subtitle logic + if (metadata.displaySubtitle != null) { + return metadata.displaySubtitle; + } else if (metadata.parentTitle != null) { + return metadata.parentTitle; + } } // Year is now shown in metadata line, so don't show it here @@ -424,6 +563,7 @@ class _MediaCardList extends StatelessWidget { identifier: "media-card-${item.ratingKey}", button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -520,22 +660,31 @@ class _MediaCardList extends StatelessWidget { } Widget _buildPosterImage(BuildContext context) { - final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + String? posterUrl; + IconData fallbackIcon = Icons.movie; + + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + } + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), + imageUrl: client.getThumbnailUrl(posterUrl!), fit: BoxFit.cover, width: double.infinity, height: double.infinity, @@ -544,15 +693,15 @@ class _MediaCardList extends StatelessWidget { placeholder: (context, url) => const SkeletonLoader(), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.broken_image, size: 40)), + child: Center(child: Icon(fallbackIcon, size: 40)), ), ); }, ); } else { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } @@ -561,16 +710,23 @@ class _MediaCardList extends StatelessWidget { /// Overlay widget for poster showing watched indicator and progress bar class _PosterOverlay extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist const _PosterOverlay({required this.item}); @override Widget build(BuildContext context) { + // Only show overlays for PlexMetadata items + if (item is! PlexMetadata) { + return const SizedBox.shrink(); + } + + final metadata = item as PlexMetadata; + return Stack( children: [ // Watched indicator (checkmark) - if (item.isWatched) + if (metadata.isWatched) Positioned( top: 4, right: 4, @@ -590,10 +746,10 @@ class _PosterOverlay extends StatelessWidget { ), ), // Progress bar for partially watched content - if (item.viewOffset != null && - item.duration != null && - item.viewOffset! > 0 && - !item.isWatched) + if (metadata.viewOffset != null && + metadata.duration != null && + metadata.viewOffset! > 0 && + !metadata.isWatched) Positioned( bottom: 0, left: 0, @@ -604,7 +760,7 @@ class _PosterOverlay extends StatelessWidget { bottomRight: Radius.circular(8), ), child: LinearProgressIndicator( - value: item.viewOffset! / item.duration!, + value: metadata.viewOffset! / metadata.duration!, backgroundColor: tokens(context).outline, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 66c72186..67f0327f 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -4,6 +4,8 @@ import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/collection_playlist_play_helper.dart'; +import '../utils/library_refresh_notifier.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; @@ -22,21 +24,25 @@ class _MenuAction { /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. class MediaContextMenu extends StatefulWidget { - final PlexMetadata metadata; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; + final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; final Widget child; final bool isInContinueWatching; + final String? collectionId; // The collection ID if displaying within a collection const MediaContextMenu({ super.key, - required this.metadata, + required this.item, this.onRefresh, this.onRemoveFromContinueWatching, + this.onListRefresh, this.onTap, required this.child, this.isInContinueWatching = false, + this.collectionId, }); @override @@ -54,12 +60,16 @@ class _MediaContextMenuState extends State { final client = context.client; if (client == null) return; - final itemType = widget.metadata.type.toLowerCase(); - final isPartiallyWatched = - widget.metadata.viewedLeafCount != null && - widget.metadata.leafCount != null && - widget.metadata.viewedLeafCount! > 0 && - widget.metadata.viewedLeafCount! < widget.metadata.leafCount!; + final isPlaylist = widget.item is PlexPlaylist; + final metadata = isPlaylist ? null : widget.item as PlexMetadata; + final itemType = isPlaylist ? 'playlist' : (metadata!.type.toLowerCase()); + final isCollection = itemType == 'collection'; + + final isPartiallyWatched = !isPlaylist && + metadata!.viewedLeafCount != null && + metadata.leafCount != null && + metadata.viewedLeafCount! > 0 && + metadata.viewedLeafCount! < widget.item.leafCount!; // Check if we should use bottom sheet (on iOS and Android) final useBottomSheet = Platform.isIOS || Platform.isAndroid; @@ -67,8 +77,41 @@ class _MediaContextMenuState extends State { // Build menu actions final menuActions = <_MenuAction>[]; + // Special actions for collections and playlists + if (isCollection || isPlaylist) { + // Play + menuActions.add( + _MenuAction( + value: 'play', + icon: Icons.play_arrow, + label: t.discover.play, + ), + ); + + // Shuffle + menuActions.add( + _MenuAction( + value: 'shuffle', + icon: Icons.shuffle, + label: t.mediaMenu.shufflePlay, + ), + ); + + // Delete + menuActions.add( + _MenuAction( + value: 'delete', + icon: Icons.delete, + label: t.common.delete, + ), + ); + + // Skip other menu items for collections and playlists + } else { + // Regular menu items for other types + // Mark as Watched - if (!widget.metadata.isWatched || isPartiallyWatched) { + if (!metadata!.isWatched || isPartiallyWatched) { menuActions.add( _MenuAction( value: 'watch', @@ -79,7 +122,7 @@ class _MediaContextMenuState extends State { } // Mark as Unwatched - if (widget.metadata.isWatched || isPartiallyWatched) { + if (metadata.isWatched || isPartiallyWatched) { menuActions.add( _MenuAction( value: 'unwatch', @@ -100,9 +143,20 @@ class _MediaContextMenuState extends State { ); } + // Remove from Collection (only when viewing items within a collection) + if (widget.collectionId != null) { + menuActions.add( + _MenuAction( + value: 'remove_from_collection', + icon: Icons.delete_outline, + label: t.collections.removeFromCollection, + ), + ); + } + // Go to Series (for episodes and seasons) if ((itemType == 'episode' || itemType == 'season') && - widget.metadata.grandparentTitle != null) { + metadata.grandparentTitle != null) { menuActions.add( _MenuAction( value: 'series', @@ -113,7 +167,7 @@ class _MediaContextMenuState extends State { } // Go to Season (for episodes) - if (itemType == 'episode' && widget.metadata.parentTitle != null) { + if (itemType == 'episode' && metadata.parentTitle != null) { menuActions.add( _MenuAction( value: 'season', @@ -145,19 +199,20 @@ class _MediaContextMenuState extends State { ); } - // Add to Playlist (for episodes, movies, shows, and seasons) + // Add to... (for episodes, movies, shows, and seasons) if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { menuActions.add( _MenuAction( - value: 'add_to_playlist', - icon: Icons.playlist_add, - label: t.playlists.addTo, + value: 'add_to', + icon: Icons.add, + label: t.common.addTo, ), ); } + } // End of regular menu items else block String? selected; @@ -172,7 +227,7 @@ class _MediaContextMenuState extends State { Padding( padding: const EdgeInsets.all(16.0), child: Text( - widget.metadata.title, + widget.item.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -249,7 +304,7 @@ class _MediaContextMenuState extends State { case 'watch': await _executeAction( context, - () => client.markAsWatched(widget.metadata.ratingKey), + () => client.markAsWatched(metadata!.ratingKey), t.messages.markedAsWatched, ); break; @@ -257,7 +312,7 @@ class _MediaContextMenuState extends State { case 'unwatch': await _executeAction( context, - () => client.markAsUnwatched(widget.metadata.ratingKey), + () => client.markAsUnwatched(metadata!.ratingKey), t.messages.markedAsUnwatched, ); break; @@ -267,7 +322,7 @@ class _MediaContextMenuState extends State { // This preserves the progression for partially watched items // and doesn't mark unwatched next episodes as watched try { - await client.removeFromOnDeck(widget.metadata.ratingKey); + await client.removeFromOnDeck(metadata!.ratingKey); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.messages.removedFromContinueWatching)), @@ -276,7 +331,7 @@ class _MediaContextMenuState extends State { if (widget.onRemoveFromContinueWatching != null) { widget.onRemoveFromContinueWatching!(); } else { - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(metadata.ratingKey); } } } catch (e) { @@ -290,10 +345,14 @@ class _MediaContextMenuState extends State { } break; + case 'remove_from_collection': + await _handleRemoveFromCollection(context, metadata!); + break; + case 'series': await _navigateToRelated( context, - widget.metadata.grandparentRatingKey, + metadata!.grandparentRatingKey, (metadata) => MediaDetailScreen(metadata: metadata), t.messages.errorLoadingSeries, ); @@ -302,7 +361,7 @@ class _MediaContextMenuState extends State { case 'season': await _navigateToRelated( context, - widget.metadata.parentRatingKey, + metadata!.parentRatingKey, (metadata) => SeasonDetailScreen(season: metadata), t.messages.errorLoadingSeason, ); @@ -312,12 +371,24 @@ class _MediaContextMenuState extends State { await _showFileInfo(context); break; - case 'add_to_playlist': - await _showAddToPlaylistDialog(context); + case 'add_to': + await _showAddToSubmenu(context); break; case 'shuffle_play': - await handleShufflePlay(context, widget.metadata); + await handleShufflePlay(context, metadata!); + break; + + case 'play': + await _handlePlay(context, isCollection, isPlaylist); + break; + + case 'shuffle': + await _handleShuffle(context, isCollection, isPlaylist); + break; + + case 'delete': + await _handleDelete(context, isCollection, isPlaylist); break; } } @@ -334,7 +405,7 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(successMessage))); - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { @@ -365,7 +436,7 @@ class _MediaContextMenuState extends State { context, MaterialPageRoute(builder: (context) => screenBuilder(metadata)), ); - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { @@ -393,7 +464,8 @@ class _MediaContextMenuState extends State { } // Fetch file info - final fileInfo = await client.getFileInfo(widget.metadata.ratingKey); + final metadata = widget.item as PlexMetadata; + final fileInfo = await client.getFileInfo(metadata.ratingKey); // Close loading indicator if (context.mounted) { @@ -408,7 +480,7 @@ class _MediaContextMenuState extends State { backgroundColor: Colors.transparent, builder: (context) => FileInfoBottomSheet( fileInfo: fileInfo, - title: widget.metadata.title, + title: metadata.title, ), ); } else if (context.mounted) { @@ -432,13 +504,95 @@ class _MediaContextMenuState extends State { } } + /// Show submenu for Add to... (Playlist or Collection) + Future _showAddToSubmenu(BuildContext context) async { + final useBottomSheet = Platform.isIOS || Platform.isAndroid; + + final submenuActions = [ + _MenuAction( + value: 'playlist', + icon: Icons.playlist_play, + label: t.playlists.playlist, + ), + _MenuAction( + value: 'collection', + icon: Icons.collections, + label: t.collections.collection, + ), + ]; + + String? selected; + + if (useBottomSheet) { + // Show bottom sheet on mobile + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + t.common.addTo, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ...submenuActions.map((action) { + return ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + ); + }), + const SizedBox(height: 8), + ], + ), + ), + ); + } else { + // Show popup menu on desktop + selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + ), + items: submenuActions.map((action) { + return PopupMenuItem( + value: action.value, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(action.icon, size: 20), + const SizedBox(width: 12), + Text(action.label), + ], + ), + ); + }).toList(), + ); + } + + // Handle the submenu selection + if (selected == 'playlist' && context.mounted) { + await _showAddToPlaylistDialog(context); + } else if (selected == 'collection' && context.mounted) { + await _showAddToCollectionDialog(context); + } + } + /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { final client = context.client; if (client == null) return; try { - final itemType = widget.metadata.type.toLowerCase(); + final metadata = widget.item as PlexMetadata; + final itemType = metadata.type.toLowerCase(); // Load playlists final playlists = await client.getPlaylists(playlistType: 'video'); @@ -455,7 +609,7 @@ class _MediaContextMenuState extends State { // Build URI for the item (works for all types: movies, episodes, seasons, shows) // For seasons/shows, the Plex API should automatically expand to include all episodes - final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey); + final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); if (result == '_create_new') { @@ -484,6 +638,8 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(t.playlists.created))); + // Trigger refresh of playlists tab + LibraryRefreshNotifier().notifyPlaylistsChanged(); } else { appLogger.e('Failed to create playlist - API returned null'); ScaffoldMessenger.of( @@ -505,6 +661,8 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded))); + // Trigger refresh of playlists tab + LibraryRefreshNotifier().notifyPlaylistsChanged(); } else { appLogger.e( 'Failed to add item(s) to playlist $result - API returned false', @@ -532,6 +690,388 @@ class _MediaContextMenuState extends State { } } + /// Show dialog to select collection and add item + Future _showAddToCollectionDialog(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + final metadata = widget.item as PlexMetadata; + final itemType = metadata.type.toLowerCase(); + + // Get the library section ID from the item + // First try from the metadata itself + int? sectionId = metadata.librarySectionID; + appLogger.d('Attempting to get section ID for ${metadata.title}'); + appLogger.d(' - librarySectionID: $sectionId'); + appLogger.d(' - key: ${metadata.key}'); + + // If not available, fetch the full metadata which should include the section ID + if (sectionId == null) { + try { + appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}'); + final fullMetadata = await client.getMetadata(metadata.ratingKey); + if (fullMetadata != null) { + sectionId = fullMetadata.librarySectionID; + appLogger.d(' - Section ID from full metadata: $sectionId'); + } + } catch (e) { + appLogger.w('Failed to get full metadata for section ID: $e'); + } + } + + // If still not found, try to extract from the key field + if (sectionId == null) { + final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key); + if (keyMatch != null) { + sectionId = int.tryParse(keyMatch.group(1)!); + appLogger.d(' - Extracted from key: $sectionId'); + } + } + + // Last resort: try to get it from the item's parent (for episodes/seasons) + if (sectionId == null && metadata.grandparentRatingKey != null) { + try { + appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}'); + final parentMeta = await client.getMetadata(metadata.grandparentRatingKey!); + sectionId = parentMeta?.librarySectionID; + appLogger.d(' - Parent sectionId: $sectionId'); + } catch (e) { + appLogger.w('Failed to get parent metadata for section ID: $e'); + } + } + + appLogger.d(' - Final sectionId: $sectionId'); + + if (sectionId == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to determine library section for this item'), + ), + ); + } + return; + } + + // Load collections for this library section + final collections = await client.getLibraryCollections(sectionId.toString()); + + if (!context.mounted) return; + + // Show dialog to select collection or create new + final result = await showDialog( + context: context, + builder: (context) => _CollectionSelectionDialog(collections: collections), + ); + + if (result == null || !context.mounted) return; + + // Build URI for the item + final itemUri = await client.buildMetadataUri(metadata.ratingKey); + appLogger.d('Built URI for $itemType: $itemUri'); + + if (result == '_create_new') { + // Create new collection flow + final collectionName = await showDialog( + context: context, + builder: (context) => _CreateCollectionDialog(), + ); + + if (collectionName == null || collectionName.isEmpty || !context.mounted) { + return; + } + + // Create collection first (without items) + // Determine the collection type based on the item type + int? collectionType; + switch (itemType) { + case 'movie': + collectionType = 1; + break; + case 'show': + collectionType = 2; + break; + case 'season': + collectionType = 3; + break; + case 'episode': + collectionType = 4; + break; + } + + appLogger.d('Creating collection "$collectionName" with type $collectionType'); + final newCollectionId = await client.createCollection( + sectionId: sectionId.toString(), + title: collectionName, + uri: '', // Empty for regular collections + type: collectionType, + ); + + if (context.mounted) { + if (newCollectionId != null) { + appLogger.d('Successfully created collection with ID: $newCollectionId'); + + // Now add the item to the newly created collection + appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri'); + final addSuccess = await client.addToCollection( + collectionId: newCollectionId, + uri: itemUri, + ); + + if (addSuccess) { + appLogger.d('Successfully added item to new collection'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.created))); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + } else { + appLogger.e('Failed to add item to new collection'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } else { + appLogger.e('Failed to create collection - API returned null'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } + } else { + // Add to existing collection + appLogger.d('Adding to collection $result with URI: $itemUri'); + final success = await client.addToCollection( + collectionId: result, + uri: itemUri, + ); + + if (context.mounted) { + if (success) { + appLogger.d('Successfully added item(s) to collection $result'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.addedToCollection))); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + } else { + appLogger.e( + 'Failed to add item(s) to collection $result - API returned false', + ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } + } + } catch (e, stackTrace) { + appLogger.e( + 'Error in add to collection flow', + error: e, + stackTrace: stackTrace, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.collections.errorAddingToCollection}: ${e.toString()}'), + duration: const Duration(seconds: 5), + ), + ); + } + } + } + + /// Handle remove from collection action + Future _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { + final client = context.client; + if (client == null) return; + + if (widget.collectionId == null) { + appLogger.e('Cannot remove from collection: collectionId is null'); + return; + } + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.collections.removeFromCollection), + content: Text( + t.collections.removeFromCollectionConfirm(title: metadata.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), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + appLogger.d( + 'Removing item ${metadata.ratingKey} from collection ${widget.collectionId}', + ); + final success = await client.removeFromCollection( + collectionId: widget.collectionId!, + itemId: metadata.ratingKey, + ); + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.collections.removedFromCollection), + ), + ); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + // Trigger list refresh to remove the item from the view + widget.onListRefresh?.call(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.collections.removeFromCollectionFailed), + ), + ); + } + } + } catch (e) { + appLogger.e('Failed to remove from collection', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.collections.removeFromCollectionError(error: e.toString()), + ), + ), + ); + } + } + } + + /// Handle play action for collections and playlists + Future _handlePlay(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.item, + shuffle: false, + ); + } + + /// Handle shuffle action for collections and playlists + Future _handleShuffle(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.item, + shuffle: true, + ); + } + + /// Handle delete action for collections and playlists + Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + final itemTitle = widget.item.title; + final itemTypeLabel = + isCollection ? t.collections.collection : t.playlists.playlist; + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + isCollection ? t.collections.deleteCollection : t.playlists.delete, + ), + content: Text( + isCollection + ? t.collections.deleteConfirm(title: itemTitle) + : t.playlists.deleteMessage(name: itemTitle), + ), + 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), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + bool success = false; + + if (isCollection) { + final metadata = widget.item as PlexMetadata; + final sectionId = metadata.librarySectionID?.toString() ?? '0'; + success = await client.deleteCollection(sectionId, metadata.ratingKey); + } else if (isPlaylist) { + final playlist = widget.item as PlexPlaylist; + success = await client.deletePlaylist(playlist.ratingKey); + } + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection ? t.collections.deleted : t.playlists.deleted, + ), + ), + ); + // Trigger list refresh + widget.onListRefresh?.call(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection + ? t.collections.deleteFailed + : t.playlists.errorDeleting, + ), + ), + ); + } + } + } catch (e) { + appLogger.e('Failed to delete $itemTypeLabel', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection + ? t.collections.deleteFailedWithError(error: e.toString()) + : t.playlists.errorDeleting, + ), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -650,3 +1190,100 @@ class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { ); } } + +/// Dialog to select a collection or create a new one +class _CollectionSelectionDialog extends StatelessWidget { + final List collections; + + const _CollectionSelectionDialog({required this.collections}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.collections.selectCollection), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: collections.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + // Create new collection option (always shown first) + return ListTile( + leading: const Icon(Icons.add), + title: Text(t.collections.createNewCollection), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + final collection = collections[index - 1]; + return ListTile( + leading: const Icon(Icons.collections), + title: Text(collection.title), + subtitle: collection.childCount != null + ? Text('${collection.childCount} items') + : null, + onTap: () => Navigator.pop(context, collection.ratingKey), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + } +} + +/// Dialog to create a new collection +class _CreateCollectionDialog extends StatefulWidget { + @override + State<_CreateCollectionDialog> createState() => _CreateCollectionDialogState(); +} + +class _CreateCollectionDialogState extends State<_CreateCollectionDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.collections.createNewCollection), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.collections.collectionName, + hintText: t.collections.enterCollectionName, + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + }, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 6d5908fd..0ff146d4 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -39,6 +39,7 @@ class _SortBottomSheetState extends State { _currentDescending = descending; }); widget.onSortChanged(sort, descending); + Navigator.pop(context); } void _handleClear() { @@ -69,10 +70,10 @@ class _SortBottomSheetState extends State { ), child: Row( children: [ - const Expanded( + Expanded( child: Text( - 'Sort By', - style: TextStyle( + t.libraries.sortBy, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), @@ -95,7 +96,7 @@ class _SortBottomSheetState extends State { groupValue: _currentSort, onChanged: (PlexSort? value) { if (value != null) { - _handleSortChange(value, value.defaultDirection == 'desc'); + _handleSortChange(value, value.isDefaultDescending); } }, child: ListView.builder( @@ -139,7 +140,7 @@ class _SortBottomSheetState extends State { onTap: () { _handleSortChange( sort, - sort.defaultDirection == 'desc', + sort.isDefaultDescending, ); }, );