refactor: deduplicate & other code quality fixes

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