diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..bbc49321 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,101 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: What went wrong? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Tap on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe what should have happened + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what actually happened + validations: + required: true + + - type: input + id: plezy-version + attributes: + label: Plezy Version + description: Check the app settings for the version number and paste it here + placeholder: e.g., 1.7.0 + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + description: Which platform(s) are affected? + multiple: true + options: + - Android + - iOS + - Web + - macOS + - Windows + - Linux + validations: + required: true + + - type: input + id: device + attributes: + label: Device Information + description: What device are you using? + placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS + + - type: textarea + id: logs + attributes: + label: Logs and Stack Traces + description: Please paste any relevant logs or stack traces + render: shell + placeholder: Paste logs here + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem + placeholder: Drag and drop screenshots here + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0c6e9893 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and Discussions + url: https://github.com/edde746/plezy/discussions + about: Ask questions and discuss ideas with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..6931d7dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,65 @@ +name: Feature Request +description: Suggest a new feature or enhancement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! Please provide as much detail as possible. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: I'm frustrated when... / It would be helpful if... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see + placeholder: What feature would solve this problem? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or features? + placeholder: Describe alternatives you've considered + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Describe how you would use this feature + placeholder: | + 1. As a user, I would... + 2. This would help me... + 3. The benefit would be... + + - type: dropdown + id: platform + attributes: + label: Target Platform + description: Which platform(s) should this feature target? + multiple: true + options: + - Android + - iOS + - Web + - macOS + - Windows + - Linux + - All platforms + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, mockups, or screenshots about the feature request + placeholder: Add mockups, links to similar features, or any other helpful context diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..56a3c512 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,136 @@ +name: CI - Sanity Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + analyze: + name: Code Analysis + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Install dependencies + run: | + + flutter pub get + + - name: Verify formatting + run: | + # Find all Dart files excluding generated files + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do + files_found=true + break + done + if [ "$files_found" != "true" ]; then + echo "No Dart files found to format" + exit 0 + fi + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed + - name: Analyze code + run: | + # Run flutter analyze and filter out info-level warnings + # Only fail on errors and warnings, not on info messages + flutter analyze 2>&1 | tee analyze_output.txt + # Check if there are any errors (not just info) + if grep -q "error •" analyze_output.txt; then + echo "❌ Analysis failed with errors" + exit 1 + elif grep -q "warning •" analyze_output.txt; then + echo "⚠️ Analysis completed with warnings" + exit 1 + else + echo "✅ Analysis passed (info messages are allowed)" + exit 0 + fi + + test: + name: Unit Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Install dependencies + run: | + flutter clean + flutter pub get + + - name: Run tests + run: | + if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then + flutter test + else + echo "No tests found, skipping test execution" + fi + + dependency-check: + name: Dependency Validation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Verify dependencies + run: | + flutter clean + flutter pub get + flutter pub outdated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1742ed9d..9ce9e5e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,29 @@ ## Development - Follow Dart/Flutter conventions -- Run `flutter analyze` before submitting +- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks) +- Run `flutter analyze` before submitting to check for issues +- Run `flutter test` if tests are available - Test your changes thoroughly +### Code Quality Checks + +The project includes automated CI checks that run on all pull requests: + +1. **Code Formatting**: Ensures code follows Dart formatting standards + - Run locally: `dart format .` to format all files + - Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`) + - Generated files are reformatted automatically by build tools + +2. **Static Analysis**: Checks for code issues and potential bugs + - Run locally: `flutter analyze` + - Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`) + +3. **Tests**: Runs unit and widget tests (when available) + - Run locally: `flutter test` + +All these checks must pass before your changes can be merged. + ## Internationalization (i18n) This project uses `slang` for internationalization with JSON files. diff --git a/README.md b/README.md index e2092494..b74aed11 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,25 @@ flutter run ## Development +### Code Quality + +Before submitting changes, ensure your code passes all quality checks: + +```bash +# Format code (including generated files) +dart format . + +# Analyze code for issues +flutter analyze + +# Run tests (if available) +flutter test +``` + +**Note**: CI checks exclude generated files (`.g.dart`, `.freezed.dart`) from formatting and analysis checks. You can run `dart format .` locally to format everything, but only your hand-written code will be validated in CI. + +These checks are automatically run in CI for all pull requests. + ### Code Generation The project uses code generation for JSON serialization. After modifying model classes, run: diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b30346..2e3c70e5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,6 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 0b31a98a..1aa617c8 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,348 @@ 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..3420d2cd --- /dev/null +++ b/lib/constants/layout_constants.dart @@ -0,0 +1,97 @@ +/// 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 00a85dd3..a94dcf81 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: 2316 (386 per locale) +/// Strings: 2634 (439 per locale) /// -/// Built on 2025-11-17 at 03:47 UTC +/// Built on 2025-11-18 at 05:02 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -180,6 +180,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); } @@ -242,6 +243,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 @@ -491,6 +495,10 @@ class _StringsVideoControlsEn { String get stretch => 'Stretch'; String get lockRotation => 'Lock rotation'; String get unlockRotation => 'Unlock rotation'; + String get sleepTimer => 'Sleep Timer'; + String get timerActive => 'Timer Active'; + String playbackWillPauseIn({required Object duration}) => 'Playback will pause in ${duration}'; + String get sleepTimerCompleted => 'Sleep timer completed - playback paused'; String get playButton => 'Play'; String get pauseButton => 'Pause'; String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -562,6 +570,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 @@ -694,6 +706,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 @@ -787,6 +808,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); @@ -795,6 +849,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'; @@ -830,6 +885,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. @@ -883,6 +966,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 @@ -944,6 +1028,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 @@ -1193,6 +1280,10 @@ class _StringsVideoControlsDe implements _StringsVideoControlsEn { @override String get stretch => 'Strecken'; @override String get lockRotation => 'Rotation sperren'; @override String get unlockRotation => 'Rotation entsperren'; + @override String get sleepTimer => 'Schlaf-Timer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + @override String get sleepTimerCompleted => 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; @override String get playButton => 'Play'; @override String get pauseButton => 'Pause'; @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -1264,6 +1355,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 @@ -1396,6 +1491,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 @@ -1530,6 +1634,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: @@ -1585,6 +1751,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 @@ -1646,6 +1813,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 @@ -1895,6 +2065,10 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String get stretch => 'Allunga'; @override String get lockRotation => 'Blocca rotazione'; @override String get unlockRotation => 'Sblocca rotazione'; + @override String get sleepTimer => 'Timer di spegnimento'; + @override String get timerActive => 'Timer attivo'; + @override String playbackWillPauseIn({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + @override String get sleepTimerCompleted => 'Timer di spegnimento completato - riproduzione in pausa'; @override String get playButton => 'Play'; @override String get pauseButton => 'Pause'; @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -1966,6 +2140,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 @@ -2098,6 +2276,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 @@ -2232,6 +2419,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: @@ -2287,6 +2536,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 @@ -2348,6 +2598,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 @@ -2597,6 +2850,10 @@ class _StringsVideoControlsNl implements _StringsVideoControlsEn { @override String get stretch => 'Uitrekken'; @override String get lockRotation => 'Vergrendel rotatie'; @override String get unlockRotation => 'Ontgrendel rotatie'; + @override String get sleepTimer => 'Slaaptimer'; + @override String get timerActive => 'Timer actief'; + @override String playbackWillPauseIn({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + @override String get sleepTimerCompleted => 'Slaaptimer voltooid - afspelen gepauzeerd'; @override String get playButton => 'Play'; @override String get pauseButton => 'Pause'; @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -2668,6 +2925,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 @@ -2800,6 +3061,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 @@ -2934,6 +3204,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: @@ -2989,6 +3321,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 @@ -3050,6 +3383,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 @@ -3299,6 +3635,10 @@ class _StringsVideoControlsSv implements _StringsVideoControlsEn { @override String get stretch => 'Sträck'; @override String get lockRotation => 'Lås rotation'; @override String get unlockRotation => 'Lås upp rotation'; + @override String get sleepTimer => 'Sovtimer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + @override String get sleepTimerCompleted => 'Sovtimer slutförd - uppspelning pausad'; @override String get playButton => 'Play'; @override String get pauseButton => 'Pause'; @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -3370,6 +3710,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 @@ -3502,6 +3846,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 @@ -3636,6 +3989,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: @@ -3691,6 +4106,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 @@ -3752,6 +4168,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 @@ -4001,6 +4420,10 @@ class _StringsVideoControlsZh implements _StringsVideoControlsEn { @override String get stretch => '拉伸'; @override String get lockRotation => '锁定旋转'; @override String get unlockRotation => '解锁旋转'; + @override String get sleepTimer => '睡眠定时器'; + @override String get timerActive => '定时器已激活'; + @override String playbackWillPauseIn({required Object duration}) => '播放将在 ${duration} 后暂停'; + @override String get sleepTimerCompleted => '睡眠定时器已完成 - 播放已暂停'; @override String get playButton => 'Play'; @override String get pauseButton => 'Pause'; @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -4072,6 +4495,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 @@ -4204,6 +4631,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 @@ -4338,6 +4774,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. @@ -4380,6 +4878,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'; @@ -4530,6 +5031,10 @@ extension on Translations { case 'videoControls.stretch': return 'Stretch'; case 'videoControls.lockRotation': return 'Lock rotation'; case 'videoControls.unlockRotation': return 'Unlock rotation'; + case 'videoControls.sleepTimer': return 'Sleep Timer'; + case 'videoControls.timerActive': return 'Timer Active'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Playback will pause in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sleep timer completed - playback paused'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -4583,6 +5088,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'; @@ -4661,6 +5170,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}'; @@ -4698,7 +5224,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'; @@ -4774,6 +5325,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'; @@ -4924,6 +5478,10 @@ extension on _StringsDe { case 'videoControls.stretch': return 'Strecken'; case 'videoControls.lockRotation': return 'Rotation sperren'; case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'videoControls.sleepTimer': return 'Schlaf-Timer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -4977,6 +5535,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'; @@ -5055,6 +5617,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}'; @@ -5126,6 +5705,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; } } @@ -5168,6 +5772,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'; @@ -5318,6 +5925,10 @@ extension on _StringsIt { case 'videoControls.stretch': return 'Allunga'; case 'videoControls.lockRotation': return 'Blocca rotazione'; case 'videoControls.unlockRotation': return 'Sblocca rotazione'; + case 'videoControls.sleepTimer': return 'Timer di spegnimento'; + case 'videoControls.timerActive': return 'Timer attivo'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Timer di spegnimento completato - riproduzione in pausa'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -5371,6 +5982,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'; @@ -5449,6 +6064,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}'; @@ -5520,6 +6152,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; } } @@ -5562,6 +6219,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'; @@ -5712,6 +6372,10 @@ extension on _StringsNl { case 'videoControls.stretch': return 'Uitrekken'; case 'videoControls.lockRotation': return 'Vergrendel rotatie'; case 'videoControls.unlockRotation': return 'Ontgrendel rotatie'; + case 'videoControls.sleepTimer': return 'Slaaptimer'; + case 'videoControls.timerActive': return 'Timer actief'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Slaaptimer voltooid - afspelen gepauzeerd'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -5765,6 +6429,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'; @@ -5843,6 +6511,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}'; @@ -5914,6 +6599,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; } } @@ -5956,6 +6666,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'; @@ -6106,6 +6819,10 @@ extension on _StringsSv { case 'videoControls.stretch': return 'Sträck'; case 'videoControls.lockRotation': return 'Lås rotation'; case 'videoControls.unlockRotation': return 'Lås upp rotation'; + case 'videoControls.sleepTimer': return 'Sovtimer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sovtimer slutförd - uppspelning pausad'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -6159,6 +6876,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'; @@ -6237,6 +6958,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}'; @@ -6308,6 +7046,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; } } @@ -6350,6 +7113,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 '切换配置文件'; @@ -6500,6 +7266,10 @@ extension on _StringsZh { case 'videoControls.stretch': return '拉伸'; case 'videoControls.lockRotation': return '锁定旋转'; case 'videoControls.unlockRotation': return '解锁旋转'; + case 'videoControls.sleepTimer': return '睡眠定时器'; + case 'videoControls.timerActive': return '定时器已激活'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => '播放将在 ${duration} 后暂停'; + case 'videoControls.sleepTimerCompleted': return '睡眠定时器已完成 - 播放已暂停'; case 'videoControls.playButton': return 'Play'; case 'videoControls.pauseButton': return 'Pause'; case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; @@ -6553,6 +7323,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 '字号'; @@ -6631,6 +7405,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}'; @@ -6702,6 +7493,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 2235af6a..6f5cd309 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", @@ -210,6 +213,10 @@ "stretch": "Stretch", "lockRotation": "Lock rotation", "unlockRotation": "Unlock rotation", + "sleepTimer": "Sleep Timer", + "timerActive": "Timer Active", + "playbackWillPauseIn": "Playback will pause in ${duration}", + "sleepTimerCompleted": "Sleep timer completed - playback paused", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,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" @@ -356,7 +367,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", @@ -407,8 +439,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 f041b5d6..b9a01c52 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", @@ -210,6 +213,10 @@ "stretch": "Strecken", "lockRotation": "Rotation sperren", "unlockRotation": "Rotation entsperren", + "sleepTimer": "Schlaf-Timer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Wiedergabe wird pausiert in ${duration}", + "sleepTimerCompleted": "Schlaf-Timer abgelaufen - Wiedergabe pausiert", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,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" @@ -356,7 +367,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", @@ -441,6 +473,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 2ed401cb..ca3bc4b8 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", @@ -210,6 +213,10 @@ "stretch": "Allunga", "lockRotation": "Blocca rotazione", "unlockRotation": "Sblocca rotazione", + "sleepTimer": "Timer di spegnimento", + "timerActive": "Timer attivo", + "playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}", + "sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,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" @@ -356,7 +367,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", @@ -441,6 +473,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 0437b044..72b64cbe 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", @@ -210,6 +213,10 @@ "stretch": "Uitrekken", "lockRotation": "Vergrendel rotatie", "unlockRotation": "Ontgrendel rotatie", + "sleepTimer": "Slaaptimer", + "timerActive": "Timer actief", + "playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}", + "sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,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" @@ -356,7 +367,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", @@ -441,6 +473,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 6d5efa5c..e5571cfe 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", @@ -210,6 +213,10 @@ "stretch": "Sträck", "lockRotation": "Lås rotation", "unlockRotation": "Lås upp rotation", + "sleepTimer": "Sovtimer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Uppspelningen pausas om ${duration}", + "sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,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" @@ -356,7 +367,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", @@ -441,6 +473,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 c5e6e865..d2414996 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": "许可证", @@ -210,6 +213,10 @@ "stretch": "拉伸", "lockRotation": "锁定旋转", "unlockRotation": "解锁旋转", + "sleepTimer": "睡眠定时器", + "timerActive": "定时器已激活", + "playbackWillPauseIn": "播放将在 ${duration} 后暂停", + "sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停", "playButton": "Play", "pauseButton": "Pause", "seekBackwardButton": "Seek backward ${seconds} seconds", @@ -266,7 +273,11 @@ "noEpisodesFoundGeneral": "未找到剧集", "noResultsFound": "未找到结果", "sleepTimerSet": "睡眠定时器已设置为 ${label}", - "failedToSwitchProfile": "无法切换到 ${displayName}" + "failedToSwitchProfile": "无法切换到 ${displayName}", + "noItemsAvailable": "没有可用的项目", + "failedToCreatePlayQueue": "创建播放队列失败", + "failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目", + "failedPlayback": "无法${action}: ${error}" }, "profile": { "noUsersAvailable": "没有可用用户" @@ -356,7 +367,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": "关于", @@ -441,6 +473,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/mixins/library_tab_state.dart b/lib/mixins/library_tab_state.dart new file mode 100644 index 00000000..426e1486 --- /dev/null +++ b/lib/mixins/library_tab_state.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; + +/// Mixin providing common state management for library tab screens +/// Standardizes loading, error handling, and lifecycle management +mixin LibraryTabStateMixin on State { + /// The list of items to display + List get items; + set items(List value); + + /// Whether data is currently loading + bool get isLoading; + set isLoading(bool value); + + /// Error message if loading failed + String? get errorMessage; + set errorMessage(String? value); + + /// The library being displayed + PlexLibrary get library; + + /// Load or reload the content + Future loadContent(); + + /// Common lifecycle: reload if library changed + @mustCallSuper + void didUpdateLibrary(PlexLibrary oldLibrary) { + if (oldLibrary.key != library.key) { + loadContent(); + } + } + + /// Helper to set loading state + void setLoadingState(bool loading) { + if (mounted) { + setState(() { + isLoading = loading; + }); + } + } + + /// Helper to set error state + void setErrorState(String? error) { + if (mounted) { + setState(() { + errorMessage = error; + isLoading = false; + }); + } + } + + /// Helper to set success state with items + void setSuccessState(List newItems) { + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + errorMessage = null; + }); + } + } +} diff --git a/lib/models/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_hub.dart b/lib/models/plex_hub.dart index e568718f..27fe3c86 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -23,9 +23,10 @@ class PlexHub { factory PlexHub.fromJson(Map json) { final metadataList = []; - // Hubs can contain either Metadata or Directory entries - if (json['Metadata'] != null) { - for (final item in json['Metadata'] as List) { + // Helper function to parse entries from a JSON list + void parseEntries(List? entries) { + if (entries == null) return; + for (final item in entries) { try { metadataList.add(PlexMetadata.fromJson(item)); } catch (e) { @@ -34,15 +35,9 @@ class PlexHub { } } - if (json['Directory'] != null) { - for (final item in json['Directory'] as List) { - try { - metadataList.add(PlexMetadata.fromJson(item)); - } catch (e) { - // Skip items that fail to parse - } - } - } + // Hubs can contain either Metadata or Directory entries + parseEntries(json['Metadata'] as List?); + parseEntries(json['Directory'] as List?); return PlexHub( hubKey: json['key'] as String? ?? '', diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index a6c2bfa7..6e05cf73 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -12,13 +12,40 @@ class PlexMediaInfo { }); } -class PlexAudioTrack { +/// Mixin for building track labels with a consistent pattern +mixin TrackLabelBuilder { + int get id; + int? get index; + String? get displayTitle; + String? get language; + + /// Builds a label from the given parts + /// If displayTitle is present, returns it + /// Otherwise, combines language and additional parts + String buildLabel(List additionalParts) { + if (displayTitle != null && displayTitle!.isNotEmpty) { + return displayTitle!; + } + final parts = []; + if (language != null && language!.isNotEmpty) { + parts.add(language!); + } + parts.addAll(additionalParts); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexAudioTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final int? channels; final bool selected; @@ -36,22 +63,24 @@ class PlexAudioTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (codec != null) parts.add(codec!.toUpperCase()); - if (channels != null) parts.add('${channels!}ch'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (codec != null) additionalParts.add(codec!.toUpperCase()); + if (channels != null) additionalParts.add('${channels!}ch'); + return buildLabel(additionalParts); } } -class PlexSubtitleTrack { +class PlexSubtitleTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final bool selected; final bool forced; @@ -71,11 +100,9 @@ class PlexSubtitleTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (forced) parts.add('Forced'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (forced) additionalParts.add('Forced'); + return buildLabel(additionalParts); } /// Returns true if this subtitle track is an external file (sidecar subtitle) diff --git a/lib/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/models/plex_playlist.dart b/lib/models/plex_playlist.dart index d47944f7..7799c3ff 100644 --- a/lib/models/plex_playlist.dart +++ b/lib/models/plex_playlist.dart @@ -45,17 +45,6 @@ class PlexPlaylist { /// Helper to get display image (composite or thumb) String? get displayImage => composite ?? thumb; - /// Helper to get formatted duration - String? get formattedDuration { - if (duration == null) return null; - final hours = duration! ~/ 3600000; - final minutes = (duration! % 3600000) ~/ 60000; - if (hours > 0) { - return '${hours}h ${minutes}m'; - } - return '${minutes}m'; - } - /// Helper to determine if playlist is editable bool get isEditable => !smart; diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart index e0ecd011..5a4c621c 100644 --- a/lib/network/endpoint_failover_interceptor.dart +++ b/lib/network/endpoint_failover_interceptor.dart @@ -138,11 +138,6 @@ class EndpointFailoverInterceptor extends Interceptor { return true; } - if (error.type == DioExceptionType.badResponse) { - final statusCode = error.response?.statusCode ?? 0; - return statusCode >= 500; - } - return false; } diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index b994af6c..9f8877db 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,128 +1,324 @@ 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. +/// Result of trying to locate the current queue index. +class _IndexLookupResult { + final int? index; + final bool attemptedLoad; + final bool loadFailed; + + const _IndexLookupResult({ + this.index, + this.attemptedLoad = false, + this.loadFailed = false, + }); +} + +/// 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; + } + + Future<_IndexLookupResult> _getCurrentIndex({ + bool loadIfMissing = false, + }) async { + if (_playbackMode != PlaybackMode.playQueue || + _loadedItems.isEmpty || + _currentPlayQueueItemID == null) { + return const _IndexLookupResult(); + } + + var currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex != -1) { + return _IndexLookupResult(index: currentIndex); + } + + if (!loadIfMissing || _client == null || _playQueueId == null) { + return const _IndexLookupResult(); + } + + final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); + if (!loaded) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex == -1) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + return _IndexLookupResult(index: currentIndex, attemptedLoad: true); + } + /// 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, - ); + final indexResult = await _getCurrentIndex(loadIfMissing: true); + if (indexResult.index == null) { + if (indexResult.loadFailed) { + clearShuffle(); + } + return null; + } + final currentIndex = indexResult.index!; - if (currentIndex == -1) { - // Current item not in queue, clear queue + // 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; - - // Find current item in queue - final currentIndex = _queue.indexWhere( - (item) => item.ratingKey == currentItemKey, - ); - - if (currentIndex == -1) { - // Current item not in queue + Future getPreviousEpisode(String currentItemKey) async { + if (_playbackMode != PlaybackMode.playQueue) { + // For sequential mode, let the video player handle previous episode return null; } - // Check if there's a previous item - if (currentIndex <= 0) { - // At the beginning of queue + final currentIndex = (await _getCurrentIndex()).index; + if (currentIndex == null) return null; + + // 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/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart new file mode 100644 index 00000000..7ae55205 --- /dev/null +++ b/lib/screens/base_media_list_detail_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/collection_playlist_play_helper.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; + +/// Abstract base class for screens displaying media lists (collections/playlists) +/// Provides common state management and playback functionality +abstract class BaseMediaListDetailScreen + extends State + with Refreshable, ItemUpdatable { + // State properties - concrete implementations to avoid duplication + List items = []; + bool isLoading = false; + String? errorMessage; + + @override + PlexClient get client => context.clientSafe; + + /// The media item being displayed (collection or playlist) + dynamic get mediaItem; + + /// Title to display in app bar + String get title; + + /// Message to show when list is empty + String get emptyMessage; + + @override + void initState() { + super.initState(); + loadItems(); + } + + /// Load or reload the items (subclasses implement this) + Future loadItems(); + + /// Play all items in the list + Future playItems() => _playWithShuffle(false); + + /// Shuffle play all items in the list + Future shufflePlayItems() => _playWithShuffle(true); + + /// Internal helper to play items with optional shuffle + Future _playWithShuffle(bool shuffle) async { + if (items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(emptyMessage))); + } + return; + } + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: mediaItem, + shuffle: shuffle, + ); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + if (mounted) { + setState(() { + final index = items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + items[index] = updatedMetadata; + } + }); + } + } + + @override + void refresh() { + loadItems(); + } +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart new file mode 100644 index 00000000..5cd38b3d --- /dev/null +++ b/lib/screens/collection_detail_screen.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../utils/app_logger.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../i18n/strings.g.dart'; +import '../utils/grid_size_calculator.dart'; +import '../utils/dialogs.dart'; +import '../utils/provider_extensions.dart'; +import 'base_media_list_detail_screen.dart'; + +/// Screen to display the contents of a collection +class CollectionDetailScreen extends StatefulWidget { + final PlexMetadata collection; + + const CollectionDetailScreen({super.key, required this.collection}); + + @override + State createState() => _CollectionDetailScreenState(); +} + +class _CollectionDetailScreenState + extends BaseMediaListDetailScreen { + @override + PlexMetadata get mediaItem => widget.collection; + + @override + String get title => widget.collection.title; + + @override + String get emptyMessage => t.collections.empty; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } + + try { + final client = this.client; + final newItems = await client.getCollectionItems( + widget.collection.ratingKey, + ); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); + } + + appLogger.d( + 'Loaded ${newItems.length} items for collection: ${widget.collection.title}', + ); + } catch (e) { + appLogger.e('Failed to load collection items', error: e); + if (mounted) { + setState(() { + errorMessage = t.collections.failedToLoadItems(error: e.toString()); + isLoading = false; + }); + } + } + } + + 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 showDeleteConfirmation( + context, + title: t.collections.deleteCollection, + message: t.collections.deleteConfirm(title: widget.collection.title), + ); + + if (confirmed != true) return; + if (!mounted) return; + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final success = await client.deleteCollection( + sectionId.toString(), + widget.collection.ratingKey, + ); + + if (!mounted) return; + + 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: playItems, + ), + // Shuffle button + if (items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.common.shuffle, + onPressed: shufflePlayItems, + ), + // 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: loadItems, + 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: loadItems, + ); + }, childCount: items.length), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 348196e7..4e0789f9 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/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 5e3fd5a4..e36ac821 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -5,9 +5,9 @@ import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/sort_bottom_sheet.dart'; @@ -293,9 +293,10 @@ class _HubDetailScreenState extends State with Refreshable { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, context.watch().libraryDensity, + 16, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, @@ -313,49 +314,4 @@ class _HubDetailScreenState extends State with Refreshable { ), ); } - - 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; - } - } } diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index ff02bde8..513d3c17 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -4,24 +4,22 @@ import 'package:dio/dio.dart'; import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; -import '../providers/plex_client_provider.dart'; -import '../providers/settings_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../widgets/app_bar_back_button.dart'; import '../widgets/context_menu_wrapper.dart'; import '../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 '../utils/error_message_utils.dart'; +import 'library_tabs/library_browse_tab.dart'; +import 'library_tabs/library_recommended_tab.dart'; +import 'library_tabs/library_collections_tab.dart'; +import 'library_tabs/library_playlists_tab.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -31,24 +29,28 @@ 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; + Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; - bool _isInitialLoad = true; - - // Pagination state + List _items = []; int _currentPage = 0; bool _hasMoreItems = true; CancelToken? _cancelToken; @@ -58,39 +60,47 @@ 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(); + } + + void _updateState(VoidCallback fn) { + if (!mounted) return; + setState(fn); + } + /// Helper method to get user-friendly error message from exception String _getErrorMessage(dynamic error, String context) { if (error is DioException) { - // Other Dio errors - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: - return t.errors.connectionTimeout(context: context); - case DioExceptionType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading $context', error: error); - return t.errors.failedToLoad( - context: context, - error: error.message ?? 'Unknown error', - ); - } + return mapDioErrorToMessage(error, context: context); } - // Generic error - appLogger.e('Unexpected error in $context', error: error); - return t.errors.failedToLoad(context: context, error: error.toString()); + return mapUnexpectedErrorToMessage(error, context: context); } Future _loadLibraries() async { // Extract context dependencies before async gap - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final hiddenLibrariesProvider = Provider.of( context, listen: false, @@ -123,7 +133,7 @@ class _LibrariesScreenState extends State savedOrder, ); - setState(() { + _updateState(() { _allLibraries = orderedLibraries; // Store all libraries with ordering applied _isLoadingLibraries = false; @@ -138,7 +148,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,17 +166,18 @@ class _LibrariesScreenState extends State libraryKeyToLoad = visibleLibraries.first.key; } - // Restore filters BEFORE loading content - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } - - if (libraryKeyToLoad != null) { + if (libraryKeyToLoad != null && mounted) { + final savedFilters = storage.getLibraryFilters( + sectionId: libraryKeyToLoad, + ); + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } _loadLibraryContent(libraryKeyToLoad); } } } catch (e) { - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'libraries'); _isLoadingLibraries = false; }); @@ -237,16 +247,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - setState(() { + _updateState(() { _errorMessage = t.errors.noClientAvailable; - _isLoadingItems = false; }); return; } - setState(() { + _updateState(() { _selectedLibraryKey = libraryKey; - _isLoadingItems = true; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) if (isChangingLibrary) { @@ -259,13 +267,21 @@ 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) { + _updateState(() { + _tabController.index = savedTabIndex; + }); + } + // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters({}); + await storage.saveLibraryFilters({}, sectionId: libraryKey); } // Cancel any existing requests @@ -274,24 +290,17 @@ class _LibrariesScreenState extends State final currentRequestId = ++_requestId; // Reset pagination state - setState(() { + _updateState(() { _currentPage = 0; _hasMoreItems = true; _items = []; }); try { - // Load filters and sort options for the new library - _loadFilters(libraryKey); + // Load sort options for the new library await _loadSortOptions(libraryKey); - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } + final filtersWithSort = _buildFiltersWithSort(); // Load pages sequentially await _loadAllPagesSequentially( @@ -306,9 +315,8 @@ class _LibrariesScreenState extends State return; } - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'library content'); - _isLoadingItems = false; }); } } @@ -335,15 +343,10 @@ class _LibrariesScreenState extends State return; // Request was superseded } - setState(() { + _updateState(() { _items.addAll(items); _currentPage++; _hasMoreItems = items.length >= _pageSize; - - // Mark as not loading if this is the last page - if (!_hasMoreItems) { - _isLoadingItems = false; - } }); } catch (e) { // Check if it's a cancellation @@ -352,8 +355,7 @@ class _LibrariesScreenState extends State } // For other errors, update state and rethrow - setState(() { - _isLoadingItems = false; + _updateState(() { _hasMoreItems = false; }); rethrow; @@ -361,35 +363,9 @@ class _LibrariesScreenState extends State } } - Future _loadFilters(String libraryKey) async { - try { - final clientProvider = Provider.of( - context, - listen: false, - ); - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - final filters = await client.getLibraryFilters(libraryKey); - setState(() { - _filters = filters; - }); - } catch (e) { - appLogger.w('Failed to load filters', error: e); - setState(() { - _filters = []; - }); - } - } - Future _loadSortOptions(String libraryKey) async { try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -399,105 +375,47 @@ 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(() { - _sortOptions = sortOptions; + _updateState(() { _selectedSort = savedSort; _isSortDescending = descending; }); } catch (e) { - setState(() { - _sortOptions = []; + _updateState(() { _selectedSort = null; _isSortDescending = false; }); } } - Future _applyFilters() async { - // Cancel any existing requests - _cancelToken?.cancel(); - _cancelToken = CancelToken(); - final currentRequestId = ++_requestId; - - setState(() { - _isLoadingItems = true; - _errorMessage = null; - _currentPage = 0; - _hasMoreItems = true; - _items = []; - }); - - try { - final clientProvider = Provider.of( - context, - listen: false, + Map _buildFiltersWithSort() { + final filtersWithSort = Map.from(_selectedFilters); + if (_selectedSort != null) { + filtersWithSort['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, ); - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } - - // Load pages sequentially - await _loadAllPagesSequentially( - _selectedLibraryKey!, - filtersWithSort, - currentRequestId, - client, - ); - } catch (e) { - // Ignore cancellation errors - if (e is DioException && e.type == DioExceptionType.cancel) { - return; - } - - setState(() { - _errorMessage = t.messages.errorLoading(error: e.toString()); - _isLoadingItems = false; - }); } - } - - Future _applySort(PlexSort sort, bool descending) async { - setState(() { - _selectedSort = sort; - _isSortDescending = descending; - }); - - // Save sort preference for this library - final storage = await StorageService.getInstance(); - final sortKey = sort.getSortKey(descending: descending); - await storage.saveLibrarySort(_selectedLibraryKey!, sortKey); - - // Reload content with new sort - _applyFilters(); + return filtersWithSort; } @override @@ -514,6 +432,36 @@ class _LibrariesScreenState extends State _loadLibraries(); } + // Refresh the currently active tab + void _refreshCurrentTab() { + switch (_tabController.index) { + case 0: // Recommended tab + final refreshable = _recommendedTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); + } + break; + case 1: // Browse tab + final refreshable = _browseTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); + } + break; + case 2: // Collections tab + final refreshable = _collectionsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); + } + break; + case 3: // Playlists tab + final refreshable = _playlistsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable 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 +506,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( @@ -686,7 +595,13 @@ class _LibrariesScreenState extends State ); } - Future _scanLibrary(PlexLibrary library) async { + Future _performLibraryAction({ + required PlexLibrary library, + required Future Function(PlexClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, + }) async { try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -694,32 +609,31 @@ class _LibrariesScreenState extends State throw Exception(t.errors.noClientAvailable); } - // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanning(title: library.title)), + content: Text(progressMessage), duration: const Duration(seconds: 2), ), ); } - await client.scanLibrary(library.key); + await action(client); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanStarted(title: library.title)), + content: Text(successMessage), duration: const Duration(seconds: 3), ), ); } } catch (e) { - appLogger.e('Failed to scan library', error: e); + appLogger.e('Library action failed', error: e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanFailed(error: e.toString())), + content: Text(failureMessage(e)), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -728,134 +642,128 @@ class _LibrariesScreenState extends State } } + Future _scanLibrary(PlexLibrary library) async { + return _performLibraryAction( + library: library, + action: (client) => client.scanLibrary(library.key), + progressMessage: t.messages.libraryScanning(title: library.title), + successMessage: t.messages.libraryScanStarted(title: library.title), + failureMessage: (error) => + t.messages.libraryScanFailed(error: error.toString()), + ); + } + Future _refreshLibraryMetadata(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.metadataRefreshing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.refreshLibraryMetadata(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshStarted(title: library.title), - ), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to refresh library metadata', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshFailed(error: e.toString()), - ), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.refreshLibraryMetadata(library.key), + progressMessage: t.messages.metadataRefreshing(title: library.title), + successMessage: t.messages.metadataRefreshStarted(title: library.title), + failureMessage: (error) => + t.messages.metadataRefreshFailed(error: error.toString()), + ); } Future _emptyLibraryTrash(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.emptyingTrash(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.emptyLibraryTrash(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.trashEmptied(title: library.title)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to empty library trash', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToEmptyTrash(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.emptyLibraryTrash(library.key), + progressMessage: t.libraries.emptyingTrash(title: library.title), + successMessage: t.libraries.trashEmptied(title: library.title), + failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), + ); } Future _analyzeLibrary(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } + return _performLibraryAction( + library: library, + action: (client) => client.analyzeLibrary(library.key), + progressMessage: t.libraries.analyzing(title: library.title), + successMessage: t.libraries.analysisStarted(title: library.title), + failureMessage: (error) => t.libraries.failedToAnalyze(error: error), + ); + } - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analyzing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } + Widget _buildTabChip(String label, int index) { + final isSelected = _tabController.index == index; + final t = tokens(context); - await client.analyzeLibrary(library.key); + 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, + ); + } - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analysisStarted(title: library.title)), - duration: const Duration(seconds: 3), + 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, ), - ); - } - } catch (e) { - appLogger.e('Failed to analyze library', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToAnalyze(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 24), + ], + ), + ); } @override @@ -873,7 +781,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 +799,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 +849,67 @@ 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 +927,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..c1de6bd8 --- /dev/null +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -0,0 +1,590 @@ +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/error_message_utils.dart'; +import '../../utils/grid_size_calculator.dart'; +import '../../widgets/media_card.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 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) { + return mapDioErrorToMessage(error, context: t.libraries.content); + } + return mapUnexpectedErrorToMessage(error, context: t.libraries.content); + } + + void _showGroupingBottomSheet() { + showModalBottomSheet( + context: context, + builder: (context) { + return ListView( + shrinkWrap: true, + children: _getGroupingOptions().map((grouping) { + return RadioListTile( + title: Text(_getGroupingLabel(grouping)), + value: grouping, + // ignore: deprecated_member_use + groupValue: _selectedGrouping, + // ignore: deprecated_member_use + onChanged: (value) async { + if (value != null) { + setState(() { + _selectedGrouping = value; + }); + + final storage = await StorageService.getInstance(); + await storage.saveLibraryGrouping(widget.library.key, value); + + if (!mounted) return; + + 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 && _selectedGrouping != 'folders') + _buildFilterChip( + icon: Icons.filter_alt, + label: _selectedFilters.isEmpty + ? t.libraries.filters + : t.libraries.filtersWithCount( + count: _selectedFilters.length, + ), + onPressed: _showFiltersBottomSheet, + ), + if (_filters.isNotEmpty && _selectedGrouping != 'folders') + const SizedBox(width: 8), + // Sort chip + if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders') + _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..681b8231 --- /dev/null +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -0,0 +1,130 @@ +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 '../../utils/app_logger.dart'; +import '../../utils/library_refresh_notifier.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; +import '../../widgets/adaptive_media_grid.dart'; + +/// Collections tab for library screen +/// Shows collections for the current library +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 + + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _collections, + emptyIcon: Icons.collections, + emptyMessage: t.libraries.noCollections, + onRetry: _loadCollections, + builder: (items) => RefreshIndicator( + onRefresh: _loadCollections, + child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections), + ), + ); + } +} 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..84f6b1be --- /dev/null +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -0,0 +1,172 @@ +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 ViewMode; +import '../../utils/grid_size_calculator.dart'; +import '../../widgets/media_card.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; + +/// Playlists tab for library screen +/// Shows playlists that contain items from the current library +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 + + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _playlists, + emptyIcon: Icons.playlist_play, + emptyMessage: t.playlists.noPlaylists, + onRetry: _loadPlaylists, + builder: (items) => RefreshIndicator( + onRefresh: _loadPlaylists, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } + }, + ), + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart new file mode 100644 index 00000000..89957997 --- /dev/null +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -0,0 +1,130 @@ +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'; +import '../../widgets/content_state_builder.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 + + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _hubs, + emptyIcon: Icons.recommend, + emptyMessage: t.libraries.noRecommendations, + onRetry: _loadHubs, + builder: (items) => RefreshIndicator( + onRefresh: _loadHubs, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + return HubSection(hub: hub, icon: _getHubIcon(hub)); + }, + ), + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0b1afff6..f81db457 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/plex_client_provider.dart'; import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_formatter.dart'; import '../utils/provider_extensions.dart'; import '../utils/shuffle_play_helper.dart'; import '../utils/video_player_navigation.dart'; @@ -489,7 +490,7 @@ class _MediaDetailScreenState extends State { borderRadius: BorderRadius.circular(6), ), child: Text( - _formatDuration(metadata.duration!), + formatDurationTextual(metadata.duration!), style: const TextStyle( color: Colors.white, fontSize: 13, @@ -953,7 +954,7 @@ class _MediaDetailScreenState extends State { return Card( clipBehavior: Clip.antiAlias, child: MediaContextMenu( - metadata: season, + item: season, onRefresh: (ratingKey) { _watchStateChanged = true; _updateWatchState(); @@ -1122,18 +1123,6 @@ class _MediaDetailScreenState extends State { ); } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - String _getPlayButtonLabel(PlexMetadata metadata) { // For TV shows if (metadata.type.toLowerCase() == 'show') { diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index bcd25c25..c761030f 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -1,20 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; -import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/grid_size_calculator.dart'; import '../widgets/media_card.dart'; import '../widgets/playlist_item_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; +import '../utils/dialogs.dart'; +import 'base_media_list_detail_screen.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -26,71 +24,56 @@ class PlaylistDetailScreen extends StatefulWidget { State createState() => _PlaylistDetailScreenState(); } -class _PlaylistDetailScreenState extends State - with Refreshable, ItemUpdatable { +class _PlaylistDetailScreenState + extends BaseMediaListDetailScreen { @override - PlexClient get client => context.clientSafe; - - List _items = []; - bool _isLoading = false; - String? _errorMessage; + dynamic get mediaItem => widget.playlist; @override - void initState() { - super.initState(); - _loadPlaylistItems(); - } + String get title => widget.playlist.title; - Future _loadPlaylistItems() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + @override + String get emptyMessage => t.playlists.emptyPlaylist; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); + final client = this.client; + final newItems = await client.getPlaylist(widget.playlist.ratingKey); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); } - final items = await client.getPlaylist(widget.playlist.ratingKey); - - setState(() { - _items = items; - _isLoading = false; - }); - appLogger.d( - 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + 'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}', ); } catch (e) { appLogger.e('Failed to load playlist items', error: e); - setState(() { - _errorMessage = 'Failed to load playlist items: ${e.toString()}'; - _isLoading = false; - }); + if (mounted) { + setState(() { + errorMessage = 'Failed to load playlist items: ${e.toString()}'; + isLoading = false; + }); + } } } Future _deletePlaylist() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.playlists.deleteConfirm, + message: t.playlists.deleteMessage(name: widget.playlist.title), ); if (confirmed == true && mounted) { @@ -120,7 +103,7 @@ class _PlaylistDetailScreenState extends State // Can't reorder if indices are the same if (oldIndex == newIndex) return; - final movedItem = _items[oldIndex]; + final movedItem = items[oldIndex]; // Check if item has playlistItemID (required for reordering) if (movedItem.playlistItemID == null) { @@ -140,7 +123,7 @@ class _PlaylistDetailScreenState extends State if (newIndex == 0) { afterPlaylistItemId = 0; // Move to top } else { - final afterItem = _items[newIndex - 1]; + final afterItem = items[newIndex - 1]; if (afterItem.playlistItemID == null) { appLogger.e('Cannot reorder: after item missing playlistItemID'); if (mounted) { @@ -159,8 +142,8 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - final item = _items.removeAt(oldIndex); - _items.insert(newIndex, item); + final item = items.removeAt(oldIndex); + items.insert(newIndex, item); }); // Call API to persist the change @@ -175,8 +158,8 @@ class _PlaylistDetailScreenState extends State appLogger.e('Failed to reorder playlist item, reverting UI'); if (mounted) { setState(() { - final item = _items.removeAt(newIndex); - _items.insert(oldIndex, item); + final item = items.removeAt(newIndex); + items.insert(oldIndex, item); }); ScaffoldMessenger.of( @@ -187,7 +170,7 @@ class _PlaylistDetailScreenState extends State } Future _removeItem(int index) async { - final item = _items[index]; + final item = items[index]; // Check if item has playlistItemID (required for removal) if (item.playlistItemID == null) { @@ -206,7 +189,7 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - _items.removeAt(index); + items.removeAt(index); }); // Call API to persist the change @@ -224,7 +207,7 @@ class _PlaylistDetailScreenState extends State // Revert on failure appLogger.e('Failed to remove playlist item, reverting UI'); setState(() { - _items.insert(index, item); + items.insert(index, item); }); ScaffoldMessenger.of( @@ -234,75 +217,62 @@ class _PlaylistDetailScreenState extends State } } - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } - } - - @override - void refresh() { - _loadPlaylistItems(); - } - - Future _playPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final playbackState = context.read(); - - // 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); - } - } - - Future _shufflePlayPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final playbackState = context.read(); - - // 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); - } - } - Future _playFromItem(int index) async { - if (_items.isEmpty || index < 0 || index >= _items.length) return; + 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; + } + + if (!mounted) 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(), + ), + ), + ), + ); + } } } @@ -344,18 +314,18 @@ class _PlaylistDetailScreenState extends State pinned: true, actions: [ // Play button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.play_arrow), tooltip: t.discover.play, - onPressed: _playPlaylist, + onPressed: playItems, ), // Shuffle button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.shuffle), tooltip: t.playlists.shuffle, - onPressed: _shufflePlayPlaylist, + onPressed: shufflePlayItems, ), // Delete button for non-smart playlists if (!widget.playlist.smart) @@ -367,7 +337,7 @@ class _PlaylistDetailScreenState extends State ), ], ), - if (_errorMessage != null) + if (errorMessage != null) SliverFillRemaining( child: Center( child: Column( @@ -379,21 +349,21 @@ class _PlaylistDetailScreenState extends State color: Colors.red, ), const SizedBox(height: 16), - Text(_errorMessage!), + Text(errorMessage!), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadPlaylistItems, + onPressed: loadItems, child: Text(t.common.retry), ), ], ), ), ) - else if (_items.isEmpty && _isLoading) + else if (items.isEmpty && isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_items.isEmpty) + else if (items.isEmpty) SliverFillRemaining( child: Center( child: Column( @@ -419,7 +389,7 @@ class _PlaylistDetailScreenState extends State padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, context.watch().libraryDensity, ), @@ -428,15 +398,15 @@ class _PlaylistDetailScreenState extends State mainAxisSpacing: 0, ), delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard(item: _items[index], onRefresh: updateItem); - }, childCount: _items.length), + return MediaCard(item: items[index], onRefresh: updateItem); + }, childCount: items.length), ), ) else // Regular playlists: Use reorderable list view SliverReorderableList( itemBuilder: (context, index) { - final item = _items[index]; + final item = items[index]; return PlaylistItemCard( key: ValueKey(item.playlistItemID ?? item.ratingKey), item: item, @@ -446,75 +416,11 @@ class _PlaylistDetailScreenState extends State canReorder: !widget.playlist.smart, ); }, - itemCount: _items.length, + itemCount: items.length, onReorder: _onReorder, ), ], ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart deleted file mode 100644 index 16166ef8..00000000 --- a/lib/screens/playlists_screen.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; -import '../models/plex_playlist.dart'; -import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; -import '../utils/app_logger.dart'; -import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../i18n/strings.g.dart'; -import 'playlist_detail_screen.dart'; - -/// Screen to display all video playlists -class PlaylistsScreen extends StatefulWidget { - const PlaylistsScreen({super.key}); - - @override - State createState() => _PlaylistsScreenState(); -} - -class _PlaylistsScreenState extends State with Refreshable { - PlexClient get client => context.clientSafe; - - List _playlists = []; - bool _isLoading = false; - String? _errorMessage; - bool? _filterSmart; - - @override - void initState() { - super.initState(); - _loadPlaylists(); - } - - Future _loadPlaylists() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); - } - - final playlists = await client.getPlaylists( - playlistType: 'video', - smart: _filterSmart, - ); - - setState(() { - _playlists = playlists; - _isLoading = false; - }); - - appLogger.d('Loaded ${playlists.length} playlists'); - } catch (e) { - appLogger.e('Failed to load playlists', error: e); - setState(() { - _errorMessage = 'Failed to load playlists: ${e.toString()}'; - _isLoading = false; - }); - } - } - - void _toggleSmartFilter() { - setState(() { - if (_filterSmart == null) { - _filterSmart = true; // Show only smart - } else if (_filterSmart == true) { - _filterSmart = false; // Show only regular - } else { - _filterSmart = null; // Show all - } - }); - _loadPlaylists(); - } - - String _getFilterLabel() { - if (_filterSmart == null) return 'All'; - if (_filterSmart == true) return 'Smart'; - return 'Regular'; - } - - @override - void refresh() { - _loadPlaylists(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(t.playlists.title), - pinned: true, - actions: [ - TextButton.icon( - icon: const Icon(Icons.filter_list), - label: Text(_getFilterLabel()), - onPressed: _toggleSmartFilter, - ), - ], - ), - 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: _loadPlaylists, - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (_playlists.isEmpty && _isLoading) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_playlists.isEmpty) - SliverFillRemaining( - child: Center(child: Text(t.playlists.noPlaylists)), - ) - else - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( - context, - context.watch().libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - return _PlaylistCard( - playlist: _playlists[index], - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - PlaylistDetailScreen(playlist: _playlists[index]), - ), - ).then((_) => _loadPlaylists()); // Refresh on return - }, - onDeleted: _loadPlaylists, - ); - }, childCount: _playlists.length), - ), - ), - ], - ), - ); - } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } -} - -/// Widget to display a single playlist card -class _PlaylistCard extends StatelessWidget { - final PlexPlaylist playlist; - final VoidCallback onTap; - final VoidCallback onDeleted; - - const _PlaylistCard({ - required this.playlist, - required this.onTap, - required this.onDeleted, - }); - - Future _showDeleteDialog(BuildContext context) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), - ); - - if (confirmed == true && context.mounted) { - final client = context.clientSafe; - final success = await client.deletePlaylist(playlist.ratingKey); - - if (context.mounted) { - if (success) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); - onDeleted(); - } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); - } - } - } - } - - @override - Widget build(BuildContext context) { - final client = context.clientSafe; - final imageUrl = playlist.displayImage != null - ? client.getThumbnailUrl(playlist.displayImage!) - : null; - - return Card( - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.all(4), - child: InkWell( - onTap: onTap, - onLongPress: () => _showDeleteDialog(context), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Playlist image - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - if (imageUrl != null) - Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildPlaceholder(); - }, - ) - else - _buildPlaceholder(), - // Smart playlist indicator - if (playlist.smart) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.9), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.auto_awesome, - size: 12, - color: Colors.white, - ), - ), - ), - ], - ), - ), - // Playlist info - Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - playlist.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.playlist_play, - size: 14, - color: Colors.grey[600], - ), - const SizedBox(width: 4), - Text( - playlist.leafCount != null && playlist.leafCount! > 0 - ? (playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount( - count: playlist.leafCount!, - )) - : t.playlists.emptyPlaylist, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildPlaceholder() { - return Container( - color: Colors.grey[850], - child: const Center( - child: Icon(Icons.playlist_play, size: 48, color: Colors.grey), - ), - ); - } -} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 2764664e..7b181dbb 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -11,6 +11,7 @@ import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; @@ -253,9 +254,10 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, settingsProvider.libraryDensity, + 32, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, @@ -279,49 +281,4 @@ class _SearchScreenState extends State ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 32.0; // 16px left + 16px right from SliverPadding - 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; - } - } } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 137816d0..9ffd3442 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/duration_formatter.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; @@ -132,7 +133,7 @@ class _SeasonDetailScreenState extends State : 0.0; return MediaContextMenu( - metadata: episode, + item: episode, onRefresh: updateItem, onTap: () async { await navigateToVideoPlayer(context, metadata: episode); @@ -330,7 +331,9 @@ class _SeasonDetailScreenState extends State children: [ if (episode.duration != null) Text( - _formatDuration(episode.duration!), + formatDurationTimestamp( + Duration(milliseconds: episode.duration!), + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, @@ -369,17 +372,4 @@ class _SeasonDetailScreenState extends State ), ); } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 8f142ca2..2740e7a7 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dio/dio.dart'; import '../i18n/strings.g.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; @@ -9,6 +10,7 @@ import '../widgets/server_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../utils/error_message_utils.dart'; import 'main_screen.dart'; class ServerSelectionScreen extends StatefulWidget { @@ -74,6 +76,12 @@ class _ServerSelectionScreenState extends State { } String _getErrorMessage(dynamic error) { + if (error is DioException) { + return mapDioErrorToMessage( + error, + context: t.serverSelection.noServersFound, + ); + } if (error is ServerParsingException) { return t.serverSelection.malformedServerData( count: error.invalidServerData.length, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 53fe04ad..6370e514 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -63,12 +63,14 @@ class VideoPlayerScreenState extends State StreamSubscription? _completedSubscription; StreamSubscription? _positionSubscription; StreamSubscription? _mediaControlSubscription; + StreamSubscription? _bufferingSubscription; bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode = 0; bool _isPinching = false; // Track if a pinch gesture is occurring + bool _isBuffering = false; // Track if video is currently buffering // Video cropping state for fill screen mode Size? _playerSize; @@ -92,6 +94,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); @@ -267,6 +284,15 @@ class VideoPlayerScreenState extends State _updateMediaControlsPosition(); }); + // Listen to buffering state + _bufferingSubscription = player!.stream.buffering.listen((isBuffering) { + if (mounted) { + setState(() { + _isBuffering = isBuffering; + }); + } + }); + // Initialize OS media controls _initializeMediaControls(); @@ -301,11 +327,13 @@ 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 +348,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 { @@ -717,6 +745,7 @@ class VideoPlayerScreenState extends State _errorSubscription?.cancel(); _positionSubscription?.cancel(); _mediaControlSubscription?.cancel(); + _bufferingSubscription?.cancel(); // Clear OS media controls completely OsMediaControls.clear(); @@ -1970,6 +1999,23 @@ class VideoPlayerScreenState extends State ), ), ), + // Buffering indicator + if (_isBuffering) + Positioned.fill( + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + ), + ), ], ), ), diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 4c16d670..f17ced12 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -168,13 +168,27 @@ 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 +199,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/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 78710145..02e46b7e 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -19,6 +19,16 @@ ThemeData monoTheme({required bool dark}) { textMuted: const Color(0x99111111), ); + final buttonStyle = ButtonStyle( + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll(c.text), + foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), + shape: const WidgetStatePropertyAll(StadiumBorder()), + ); + final base = ThemeData( useMaterial3: true, brightness: dark ? Brightness.dark : Brightness.light, @@ -103,28 +113,8 @@ ThemeData monoTheme({required bool dark}) { ), hintStyle: TextStyle(color: c.textMuted), ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), + elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), + filledButtonTheme: FilledButtonThemeData(style: buttonStyle), dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline), listTileTheme: ListTileThemeData( dense: true, diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart new file mode 100644 index 00000000..6203f34b --- /dev/null +++ b/lib/utils/collection_playlist_play_helper.dart @@ -0,0 +1,115 @@ +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); + + if (!context.mounted) return; + + // 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); + + if (!context.mounted) return; + + // 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/dialogs.dart b/lib/utils/dialogs.dart new file mode 100644 index 00000000..ac1840a5 --- /dev/null +++ b/lib/utils/dialogs.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// Utility functions for showing common dialogs + +/// Shows a delete confirmation dialog +/// Returns true if user confirmed, false if cancelled +Future showDeleteConfirmation( + BuildContext context, { + required String title, + required String message, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + return confirmed ?? false; +} diff --git a/lib/utils/duration_formatter.dart b/lib/utils/duration_formatter.dart new file mode 100644 index 00000000..698b612f --- /dev/null +++ b/lib/utils/duration_formatter.dart @@ -0,0 +1,92 @@ +import 'package:duration/duration.dart'; +import 'package:duration/locale.dart'; +import '../i18n/strings.g.dart'; + +/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes"). +/// Uses localized unit names based on the current app locale. +/// Shows hours and minutes only (no seconds). +/// +/// Used for: media cards, media details, playlists. +String formatDurationTextual(int milliseconds, {bool abbreviated = true}) { + final duration = Duration(milliseconds: milliseconds); + + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated or full units (h, m) but no seconds + return prettyDuration( + duration, + abbreviated: abbreviated, + locale: durationLocale, + delimiter: abbreviated ? ' ' : ', ', + spacer: '', + // Configure to show only hours and minutes + tersity: DurationTersity.minute, + ); +} + +/// Formats a duration in human-readable textual format with seconds (e.g., "1h 23m 45s"). +/// Uses localized unit names based on the current app locale. +/// Shows hours, minutes, and seconds. +/// +/// Used for: sleep timer countdown. +String formatDurationWithSeconds(Duration duration) { + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated units (h, m, s) including seconds + return prettyDuration( + duration, + abbreviated: true, + locale: durationLocale, + delimiter: ' ', + spacer: '', + // Show all non-zero units + tersity: DurationTersity.second, + ); +} + +/// Formats a duration in timestamp format (e.g., "1:23:45" or "23:45"). +/// This format is not localized as it follows universal digital clock conventions. +/// Shows H:MM:SS or M:SS depending on duration. +/// +/// Used for: video controls, chapters, episode durations. +String formatDurationTimestamp(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } +} + +/// Formats a sync offset in milliseconds with sign indicator (e.g., "+150ms", "-250ms"). +/// This format is used for audio/subtitle synchronization adjustments. +/// +/// Used for: audio sync sheet, sync offset controls. +String formatSyncOffset(double offsetMs) { + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs.round()}ms'; +} + +/// Gets the duration package locale based on the current app locale. +/// Falls back to English if the locale is not supported by the duration package. +DurationLocale _getDurationLocale() { + // Get the current locale from slang's LocaleSettings + final appLocale = LocaleSettings.currentLocale; + final languageCode = appLocale.languageCode; + + // Map supported locales to duration package locales + // The duration package supports many languages, but we'll focus on the ones + // that our app supports: en, de, it, nl, sv, zh + try { + return DurationLocale.fromLanguageCode(languageCode) ?? + const EnglishDurationLocale(); + } catch (e) { + // Fallback to English if language code is not supported + return const EnglishDurationLocale(); + } +} diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart new file mode 100644 index 00000000..567ef238 --- /dev/null +++ b/lib/utils/error_message_utils.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import '../i18n/strings.g.dart'; +import 'app_logger.dart'; + +/// Shared helpers for translating network errors into user-friendly messages. +String mapDioErrorToMessage(DioException error, {required String context}) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + return t.errors.connectionTimeout(context: context); + case DioExceptionType.connectionError: + return t.errors.connectionFailed; + default: + appLogger.e('Error loading $context', error: error); + return t.errors.failedToLoad( + context: context, + error: error.message ?? t.common.unknown, + ); + } +} + +/// Generic fallback for unexpected errors. +String mapUnexpectedErrorToMessage(dynamic error, {required String context}) { + appLogger.e('Unexpected error in $context', error: error); + return t.errors.failedToLoad(context: context, error: error.toString()); +} diff --git a/lib/utils/grid_cross_axis_extent.dart b/lib/utils/grid_cross_axis_extent.dart new file mode 100644 index 00000000..812d010f --- /dev/null +++ b/lib/utils/grid_cross_axis_extent.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; + +/// Calculates the max cross-axis extent for grid items, accounting for outer padding. +double getMaxCrossAxisExtentWithPadding( + BuildContext context, + LibraryDensity density, + double horizontalPadding, +) { + final screenWidth = MediaQuery.of(context).size.width; + final availableWidth = screenWidth - horizontalPadding; + + 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; + } +} diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart new file mode 100644 index 00000000..35f403a2 --- /dev/null +++ b/lib/utils/grid_size_calculator.dart @@ -0,0 +1,54 @@ +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..712348f0 --- /dev/null +++ b/lib/utils/library_refresh_notifier.dart @@ -0,0 +1,39 @@ +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/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index 58abed1c..a14ab81e 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -90,6 +90,7 @@ Future handleShufflePlay( episodes.shuffle(); // Store shuffle queue in provider + // ignore: deprecated_member_use_from_same_package playbackState.setShuffleQueue(episodes, metadata.ratingKey); // Navigate to first episode diff --git a/lib/widgets/adaptive_media_grid.dart b/lib/widgets/adaptive_media_grid.dart new file mode 100644 index 00000000..cf8b37eb --- /dev/null +++ b/lib/widgets/adaptive_media_grid.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart' show ViewMode; +import '../utils/grid_size_calculator.dart'; +import 'media_card.dart'; + +/// A widget that automatically switches between grid and list view +/// based on user settings, providing a consistent layout pattern +/// across all library screens +class AdaptiveMediaGrid extends StatelessWidget { + /// The list of media items to display + final List items; + + /// Callback when the list needs to be refreshed + final VoidCallback? onRefresh; + + /// Optional padding around the grid/list + final EdgeInsets padding; + + /// Child aspect ratio for grid items (width / height) + final double childAspectRatio; + + const AdaptiveMediaGrid({ + super.key, + required this.items, + this.onRefresh, + this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), + this.childAspectRatio = 2 / 3.3, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: padding, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } else { + return GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } + }, + ); + } +} diff --git a/lib/widgets/app_bar_back_button.dart b/lib/widgets/app_bar_back_button.dart index a23b924f..7a46a585 100644 --- a/lib/widgets/app_bar_back_button.dart +++ b/lib/widgets/app_bar_back_button.dart @@ -29,11 +29,13 @@ class AppBarBackButton extends StatefulWidget { /// [style] determines the visual appearance of the back button. /// [onPressed] is called when the button is tapped. If null, defaults to Navigator.pop. /// [color] overrides the default icon color. If null, uses white for circular/video, theme default for plain. + /// [semanticLabel] provides accessibility label for screen readers. const AppBarBackButton({ super.key, this.style = BackButtonStyle.circular, this.onPressed, this.color, + this.semanticLabel, }); /// The visual style of the back button @@ -45,6 +47,9 @@ class AppBarBackButton extends StatefulWidget { /// The color of the back arrow icon. If null, uses style-appropriate default. final Color? color; + /// Semantic label for screen readers + final String? semanticLabel; + @override State createState() => _AppBarBackButtonState(); } @@ -124,7 +129,7 @@ class _AppBarBackButtonState extends State break; } - final button = MouseRegion( + final buttonWidget = MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => _onHoverChange(true), onExit: (_) => _onHoverChange(false), @@ -147,13 +152,26 @@ class _AppBarBackButtonState extends State color: currentColor, shape: BoxShape.circle, ), - child: Icon(Icons.arrow_back, color: effectiveColor, size: 20), + child: Icon( + Icons.arrow_back, + color: effectiveColor, + size: 20, + ), ); }, ), ), ); + final button = widget.semanticLabel != null + ? Semantics( + label: widget.semanticLabel, + button: true, + excludeSemantics: true, + child: buttonWidget, + ) + : buttonWidget; + return widget.style == BackButtonStyle.circular ? SafeArea(child: button) : button; diff --git a/lib/widgets/content_state_builder.dart b/lib/widgets/content_state_builder.dart new file mode 100644 index 00000000..5ad59cee --- /dev/null +++ b/lib/widgets/content_state_builder.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// A widget that handles loading, error, empty, and content states +/// Provides a consistent UI pattern across the app for data-driven screens +class ContentStateBuilder extends StatelessWidget { + /// Whether data is currently loading + final bool isLoading; + + /// Error message to display (null if no error) + final String? errorMessage; + + /// The list of items to display + final List items; + + /// Icon to display when the list is empty + final IconData emptyIcon; + + /// Message to display when the list is empty + final String emptyMessage; + + /// Callback when user taps retry button + final VoidCallback onRetry; + + /// Builder for the content when items are available + final Widget Function(List items) builder; + + const ContentStateBuilder({ + super.key, + required this.isLoading, + required this.errorMessage, + required this.items, + required this.emptyIcon, + required this.emptyMessage, + required this.onRetry, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + // Loading state (only show loading indicator if items list is empty) + if (isLoading && items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + // Error state (only show error if items list is empty) + if (errorMessage != null && items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + ElevatedButton(onPressed: onRetry, child: Text(t.common.retry)), + ], + ), + ); + } + + // Empty state + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(emptyIcon, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(emptyMessage, style: const TextStyle(color: Colors.white70)), + ], + ), + ); + } + + // Content state - delegate to builder + return builder(items); + } +} diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart new file mode 100644 index 00000000..d8f83a8e --- /dev/null +++ b/lib/widgets/empty_state_widget.dart @@ -0,0 +1,65 @@ +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.withValues(alpha: 0.4), + ), + const SizedBox(height: 16), + ], + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 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..31559bfe --- /dev/null +++ b/lib/widgets/error_state_widget.dart @@ -0,0 +1,57 @@ +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..9b5d0c12 --- /dev/null +++ b/lib/widgets/folder_tree_item.dart @@ -0,0 +1,128 @@ +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.withValues(alpha: 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.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart new file mode 100644 index 00000000..371a2e48 --- /dev/null +++ b/lib/widgets/folder_tree_view.dart @@ -0,0 +1,265 @@ +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..ce7f7461 --- /dev/null +++ b/lib/widgets/hub_section.dart @@ -0,0 +1,133 @@ +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 a71a80bb..cd814ae3 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -2,26 +2,34 @@ 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'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_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 +38,10 @@ class MediaCard extends StatefulWidget { this.height, this.onRefresh, this.onRemoveFromContinueWatching, + this.onListRefresh, this.forceGridMode = false, this.isInContinueWatching = false, + this.collectionId, }); @override @@ -84,8 +94,36 @@ 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) { @@ -143,33 +181,38 @@ class _MediaCardState extends State { final semanticLabel = _buildSemanticLabel(); + final cardWidget = viewMode == ViewMode.grid + ? _MediaCardGrid( + item: widget.item, + width: widget.width, + height: widget.height, + semanticLabel: semanticLabel, + onTap: () => _handleTap(context), + ) + : _MediaCardList( + item: widget.item, + semanticLabel: semanticLabel, + 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, - semanticLabel: semanticLabel, - onTap: () => _handleTap(context), - ) - : _MediaCardList( - item: widget.item, - semanticLabel: semanticLabel, - 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 String semanticLabel; @@ -191,6 +234,7 @@ class _MediaCardGrid extends StatelessWidget { label: semanticLabel, button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -213,7 +257,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( @@ -222,37 +268,92 @@ 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(); + }, ), + ], ], ), ], @@ -268,56 +369,17 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], ); } - - Widget _buildPosterImage(BuildContext context) { - final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return const SkeletonLoader( - child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), - ), - ); - } - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - 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)), - ), - ); - }, - ); - } else { - return const SkeletonLoader( - child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), - ), - ); - } - } } /// List layout for media cards class _MediaCardList extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final String semanticLabel; final VoidCallback onTap; final LibraryDensity density; @@ -378,14 +440,8 @@ class _MediaCardList extends StatelessWidget { } double get _summaryFontSize { - switch (density) { - case LibraryDensity.compact: - return 11; - case LibraryDensity.normal: - return 12; - case LibraryDensity.comfortable: - return 13; - } + // Summary uses the same sizing as metadata text + return _metadataFontSize; } int get _summaryMaxLines { @@ -399,63 +455,88 @@ class _MediaCardList extends StatelessWidget { } } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - 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.duration != null) { + parts.add(formatDurationTextual(playlist.duration!)); + } - // 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(formatDurationTextual(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 @@ -471,6 +552,7 @@ class _MediaCardList extends StatelessWidget { label: semanticLabel, button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -485,7 +567,7 @@ class _MediaCardList extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], @@ -565,59 +647,73 @@ class _MediaCardList extends StatelessWidget { ), ); } +} - Widget _buildPosterImage(BuildContext context) { +Widget _buildPosterImage(BuildContext context, dynamic item) { + String? posterUrl; + IconData fallbackIcon = Icons.movie; + + if (item is PlexPlaylist) { + posterUrl = item.displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return const SkeletonLoader( - child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), - ), - ); - } + posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + } - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - 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)), + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return SkeletonLoader( + child: Center( + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); - }, - ); - } else { - return const SkeletonLoader( - child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), - ), - ); - } + } + + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + filterQuality: FilterQuality.medium, + fadeInDuration: const Duration(milliseconds: 300), + placeholder: (context, url) => const SkeletonLoader(), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center(child: Icon(fallbackIcon, size: 40)), + ), + ); + }, + ); + } else { + return SkeletonLoader( + child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)), + ); } } /// 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, @@ -637,10 +733,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, @@ -651,7 +747,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..babdade0 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,26 @@ 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 +61,17 @@ class _MediaContextMenuState extends State { final client = context.client; if (client == null) return; - final itemType = widget.metadata.type.toLowerCase(); + 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 = - widget.metadata.viewedLeafCount != null && - widget.metadata.leafCount != null && - widget.metadata.viewedLeafCount! > 0 && - widget.metadata.viewedLeafCount! < widget.metadata.leafCount!; + !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,97 +79,138 @@ class _MediaContextMenuState extends State { // Build menu actions final menuActions = <_MenuAction>[]; - // Mark as Watched - if (!widget.metadata.isWatched || isPartiallyWatched) { + // Special actions for collections and playlists + if (isCollection || isPlaylist) { + // Play menuActions.add( _MenuAction( - value: 'watch', - icon: Icons.check_circle_outline, - label: t.mediaMenu.markAsWatched, + value: 'play', + icon: Icons.play_arrow, + label: t.discover.play, ), ); - } - // Mark as Unwatched - if (widget.metadata.isWatched || isPartiallyWatched) { + // Shuffle menuActions.add( _MenuAction( - value: 'unwatch', - icon: Icons.remove_circle_outline, - label: t.mediaMenu.markAsUnwatched, - ), - ); - } - - // Remove from Continue Watching (only in continue watching section) - if (widget.isInContinueWatching) { - menuActions.add( - _MenuAction( - value: 'remove_from_continue_watching', - icon: Icons.close, - label: t.mediaMenu.removeFromContinueWatching, - ), - ); - } - - // Go to Series (for episodes and seasons) - if ((itemType == 'episode' || itemType == 'season') && - widget.metadata.grandparentTitle != null) { - menuActions.add( - _MenuAction( - value: 'series', - icon: Icons.tv, - label: t.mediaMenu.goToSeries, - ), - ); - } - - // Go to Season (for episodes) - if (itemType == 'episode' && widget.metadata.parentTitle != null) { - menuActions.add( - _MenuAction( - value: 'season', - icon: Icons.playlist_play, - label: t.mediaMenu.goToSeason, - ), - ); - } - - // Shuffle Play (for shows and seasons) - if (itemType == 'show' || itemType == 'season') { - menuActions.add( - _MenuAction( - value: 'shuffle_play', + value: 'shuffle', icon: Icons.shuffle, label: t.mediaMenu.shufflePlay, ), ); - } - // File Info (for episodes and movies) - if (itemType == 'episode' || itemType == 'movie') { + // Delete menuActions.add( _MenuAction( - value: 'fileinfo', - icon: Icons.info_outline, - label: t.mediaMenu.fileInfo, + value: 'delete', + icon: Icons.delete, + label: t.common.delete, ), ); - } - // Add to Playlist (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, - ), - ); - } + // Skip other menu items for collections and playlists + } else { + // Regular menu items for other types + + // Mark as Watched + if (!metadata!.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'watch', + icon: Icons.check_circle_outline, + label: t.mediaMenu.markAsWatched, + ), + ); + } + + // Mark as Unwatched + if (metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'unwatch', + icon: Icons.remove_circle_outline, + label: t.mediaMenu.markAsUnwatched, + ), + ); + } + + // Remove from Continue Watching (only in continue watching section) + if (widget.isInContinueWatching) { + menuActions.add( + _MenuAction( + value: 'remove_from_continue_watching', + icon: Icons.close, + label: t.mediaMenu.removeFromContinueWatching, + ), + ); + } + + // 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') && + metadata.grandparentTitle != null) { + menuActions.add( + _MenuAction( + value: 'series', + icon: Icons.tv, + label: t.mediaMenu.goToSeries, + ), + ); + } + + // Go to Season (for episodes) + if (itemType == 'episode' && metadata.parentTitle != null) { + menuActions.add( + _MenuAction( + value: 'season', + icon: Icons.playlist_play, + label: t.mediaMenu.goToSeason, + ), + ); + } + + // Shuffle Play (for shows and seasons) + if (itemType == 'show' || itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'shuffle_play', + icon: Icons.shuffle, + label: t.mediaMenu.shufflePlay, + ), + ); + } + + // File Info (for episodes and movies) + if (itemType == 'episode' || itemType == 'movie') { + menuActions.add( + _MenuAction( + value: 'fileinfo', + icon: Icons.info_outline, + label: t.mediaMenu.fileInfo, + ), + ); + } + + // Add to... (for episodes, movies, shows, and seasons) + if (itemType == 'episode' || + itemType == 'movie' || + itemType == 'show' || + itemType == 'season') { + menuActions.add( + _MenuAction(value: 'add_to', icon: Icons.add, label: t.common.addTo), + ); + } + } // End of regular menu items else block String? selected; @@ -172,7 +225,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 +302,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 +310,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 +320,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 +329,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 +343,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 +359,7 @@ class _MediaContextMenuState extends State { case 'season': await _navigateToRelated( context, - widget.metadata.parentRatingKey, + metadata!.parentRatingKey, (metadata) => SeasonDetailScreen(season: metadata), t.messages.errorLoadingSeason, ); @@ -312,12 +369,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 +403,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 +434,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 +462,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) { @@ -406,10 +476,8 @@ class _MediaContextMenuState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => FileInfoBottomSheet( - fileInfo: fileInfo, - title: widget.metadata.title, - ), + builder: (context) => + FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title), ); } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -432,13 +500,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,9 +605,11 @@ 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 (!context.mounted) return; + if (result == '_create_new') { // Create new playlist flow final playlistName = await showDialog( @@ -478,12 +630,16 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (context.mounted) { if (newPlaylist != null) { appLogger.d('Successfully created playlist: ${newPlaylist.title}'); 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( @@ -499,12 +655,16 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to playlist $result'); 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 +692,427 @@ 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) return; + + 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 (!context.mounted) return; + + 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) return; + + 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 +1231,101 @@ 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/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 234ae989..004f771e 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../utils/duration_formatter.dart'; import '../i18n/strings.g.dart'; /// Custom list item widget for playlist items @@ -97,7 +98,7 @@ class PlaylistItemCard extends StatelessWidget { // Duration if (item.duration != null) Text( - _formatDuration(item.duration!), + formatDurationTextual(item.duration!), style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), @@ -175,16 +176,4 @@ class PlaylistItemCard extends StatelessWidget { // Default to type return item.type; } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } } diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 6d5908fd..8c45770f 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( @@ -137,10 +138,7 @@ class _SortBottomSheetState extends State { : null, leading: Radio(value: sort, toggleable: false), onTap: () { - _handleSortChange( - sort, - sort.defaultDirection == 'desc', - ); + _handleSortChange(sort, sort.isDefaultDescending); }, ); }, diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart index 5d1af9a3..6757124a 100644 --- a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:plezy/services/settings_service.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for adjusting audio sync offset class AudioSyncSheet extends StatefulWidget { @@ -14,23 +16,12 @@ class AudioSyncSheet extends StatefulWidget { required this.initialOffset, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player, int initialOffset) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, - constraints: getBottomSheetConstraints(context), + constraints: BaseVideoControlSheet.getBottomSheetConstraints(context), builder: (context) => AudioSyncSheet(player: player, initialOffset: initialOffset), ); @@ -71,11 +62,6 @@ class _AudioSyncSheetState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - @override Widget build(BuildContext context) { return SafeArea( @@ -114,7 +100,7 @@ class _AudioSyncSheetState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48, diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 40354f17..e28043f7 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { @@ -9,27 +10,13 @@ class AudioTrackSheet extends StatelessWidget { const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(AudioTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -46,108 +33,73 @@ class AudioTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.audiotrack, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.audioLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.audioLabel, + icon: Icons.audiotrack, + child: audioTracks.isEmpty + ? const Center( + child: Text( + 'No audio tracks available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (audioTracks.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No audio tracks available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.audio; - final selectedId = selectedTrack.id; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.audio; + final selectedId = selectedTrack.id; - return ListView.builder( - itemCount: audioTracks.length, - itemBuilder: (context, index) { - final audioTrack = audioTracks[index]; - final isSelected = audioTrack.id == selectedId; + return ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final audioTrack = audioTracks[index]; + final isSelected = audioTrack.id == selectedId; - final parts = []; - if (audioTrack.title != null && - audioTrack.title!.isNotEmpty) { - parts.add(audioTrack.title!); - } - if (audioTrack.language != null && - audioTrack.language!.isNotEmpty) { - parts.add(audioTrack.language!.toUpperCase()); - } - if (audioTrack.codec != null && - audioTrack.codec!.isNotEmpty) { - parts.add(audioTrack.codec!.toUpperCase()); - } - if (audioTrack.channelscount != null) { - parts.add('${audioTrack.channelscount}ch'); - } + final parts = []; + if (audioTrack.title != null && + audioTrack.title!.isNotEmpty) { + parts.add(audioTrack.title!); + } + if (audioTrack.language != null && + audioTrack.language!.isNotEmpty) { + parts.add(audioTrack.language!.toUpperCase()); + } + if (audioTrack.codec != null && + audioTrack.codec!.isNotEmpty) { + parts.add(audioTrack.codec!.toUpperCase()); + } + if (audioTrack.channelscount != null) { + parts.add('${audioTrack.channelscount}ch'); + } - final label = parts.isEmpty - ? 'Audio Track ${index + 1}' - : parts.join(' · '); + final label = parts.isEmpty + ? 'Audio Track ${index + 1}' + : parts.join(' · '); - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setAudioTrack(audioTrack); - onTrackChanged?.call(audioTrack); - Navigator.pop(context); - }, - ); + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart new file mode 100644 index 00000000..d02a875a --- /dev/null +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +/// Base class for video control bottom sheets providing common UI structure +class BaseVideoControlSheet extends StatelessWidget { + final String title; + final IconData icon; + final Widget child; + final Color? iconColor; + + const BaseVideoControlSheet({ + super.key, + required this.title, + required this.icon, + required this.child, + this.iconColor, + }); + + /// Get consistent bottom sheet constraints across all video control sheets + static BoxConstraints getBottomSheetConstraints(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + /// Helper method to show a modal bottom sheet with consistent styling + static Future showSheet({ + required BuildContext context, + required WidgetBuilder builder, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: getBottomSheetConstraints(context), + builder: builder, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + _buildHeader(context), + const Divider(color: Colors.white24, height: 1), + Expanded(child: child), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: iconColor ?? Colors.white), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index f77ea04a..5c615eb1 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -3,6 +3,8 @@ import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import '../../../utils/duration_formatter.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters class ChapterSheet extends StatelessWidget { @@ -17,28 +19,14 @@ class ChapterSheet extends StatelessWidget { required this.chaptersLoaded, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, List chapters, bool chaptersLoaded, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => ChapterSheet( player: player, chapters: chapters, @@ -47,18 +35,6 @@ class ChapterSheet extends StatelessWidget { ); } - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } - @override Widget build(BuildContext context) { return StreamBuilder( @@ -85,148 +61,103 @@ class ChapterSheet extends StatelessWidget { } } - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_library, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Chapters', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + Widget content; + if (!chaptersLoaded) { + content = const Center(child: CircularProgressIndicator()); + } else if (chapters.isEmpty) { + content = const Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ); + } else { + content = ListView.builder( + itemCount: chapters.length, + itemBuilder: (context, index) { + final chapter = chapters[index]; + final isCurrentChapter = currentChapterIndex == index; + + return ListTile( + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ); + } + return Image.network( + client.getThumbnailUrl(chapter.thumb), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ); + }, + ), + ), + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter ? Colors.blue : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, ), ), - const Divider(color: Colors.white24, height: 1), - if (!chaptersLoaded) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else if (chapters.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No chapters available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: chapters.length, - itemBuilder: (context, index) { - final chapter = chapters[index]; - final isCurrentChapter = currentChapterIndex == index; - - return ListTile( - leading: chapter.thumb != null - ? Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Consumer( - builder: - (context, clientProvider, child) { - final client = - clientProvider.client; - if (client == null) { - return const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ); - } - return Image.network( - client.getThumbnailUrl( - chapter.thumb, - ), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: - ( - context, - error, - stackTrace, - ) => const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), - ); - }, - ), - ), - if (isCurrentChapter) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 4, - ), - border: Border.all( - color: Colors.blue, - width: 2, - ), - ), - ), - ), - ], - ) - : null, - title: Text( - chapter.label, - style: TextStyle( - color: isCurrentChapter - ? Colors.blue - : Colors.white, - fontWeight: isCurrentChapter - ? FontWeight.bold - : FontWeight.normal, - ), - ), - subtitle: Text( - _formatDuration(chapter.startTime), - style: TextStyle( - color: isCurrentChapter - ? Colors.blue.withValues(alpha: 0.7) - : Colors.white70, - fontSize: 12, - ), - ), - trailing: isCurrentChapter - ? const Icon( - Icons.play_circle_filled, - color: Colors.blue, - ) - : null, - onTap: () { - player.seek(chapter.startTime); - Navigator.pop(context); - }, - ); - }, - ), + subtitle: Text( + formatDurationTimestamp(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, ), - ], - ), - ), + ), + trailing: isCurrentChapter + ? const Icon(Icons.play_circle_filled, color: Colors.blue) + : null, + onTap: () { + player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ); + } + + return BaseVideoControlSheet( + title: 'Chapters', + icon: Icons.video_library, + child: content, ); }, ); diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart index ea96e225..ce727db3 100644 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting playback speed class PlaybackSpeedSheet extends StatelessWidget { @@ -7,23 +8,9 @@ class PlaybackSpeedSheet extends StatelessWidget { const PlaybackSpeedSheet({super.key, required this.player}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => PlaybackSpeedSheet(player: player), ); } @@ -39,66 +26,36 @@ class PlaybackSpeedSheet extends StatelessWidget { // Define available playback speeds final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.speed, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Playback Speed', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: 'Playback Speed', + icon: Icons.speed, + child: ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + final isSelected = (currentRate - speed).abs() < 0.01; + + // Format speed label + final label = speed == 1.0 + ? 'Normal' + : '${speed.toStringAsFixed(2)}x'; + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, ), ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - final isSelected = (currentRate - speed).abs() < 0.01; - - // Format speed label - final label = speed == 1.0 - ? 'Normal' - : '${speed.toStringAsFixed(2)}x'; - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setRate(speed); + Navigator.pop(context); + }, + ); + }, ), ); }, diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index b2786ba3..649d7096 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; -import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; +import '../widgets/sleep_timer_content.dart'; /// Bottom sheet for sleep timer configuration class SleepTimerSheet extends StatelessWidget { @@ -15,47 +17,19 @@ class SleepTimerSheet extends StatelessWidget { required this.defaultDuration, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) async { final settingsService = await SettingsService.getInstance(); final defaultDuration = settingsService.getSleepTimerDuration(); if (!context.mounted) return; - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SleepTimerSheet(player: player, defaultDuration: defaultDuration), ); } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); @@ -63,169 +37,15 @@ class SleepTimerSheet extends StatelessWidget { return ListenableBuilder( listenable: sleepTimer, builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - // Add default duration if not in list - if (!durations.contains(defaultDuration)) { - durations.add(defaultDuration); - durations.sort(); - } - final remainingTime = sleepTimer.remainingTime; - - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon( - sleepTimer.isActive - ? Icons.bedtime - : Icons.bedtime_outlined, - color: sleepTimer.isActive - ? Colors.amber - : Colors.white, - ), - const SizedBox(width: 12), - const Text( - 'Sleep Timer', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - - // Show current timer status if active - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime( - amount: "15", - unit: " min", - ), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer( - const Duration(minutes: 15), - ); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration selection list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.normal, - ), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - // Pause playback when timer completes - player.pause(); - - // Show a snackbar notification - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); - - // Show confirmation snackbar - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.sleepTimerSet(label: label), - ), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ), + return BaseVideoControlSheet( + title: t.videoControls.sleepTimer, + icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, + iconColor: sleepTimer.isActive ? Colors.amber : null, + child: SleepTimerContent( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, + onCancel: () => Navigator.pop(context), ), ); }, diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index 4f375627..90dd113f 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { @@ -13,27 +14,13 @@ class SubtitleTrackSheet extends StatelessWidget { this.onTrackChanged, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(SubtitleTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -50,146 +37,106 @@ class SubtitleTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.subtitles, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.subtitlesLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.subtitlesLabel, + icon: Icons.subtitles, + child: subtitles.isEmpty + ? const Center( + child: Text( + 'No subtitles available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (subtitles.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No subtitles available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.subtitle; - final selectedId = selectedTrack.id; - final isOffSelected = selectedId == 'no'; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.subtitle; + final selectedId = selectedTrack.id; + final isOffSelected = selectedId == 'no'; - return ListView.builder( - itemCount: - subtitles.length + 1, // +1 for "Off" option - itemBuilder: (context, index) { - // First item is "Off" - if (index == 0) { - return ListTile( - title: Text( - 'Off', - style: TextStyle( - color: isOffSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isOffSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) - : null, - onTap: () { - player.setSubtitleTrack(SubtitleTrack.no()); - onTrackChanged?.call(SubtitleTrack.no()); - Navigator.pop(context); - }, - ); - } - - // Subsequent items are subtitle tracks - final subtitle = subtitles[index - 1]; - final isSelected = subtitle.id == selectedId; - - // Build label with available info - final parts = []; - if (subtitle.title != null && - subtitle.title!.isNotEmpty) { - parts.add(subtitle.title!); - } - if (subtitle.language != null && - subtitle.language!.isNotEmpty) { - parts.add(subtitle.language!.toUpperCase()); - } - if (subtitle.codec != null && - subtitle.codec!.isNotEmpty) { - // Format codec names nicely - String codecName = subtitle.codec!.toUpperCase(); - if (codecName == 'SUBRIP') { - codecName = 'SRT'; - } else if (codecName == 'DVD_SUBTITLE') { - codecName = 'DVD'; - } else if (codecName == 'ASS' || - codecName == 'SSA') { - codecName = codecName; // Keep as-is - } else if (codecName == 'WEBVTT') { - codecName = 'VTT'; - } - parts.add(codecName); - } - - final label = parts.isEmpty - ? 'Track $index' - : parts.join(' · '); - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.blue - : Colors.white, - ), + return ListView.builder( + itemCount: subtitles.length + 1, // +1 for "Off" option + itemBuilder: (context, index) { + // First item is "Off" + if (index == 0) { + return ListTile( + title: Text( + 'Off', + style: TextStyle( + color: isOffSelected + ? Colors.blue + : Colors.white, ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setSubtitleTrack(subtitle); - onTrackChanged?.call(subtitle); - Navigator.pop(context); - }, - ); + ), + trailing: isOffSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); + Navigator.pop(context); + }, + ); + } + + // Subsequent items are subtitle tracks + final subtitle = subtitles[index - 1]; + final isSelected = subtitle.id == selectedId; + + // Build label with available info + final parts = []; + if (subtitle.title != null && + subtitle.title!.isNotEmpty) { + parts.add(subtitle.title!); + } + if (subtitle.language != null && + subtitle.language!.isNotEmpty) { + parts.add(subtitle.language!.toUpperCase()); + } + if (subtitle.codec != null && + subtitle.codec!.isNotEmpty) { + // Format codec names nicely + String codecName = subtitle.codec!.toUpperCase(); + if (codecName == 'SUBRIP') { + codecName = 'SRT'; + } else if (codecName == 'DVD_SUBTITLE') { + codecName = 'DVD'; + } else if (codecName == 'ASS' || codecName == 'SSA') { + codecName = codecName; // Keep as-is + } else if (codecName == 'WEBVTT') { + codecName = 'VTT'; + } + parts.add(codecName); + } + + final label = parts.isEmpty + ? 'Track $index' + : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index eafd4dfe..70b45e9d 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_version.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting video version class VersionSheet extends StatelessWidget { @@ -14,28 +15,14 @@ class VersionSheet extends StatelessWidget { required this.onVersionSelected, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, List availableVersions, int selectedMediaIndex, Function(int) onVersionSelected, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => VersionSheet( availableVersions: availableVersions, selectedMediaIndex: selectedMediaIndex, @@ -46,61 +33,29 @@ class VersionSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_file, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Video Version', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: ListView.builder( - itemCount: availableVersions.length, - itemBuilder: (context, index) { - final version = availableVersions[index]; - final isSelected = index == selectedMediaIndex; + return BaseVideoControlSheet( + title: 'Video Version', + icon: Icons.video_file, + child: ListView.builder( + itemCount: availableVersions.length, + itemBuilder: (context, index) { + final version = availableVersions[index]; + final isSelected = index == selectedMediaIndex; - return ListTile( - title: Text( - version.displayLabel, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - Navigator.pop(context); - onVersionSelected(index); - }, - ); - }, - ), + return ListTile( + title: Text( + version.displayLabel, + style: TextStyle(color: isSelected ? Colors.blue : Colors.white), ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + Navigator.pop(context); + onVersionSelected(index); + }, + ); + }, ), ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index fb78e421..51ee84ed 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -4,10 +4,57 @@ import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../widgets/sync_offset_control.dart'; +import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } +/// Reusable menu item widget for settings sheet +class _SettingsMenuItem extends StatelessWidget { + final IconData icon; + final String title; + final String valueText; + final VoidCallback onTap; + final bool isHighlighted; + final bool allowValueOverflow; + + const _SettingsMenuItem({ + required this.icon, + required this.title, + required this.valueText, + required this.onTap, + this.isHighlighted = false, + this.allowValueOverflow = false, + }); + + @override + Widget build(BuildContext context) { + final valueWidget = Text( + valueText, + style: TextStyle( + color: isHighlighted ? Colors.amber : Colors.white70, + fontSize: 14, + ), + overflow: allowValueOverflow ? TextOverflow.ellipsis : null, + ); + + return ListTile( + leading: Icon(icon, color: isHighlighted ? Colors.amber : Colors.white70), + title: Text(title, style: const TextStyle(color: Colors.white)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget, + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: onTap, + ); + } +} + /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; @@ -21,17 +68,6 @@ class VideoSettingsSheet extends StatefulWidget { required this.subtitleSyncOffset, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static Future show( BuildContext context, Player player, @@ -42,7 +78,7 @@ class VideoSettingsSheet extends StatefulWidget { context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, - constraints: getBottomSheetConstraints(context), + constraints: BaseVideoControlSheet.getBottomSheetConstraints(context), builder: (context) => VideoSettingsSheet( player: player, audioSyncOffset: audioSyncOffset, @@ -139,20 +175,6 @@ class _VideoSettingsSheetState extends State { } } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - Widget _buildHeader() { final sleepTimer = SleepTimerService(); final isIconActive = @@ -204,23 +226,10 @@ class _VideoSettingsSheetState extends State { initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; - return ListTile( - leading: const Icon(Icons.speed, color: Colors.white70), - title: const Text( - 'Playback Speed', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSpeed(currentRate), - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speed, + title: 'Playback Speed', + valueText: _formatSpeed(currentRate), onTap: () => _navigateTo(_SettingsView.speed), ); }, @@ -231,87 +240,31 @@ class _VideoSettingsSheetState extends State { listenable: sleepTimer, builder: (context, _) { final isActive = sleepTimer.isActive; - return ListTile( - leading: Icon( - isActive ? Icons.bedtime : Icons.bedtime_outlined, - color: isActive ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Sleep Timer', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSleepTimer(sleepTimer), - style: TextStyle( - color: isActive ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: isActive ? Icons.bedtime : Icons.bedtime_outlined, + title: 'Sleep Timer', + valueText: _formatSleepTimer(sleepTimer), + isHighlighted: isActive, onTap: () => _navigateTo(_SettingsView.sleep), ); }, ), // Audio Sync - ListTile( - leading: Icon( - Icons.sync, - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Audio Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_audioSyncOffset), - style: TextStyle( - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.sync, + title: 'Audio Sync', + valueText: _formatAudioSync(_audioSyncOffset), + isHighlighted: _audioSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.audioSync), ), // Subtitle Sync - ListTile( - leading: Icon( - Icons.subtitles, - color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Subtitle Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_subtitleSyncOffset), - style: TextStyle( - color: _subtitleSyncOffset != 0 - ? Colors.amber - : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.subtitles, + title: 'Subtitle Sync', + valueText: _formatAudioSync(_subtitleSyncOffset), + isHighlighted: _subtitleSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.subtitleSync), ), @@ -327,29 +280,11 @@ class _VideoSettingsSheetState extends State { ? currentDevice.name : currentDevice.description; - return ListTile( - leading: const Icon(Icons.speaker, color: Colors.white70), - title: const Text( - 'Audio Output', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - deviceLabel, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speaker, + title: 'Audio Output', + valueText: deviceLabel, + allowValueOverflow: true, onTap: () => _navigateTo(_SettingsView.audioDevice), ); }, @@ -399,119 +334,10 @@ class _VideoSettingsSheetState extends State { Widget _buildSleepView() { final sleepTimer = SleepTimerService(); - return ListenableBuilder( - listenable: sleepTimer, - builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - final remainingTime = sleepTimer.remainingTime; - - return Column( - children: [ - // Active timer status - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime(amount: "15", unit: " min"), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer(const Duration(minutes: 15)); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); // Close after cancel - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle(color: Colors.white), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - widget.player.pause(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); // Close after selection - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.sleepTimerSet(label: label)), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ); - }, + return SleepTimerContent( + player: widget.player, + sleepTimer: sleepTimer, + onCancel: () => Navigator.pop(context), ); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 9d2638df..b6996c9c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -16,6 +16,7 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../services/sleep_timer_service.dart'; import '../../utils/desktop_window_padding.dart'; +import '../../utils/duration_formatter.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -1195,14 +1196,14 @@ class _PlexVideoControlsState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1342,7 +1343,7 @@ class _PlexVideoControlsState extends State return Row( children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1357,7 +1358,7 @@ class _PlexVideoControlsState extends State ), const SizedBox(width: 12), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1656,16 +1657,4 @@ class _PlexVideoControlsState extends State } } } - - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart new file mode 100644 index 00000000..fde98207 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; + +/// Widget displaying active sleep timer status with extend/cancel actions +class SleepTimerActiveStatus extends StatelessWidget { + final SleepTimerService sleepTimer; + final Duration remainingTime; + final VoidCallback? onCancel; + + const SleepTimerActiveStatus({ + super.key, + required this.sleepTimer, + required this.remainingTime, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.amber.withValues(alpha: 0.1), + child: Column( + children: [ + Text( + t.videoControls.timerActive, + style: const TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + t.videoControls.playbackWillPauseIn( + duration: formatDurationWithSeconds(remainingTime), + ), + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text( + t.videoControls.addTime(amount: "15", unit: " min"), + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + onPressed: () { + sleepTimer.extendTimer(const Duration(minutes: 15)); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + icon: const Icon(Icons.cancel), + label: Text(t.common.cancel), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + onPressed: () { + sleepTimer.cancelTimer(); + onCancel?.call(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_content.dart b/lib/widgets/video_controls/widgets/sleep_timer_content.dart new file mode 100644 index 00000000..c23611d0 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_content.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import 'sleep_timer_active_status.dart'; +import 'sleep_timer_duration_list.dart'; + +/// Shared UI for sleep timer selection and active status. +class SleepTimerContent extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + final VoidCallback? onCancel; + + const SleepTimerContent({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final remainingTime = sleepTimer.remainingTime; + + return Column( + children: [ + if (sleepTimer.isActive && remainingTime != null) ...[ + SleepTimerActiveStatus( + sleepTimer: sleepTimer, + remainingTime: remainingTime, + onCancel: onCancel, + ), + const Divider(color: Colors.white24, height: 1), + ], + Expanded( + child: SleepTimerDurationList( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart new file mode 100644 index 00000000..5c8a6037 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../utils/duration_formatter.dart'; +import '../../../i18n/strings.g.dart'; + +/// Widget displaying list of sleep timer durations for selection +class SleepTimerDurationList extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + + const SleepTimerDurationList({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + }); + + @override + Widget build(BuildContext context) { + final durations = [5, 10, 15, 30, 45, 60, 90, 120]; + // Add default duration if provided and not already in list + if (defaultDuration != null && !durations.contains(defaultDuration)) { + durations.add(defaultDuration!); + durations.sort(); + } + + return ListView.builder( + itemCount: durations.length, + itemBuilder: (context, index) { + final minutes = durations[index]; + final label = formatDurationTextual( + minutes * 60 * 1000, // Convert minutes to milliseconds + abbreviated: false, // Use full format for better readability + ); + + return ListTile( + leading: const Icon(Icons.timer, color: Colors.white70), + title: Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.normal, + ), + ), + onTap: () { + sleepTimer.startTimer(Duration(minutes: minutes), () { + // Pause playback when timer completes + player.pause(); + + // Show a snackbar notification + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.videoControls.sleepTimerCompleted), + duration: const Duration(seconds: 3), + ), + ); + } + }); + Navigator.pop(context); + + // Show confirmation snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.sleepTimerSet(label: label)), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 16a8a7ea..37f4d989 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { @@ -63,11 +64,6 @@ class _SyncOffsetControlState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - String _getDescriptionText() { if (_currentOffset > 0) { return t.videoControls.playsLater(label: widget.labelText); @@ -87,7 +83,7 @@ class _SyncOffsetControlState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48, diff --git a/pubspec.lock b/pubspec.lock index e818babd..fea4690d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + duration: + dependency: "direct main" + description: + name: duration + sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501" + url: "https://pub.dev" + source: hosted + version: "4.0.3" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d6ae92be..661be4a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex client for Flutter" publish_to: "none" -version: 1.6.1+15 +version: 1.7.0+16 environment: sdk: ^3.8.1 @@ -28,6 +28,7 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + duration: ^4.0.3 connectivity_plus: ^6.0.5 os_media_controls: git: