Added semantics to app bar's back button. Fixed conflicts

This commit is contained in:
Manuel Cortez
2025-11-17 23:12:42 -06:00
81 changed files with 7708 additions and 3319 deletions
+101
View File
@@ -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
+5
View File
@@ -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
@@ -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
+136
View File
@@ -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
+21 -1
View File
@@ -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.
+19
View File
@@ -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:
+5
View File
@@ -1 +1,6 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
+347 -1
View File
@@ -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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<bool> 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<String?> 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<bool> 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<bool> 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<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
}) async {
try {
final queryParams = <String, dynamic>{
'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<PlayQueueResponse?> getPlayQueue(
int playQueueId, {
String? center,
int window = 50,
int includeBefore = 1,
int includeAfter = 1,
}) async {
try {
final queryParams = <String, dynamic>{
'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<PlayQueueResponse?> 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<bool> 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<PlexMetadata> _extractMetadataAndDirectories(Response response) {
final List<PlexMetadata> 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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<List<PlexPlaylist>> 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
// ============================================================================
+97
View File
@@ -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;
}
+818 -2
View File
File diff suppressed because it is too large Load Diff
+62 -3
View File
@@ -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",
+63 -4
View File
@@ -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}"
}
}
+63 -4
View File
@@ -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}"
}
}
+63 -4
View File
@@ -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}"
}
}
+63 -4
View File
@@ -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}"
}
}
+63 -4
View File
@@ -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}"
}
}
+63
View File
@@ -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<T extends StatefulWidget> on State<T> {
/// The list of items to display
List<PlexMetadata> get items;
set items(List<PlexMetadata> 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<void> 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<PlexMetadata> newItems) {
if (mounted) {
setState(() {
items = newItems;
isLoading = false;
errorMessage = null;
});
}
}
}
+77
View File
@@ -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<bool, Object> {
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<PlexMetadata>? 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<String, dynamic> json) {
// The API returns data wrapped in MediaContainer
final container = json['MediaContainer'] as Map<String, dynamic>? ?? 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,
);
}
}
+28
View File
@@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'play_queue_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
+7 -12
View File
@@ -23,9 +23,10 @@ class PlexHub {
factory PlexHub.fromJson(Map<String, dynamic> json) {
final metadataList = <PlexMetadata>[];
// 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? ?? '',
+40 -13
View File
@@ -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<String> additionalParts) {
if (displayTitle != null && displayTitle!.isNotEmpty) {
return displayTitle!;
}
final parts = <String>[];
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 = <String>[];
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 = <String>[];
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 = <String>[];
if (language != null) parts.add(language!);
if (forced) parts.add('Forced');
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
final additionalParts = <String>[];
if (forced) additionalParts.add('Forced');
return buildLabel(additionalParts);
}
/// Returns true if this subtitle track is an external file (sidecar subtitle)
+12
View File
@@ -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<PlexRole>? 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<PlexRole>? 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;
+6
View File
@@ -37,12 +37,15 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.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<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
@@ -77,8 +80,11 @@ Map<String, dynamic> _$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,
};
-11
View File
@@ -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;
@@ -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;
}
+257 -61
View File
@@ -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<PlexMetadata> _queue = [];
// Play queue state
int? _playQueueId;
int _playQueueTotalCount = 0;
bool _playQueueShuffled = false;
int? _currentPlayQueueItemID;
// Windowed items (loaded around current position)
List<PlexMetadata> _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<PlexMetadata> 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<void> 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<PlexMetadata> 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<PlexMetadata> 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<PlexMetadata> 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<bool> _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<PlexMetadata?> 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<PlexMetadata?> 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;
}
@@ -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<T extends StatefulWidget>
extends State<T>
with Refreshable, ItemUpdatable {
// State properties - concrete implementations to avoid duplication
List<PlexMetadata> 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<void> loadItems();
/// Play all items in the list
Future<void> playItems() => _playWithShuffle(false);
/// Shuffle play all items in the list
Future<void> shufflePlayItems() => _playWithShuffle(true);
/// Internal helper to play items with optional shuffle
Future<void> _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();
}
}
+236
View File
@@ -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<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
}
class _CollectionDetailScreenState
extends BaseMediaListDetailScreen<CollectionDetailScreen> {
@override
PlexMetadata get mediaItem => widget.collection;
@override
String get title => widget.collection.title;
@override
String get emptyMessage => t.collections.empty;
@override
Future<void> 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<void> _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<SettingsProvider>(
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),
);
},
),
),
],
),
);
}
}
+7 -39
View File
@@ -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<DiscoverScreen>
context,
listen: false,
);
final plexClientProvider = Provider.of<PlexClientProvider>(
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<DiscoverScreen>
],
// 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(
+3 -47
View File
@@ -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<HubDetailScreen> with Refreshable {
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context,
context.watch<SettingsProvider>().libraryDensity,
16,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
@@ -313,49 +314,4 @@ class _HubDetailScreenState extends State<HubDetailScreen> 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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
}
class _LibraryBrowseTabState extends State<LibraryBrowseTab>
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<PlexMetadata> _items = [];
List<PlexFilter> _filters = [];
List<PlexSort> _sortOptions = [];
bool _isLoading = false;
String? _errorMessage;
Map<String, String> _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<void> _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<void> _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<PlexClientProvider>().client;
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
// Build filter params
final filterParams = Map<String, String>.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<String> _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<String>(
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<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 300 &&
_hasMoreItems &&
!_isLoading) {
_loadItems(loadMore: true);
}
return false;
},
child: Consumer<SettingsProvider>(
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,
);
},
);
}
},
),
);
}
}
@@ -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<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
}
class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
with AutomaticKeepAliveClientMixin, Refreshable {
@override
bool get wantKeepAlive => true;
@override
void refresh() {
_loadCollections();
}
List<PlexMetadata> _collections = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _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<void> _loadCollections() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final client = context.read<PlexClientProvider>().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<PlexMetadata>(
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),
),
);
}
}
@@ -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<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
}
class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
with AutomaticKeepAliveClientMixin, Refreshable {
@override
bool get wantKeepAlive => true;
@override
void refresh() {
_loadPlaylists();
}
List<PlexPlaylist> _playlists = [];
bool _isLoading = false;
String? _errorMessage;
StreamSubscription<void>? _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<void> _loadPlaylists() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final client = context.read<PlexClientProvider>().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<PlexPlaylist>(
isLoading: _isLoading,
errorMessage: _errorMessage,
items: _playlists,
emptyIcon: Icons.playlist_play,
emptyMessage: t.playlists.noPlaylists,
onRetry: _loadPlaylists,
builder: (items) => RefreshIndicator(
onRefresh: _loadPlaylists,
child: Consumer<SettingsProvider>(
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,
);
},
);
}
},
),
),
);
}
}
@@ -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<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
}
class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
with AutomaticKeepAliveClientMixin, Refreshable {
@override
bool get wantKeepAlive => true;
@override
void refresh() {
_loadHubs();
}
List<PlexHub> _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<void> _loadHubs() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final client = context.read<PlexClientProvider>().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<PlexHub>(
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));
},
),
),
);
}
}
+3 -14
View File
@@ -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<MediaDetailScreen> {
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<MediaDetailScreen> {
return Card(
clipBehavior: Clip.antiAlias,
child: MediaContextMenu(
metadata: season,
item: season,
onRefresh: (ratingKey) {
_watchStateChanged = true;
_updateWatchState();
@@ -1122,18 +1123,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
);
}
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') {
+113 -207
View File
@@ -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<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
with Refreshable, ItemUpdatable {
class _PlaylistDetailScreenState
extends BaseMediaListDetailScreen<PlaylistDetailScreen> {
@override
PlexClient get client => context.clientSafe;
List<PlexMetadata> _items = [];
bool _isLoading = false;
String? _errorMessage;
dynamic get mediaItem => widget.playlist;
@override
void initState() {
super.initState();
_loadPlaylistItems();
}
String get title => widget.playlist.title;
Future<void> _loadPlaylistItems() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
@override
String get emptyMessage => t.playlists.emptyPlaylist;
@override
Future<void> 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<void> _deletePlaylist() async {
final confirmed = await showDialog<bool>(
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<PlaylistDetailScreen>
// 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<PlaylistDetailScreen>
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<PlaylistDetailScreen>
// 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<PlaylistDetailScreen>
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<PlaylistDetailScreen>
}
Future<void> _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<PlaylistDetailScreen>
// Optimistically update UI
setState(() {
_items.removeAt(index);
items.removeAt(index);
});
// Call API to persist the change
@@ -224,7 +207,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
// 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<PlaylistDetailScreen>
}
}
@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<void> _playPlaylist() async {
if (_items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
}
return;
}
final playbackState = context.read<PlaybackStateProvider>();
// 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<void> _shufflePlayPlaylist() async {
if (_items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
}
return;
}
final playbackState = context.read<PlaybackStateProvider>();
// Shuffle the items
final shuffledItems = List<PlexMetadata>.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<void> _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<PlaybackStateProvider>();
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<PlaybackStateProvider>();
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<PlaylistDetailScreen>
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<PlaylistDetailScreen>
),
],
),
if (_errorMessage != null)
if (errorMessage != null)
SliverFillRemaining(
child: Center(
child: Column(
@@ -379,21 +349,21 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
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<PlaylistDetailScreen>
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
@@ -428,15 +398,15 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
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<PlaylistDetailScreen>
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;
}
}
}
-398
View File
@@ -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<PlaylistsScreen> createState() => _PlaylistsScreenState();
}
class _PlaylistsScreenState extends State<PlaylistsScreen> with Refreshable {
PlexClient get client => context.clientSafe;
List<PlexPlaylist> _playlists = [];
bool _isLoading = false;
String? _errorMessage;
bool? _filterSmart;
@override
void initState() {
super.initState();
_loadPlaylists();
}
Future<void> _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<SettingsProvider>().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<void> _showDeleteDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
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),
),
);
}
}
+3 -46
View File
@@ -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<SearchScreen>
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<SearchScreen>
),
);
}
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;
}
}
}
+5 -15
View File
@@ -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<SeasonDetailScreen>
: 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<SeasonDetailScreen>
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<SeasonDetailScreen>
),
);
}
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')}';
}
}
}
+8
View File
@@ -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<ServerSelectionScreen> {
}
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,
+50 -4
View File
@@ -63,12 +63,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
StreamSubscription<bool>? _completedSubscription;
StreamSubscription<Duration>? _positionSubscription;
StreamSubscription<dynamic>? _mediaControlSubscription;
StreamSubscription<bool>? _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<VideoPlayerScreen>
appLogger.d('Preferred subtitle track: $subtitleDesc');
}
// Update current item in playback state provider
try {
final playbackState = context.read<PlaybackStateProvider>();
// 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<VideoPlayerScreen>
_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<VideoPlayerScreen>
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<VideoPlayerScreen>
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<VideoPlayerScreen>
_errorSubscription?.cancel();
_positionSubscription?.cancel();
_mediaControlSubscription?.cancel();
_bufferingSubscription?.cancel();
// Clear OS media controls completely
OsMediaControls.clear();
@@ -1970,6 +1999,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
),
),
),
// 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,
),
),
),
),
],
),
),
+54 -10
View File
@@ -168,13 +168,27 @@ class StorageService {
}
// Library Filters (stored as JSON string)
Future<void> saveLibraryFilters(Map<String, String> filters) async {
Future<void> saveLibraryFilters(
Map<String, String> 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<String, String> getLibraryFilters() {
final jsonString = _prefs.getString(_keyLibraryFilters);
Map<String, String> 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<void> saveLibrarySort(String sectionId, String sortKey) async {
await _prefs.setString('library_sort_$sectionId', sortKey);
// Library Sort (per-library, stored individually with descending flag)
Future<void> 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<String, dynamic>? getLibrarySort(String sectionId) {
final jsonString = _prefs.getString('library_sort_$sectionId');
if (jsonString == null) return null;
try {
return json.decode(jsonString) as Map<String, dynamic>;
} 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<void> 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<void> 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)
+12 -22
View File
@@ -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,
@@ -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<void> 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<PlaybackStateProvider>();
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<PlaybackStateProvider>();
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(),
),
),
),
);
}
}
}
+33
View File
@@ -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<bool> showDeleteConfirmation(
BuildContext context, {
required String title,
required String message,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.common.delete),
),
],
),
);
return confirmed ?? false;
}
+92
View File
@@ -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();
}
}
+26
View File
@@ -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());
}
+51
View File
@@ -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;
}
}
+54
View File
@@ -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;
}
}
+39
View File
@@ -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<void>.broadcast();
final _playlistsController = StreamController<void>.broadcast();
// Streams that tabs can listen to
Stream<void> get collectionsStream => _collectionsController.stream;
Stream<void> 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();
}
}
+1
View File
@@ -90,6 +90,7 @@ Future<void> handleShufflePlay(
episodes.shuffle();
// Store shuffle queue in provider
// ignore: deprecated_member_use_from_same_package
playbackState.setShuffleQueue(episodes, metadata.ratingKey);
// Navigate to first episode
+76
View File
@@ -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<PlexMetadata> 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<SettingsProvider>(
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,
);
},
);
}
},
);
}
}
+20 -2
View File
@@ -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<AppBarBackButton> createState() => _AppBarBackButtonState();
}
@@ -124,7 +129,7 @@ class _AppBarBackButtonState extends State<AppBarBackButton>
break;
}
final button = MouseRegion(
final buttonWidget = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => _onHoverChange(true),
onExit: (_) => _onHoverChange(false),
@@ -147,13 +152,26 @@ class _AppBarBackButtonState extends State<AppBarBackButton>
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;
+83
View File
@@ -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<T> 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<T> 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<T> 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);
}
}
+65
View File
@@ -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!),
),
],
],
),
),
);
}
}
+57
View File
@@ -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'),
),
],
],
),
),
);
}
}
+320
View File
@@ -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<PlexFilter> filters;
final Map<String, String> selectedFilters;
final Function(Map<String, String>) onFiltersChanged;
const FiltersBottomSheet({
super.key,
required this.filters,
required this.selectedFilters,
required this.onFiltersChanged,
});
@override
State<FiltersBottomSheet> createState() => _FiltersBottomSheetState();
}
class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
PlexFilter? _currentFilter;
List<PlexFilterValue> _filterValues = [];
bool _isLoadingValues = false;
final Map<String, String> _tempSelectedFilters = {};
final Map<String, String> _filterDisplayNames = {}; // Cache for display names
late List<PlexFilter> _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<void> _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),
);
},
),
),
],
);
},
);
}
}
+128
View File
@@ -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),
),
),
],
),
),
);
}
}
+265
View File
@@ -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<FolderTreeView> createState() => _FolderTreeViewState();
}
class _FolderTreeViewState extends State<FolderTreeView> {
List<PlexMetadata> _rootFolders = [];
final Map<String, List<PlexMetadata>> _childrenCache = {};
final Set<String> _expandedFolders = {};
final Set<String> _loadingFolders = {};
bool _isLoadingRoot = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadRootFolders();
}
Future<void> _loadRootFolders() async {
setState(() {
_isLoadingRoot = true;
_errorMessage = null;
});
try {
final clientProvider = context.read<PlexClientProvider>();
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<void> _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<PlexClientProvider>();
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<void> _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<bool>(
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<Widget> _buildTreeItems(
List<PlexMetadata> items,
int depth, [
String parentPath = '',
]) {
final List<Widget> 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)),
);
}
}
+133
View File
@@ -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),
),
),
],
);
}
}
+275 -179
View File
@@ -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<MediaCard> {
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<bool>(
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<MediaCard> {
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<SettingsProvider>().useSeasonPoster;
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
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 = <String>[];
// 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<SettingsProvider>().useSeasonPoster;
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
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<PlexClientProvider>(
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<Color>(
Theme.of(context).colorScheme.primary,
File diff suppressed because it is too large Load Diff
+2 -13
View File
@@ -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';
}
}
}
+6 -8
View File
@@ -39,6 +39,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_currentDescending = descending;
});
widget.onSortChanged(sort, descending);
Navigator.pop(context);
}
void _handleClear() {
@@ -69,10 +70,10 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
),
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<SortBottomSheet> {
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<SortBottomSheet> {
: null,
leading: Radio<PlexSort>(value: sort, toggleable: false),
onTap: () {
_handleSortChange(
sort,
sort.defaultDirection == 'desc',
);
_handleSortChange(sort, sort.isDefaultDescending);
},
);
},
@@ -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<AudioSyncSheet> {
_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<AudioSyncSheet> {
children: [
// Current offset display
Text(
_formatOffset(_currentOffset),
formatSyncOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
@@ -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<Track>(
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<Track>(
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 = <String>[];
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 = <String>[];
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);
},
);
},
),
),
],
),
),
);
},
),
);
},
);
@@ -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<T?> showSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
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),
),
],
),
);
}
}
@@ -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<PlexChapter> 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<Duration>(
@@ -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<PlexClientProvider>(
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<PlexClientProvider>(
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,
);
},
);
@@ -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);
},
);
},
),
);
},
@@ -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),
),
);
},
@@ -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<Track>(
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<Track>(
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 = <String>[];
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 = <String>[];
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);
},
);
},
),
),
],
),
),
);
},
),
);
},
);
@@ -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<PlexMediaVersion> 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);
},
);
},
),
);
}
@@ -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<void> 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<VideoSettingsSheet> {
}
}
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
? 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<VideoSettingsSheet> {
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),
);
}
+5 -16
View File
@@ -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<PlexVideoControls>
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<PlexVideoControls>
return Row(
children: [
Text(
_formatDuration(position),
formatDurationTimestamp(position),
style: const TextStyle(
color: Colors.white,
fontSize: 14,
@@ -1357,7 +1358,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
),
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<PlexVideoControls>
}
}
}
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')}';
}
}
}
@@ -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();
},
),
],
),
],
),
);
}
}
@@ -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,
),
),
],
);
},
);
}
}
@@ -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),
),
);
},
);
},
);
}
}
@@ -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<SyncOffsetControl> {
_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<SyncOffsetControl> {
children: [
// Current offset display
Text(
_formatOffset(_currentOffset),
formatSyncOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
+8
View File
@@ -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:
+2 -1
View File
@@ -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: