From b5349d7b3d111c303e74c204043cb113627ad3b8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 04:17:39 +0100 Subject: [PATCH] feat: library density settings also add season/show poster toggle for episodes --- lib/main.dart | 6 ++ lib/models/plex_metadata.dart | 19 ++++-- lib/models/plex_metadata.g.dart | 2 + lib/providers/settings_provider.dart | 49 +++++++++++++++ lib/screens/libraries_screen.dart | 54 ++++++++++++++++- lib/screens/search_screen.dart | 55 ++++++++++++++++- lib/screens/settings_screen.dart | 91 ++++++++++++++++++++++++++++ lib/services/settings_service.dart | 30 +++++++++ lib/widgets/media_card.dart | 7 ++- 9 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 lib/providers/settings_provider.dart diff --git a/lib/main.dart b/lib/main.dart index 28beb0ff..b1facf24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'services/fullscreen_state_manager.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; +import 'providers/settings_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; @@ -21,6 +22,10 @@ import 'utils/orientation_helper.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Configure image cache for large libraries + PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB + PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images + // Initialize window_manager for desktop platforms if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { await windowManager.ensureInitialized(); @@ -62,6 +67,7 @@ class MainApp extends StatelessWidget { create: (context) => UserProfileProvider()..initialize(), ), ChangeNotifierProvider(create: (context) => ThemeProvider()), + ChangeNotifierProvider(create: (context) => SettingsProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4d5d8f39..5acb3d9a 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -24,6 +24,7 @@ class PlexMetadata { final String? grandparentArt; // Show art for episodes final String? grandparentRatingKey; // Show rating key for episodes final String? parentTitle; // Season title for episodes + final String? parentThumb; // Season poster for episodes final String? parentRatingKey; // Season rating key for episodes final int? parentIndex; // Season number final int? index; // Episode number @@ -58,6 +59,7 @@ class PlexMetadata { this.grandparentArt, this.grandparentRatingKey, this.parentTitle, + this.parentThumb, this.parentRatingKey, this.parentIndex, this.index, @@ -121,12 +123,21 @@ class PlexMetadata { } // Helper to get the poster (show poster for episodes/seasons, thumb otherwise) - String? get posterThumb { + // If useSeasonPoster is true, episodes will use season poster instead of series poster + String? posterThumb({bool useSeasonPoster = false}) { final itemType = type.toLowerCase(); - // For episodes and seasons, prefer grandparent thumb (show poster) - if ((itemType == 'episode' || itemType == 'season') && - grandparentThumb != null) { + if (itemType == 'episode') { + // If season poster is enabled and available, use it + if (useSeasonPoster && parentThumb != null) { + return parentThumb!; + } + // Otherwise fall back to series poster, then item thumb + if (grandparentThumb != null) { + return grandparentThumb!; + } + } else if (itemType == 'season' && grandparentThumb != null) { + // For seasons, always use series poster return grandparentThumb!; } return thumb; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 1d0901ac..27559413 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -27,6 +27,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( grandparentArt: json['grandparentArt'] as String?, grandparentRatingKey: json['grandparentRatingKey'] as String?, parentTitle: json['parentTitle'] as String?, + parentThumb: json['parentThumb'] as String?, parentRatingKey: json['parentRatingKey'] as String?, parentIndex: (json['parentIndex'] as num?)?.toInt(), index: (json['index'] as num?)?.toInt(), @@ -59,6 +60,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'grandparentArt': instance.grandparentArt, 'grandparentRatingKey': instance.grandparentRatingKey, 'parentTitle': instance.parentTitle, + 'parentThumb': instance.parentThumb, 'parentRatingKey': instance.parentRatingKey, 'parentIndex': instance.parentIndex, 'index': instance.index, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart new file mode 100644 index 00000000..1a1be3b2 --- /dev/null +++ b/lib/providers/settings_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; + +class SettingsProvider extends ChangeNotifier { + late SettingsService _settingsService; + LibraryDensity _libraryDensity = LibraryDensity.normal; + bool _useSeasonPoster = false; + + SettingsProvider() { + _initializeSettings(); + } + + Future _initializeSettings() async { + _settingsService = await SettingsService.getInstance(); + _libraryDensity = _settingsService.getLibraryDensity(); + _useSeasonPoster = _settingsService.getUseSeasonPoster(); + notifyListeners(); + } + + LibraryDensity get libraryDensity => _libraryDensity; + bool get useSeasonPoster => _useSeasonPoster; + + Future setLibraryDensity(LibraryDensity density) async { + if (_libraryDensity != density) { + _libraryDensity = density; + await _settingsService.setLibraryDensity(density); + notifyListeners(); + } + } + + Future setUseSeasonPoster(bool value) async { + if (_useSeasonPoster != value) { + _useSeasonPoster = value; + await _settingsService.setUseSeasonPoster(value); + notifyListeners(); + } + } + + String get libraryDensityDisplayName { + switch (_libraryDensity) { + case LibraryDensity.compact: + return 'Compact'; + case LibraryDensity.normal: + return 'Normal'; + case LibraryDensity.comfortable: + return 'Comfortable'; + } + } +} diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index bd2b4cc6..a44cf7f9 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -5,11 +5,13 @@ import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; import '../models/plex_filter.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; import '../services/storage_service.dart'; +import '../services/settings_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; @@ -421,8 +423,11 @@ class _LibrariesScreenState extends State SliverPadding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, mainAxisSpacing: 0, @@ -457,6 +462,51 @@ class _LibrariesScreenState extends State return Icons.folder; } } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; // 8px left + 8px right + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } } class _FiltersBottomSheet extends StatefulWidget { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 588353e3..fe2a2d7a 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,7 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../services/settings_service.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; @@ -213,8 +216,11 @@ class _SearchScreenState extends State SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 180, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, mainAxisSpacing: 8, @@ -234,4 +240,49 @@ class _SearchScreenState extends State ), ); } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 32.0; // 16px left + 16px right from SliverPadding + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ac32a9ed..d9c7a72c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../providers/theme_provider.dart'; +import '../providers/settings_provider.dart'; import '../services/settings_service.dart' as settings; import '../services/keyboard_shortcuts_service.dart'; import '../widgets/desktop_app_bar.dart'; @@ -99,6 +100,30 @@ class _SettingsScreenState extends State { ); }, ), + Consumer( + builder: (context, settingsProvider, child) { + return ListTile( + leading: const Icon(Icons.grid_view), + title: const Text('Library Density'), + subtitle: Text(settingsProvider.libraryDensityDisplayName), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLibraryDensityDialog(), + ); + }, + ), + Consumer( + builder: (context, settingsProvider, child) { + return SwitchListTile( + secondary: const Icon(Icons.image), + title: const Text('Use Season Posters'), + subtitle: const Text('Show season poster instead of series poster for episodes'), + value: settingsProvider.useSeasonPoster, + onChanged: (value) async { + await settingsProvider.setUseSeasonPoster(value); + }, + ); + }, + ), ], ), ); @@ -410,6 +435,72 @@ class _SettingsScreenState extends State { }, ); } + + void _showLibraryDensityDialog() { + final settingsProvider = context.read(); + showDialog( + context: context, + builder: (BuildContext context) { + return Consumer( + builder: (context, provider, child) { + return AlertDialog( + title: const Text('Library Density'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + provider.libraryDensity == settings.LibraryDensity.compact + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Compact'), + subtitle: const Text('Smaller cards, more items visible'), + onTap: () async { + await settingsProvider.setLibraryDensity(settings.LibraryDensity.compact); + if (context.mounted) Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + provider.libraryDensity == settings.LibraryDensity.normal + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Normal'), + subtitle: const Text('Default size'), + onTap: () async { + await settingsProvider.setLibraryDensity(settings.LibraryDensity.normal); + if (context.mounted) Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + provider.libraryDensity == settings.LibraryDensity.comfortable + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Comfortable'), + subtitle: const Text('Larger cards, fewer items visible'), + onTap: () async { + await settingsProvider.setLibraryDensity(settings.LibraryDensity.comfortable); + if (context.mounted) Navigator.pop(context); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); + } } class _KeyboardShortcutsScreen extends StatefulWidget { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 01668be8..ad28b843 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -5,6 +5,8 @@ import 'package:hotkey_manager/hotkey_manager.dart'; enum ThemeMode { system, light, dark } +enum LibraryDensity { compact, normal, comfortable } + class SettingsService { static const String _keyThemeMode = 'theme_mode'; static const String _keyEnableDebugLogging = 'enable_debug_logging'; @@ -14,6 +16,8 @@ class SettingsService { static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding'; static const String _keyPreferredVideoCodec = 'preferred_video_codec'; static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; + static const String _keyLibraryDensity = 'library_density'; + static const String _keyUseSeasonPoster = 'use_season_poster'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -91,6 +95,28 @@ class SettingsService { return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; } + // Library Density + Future setLibraryDensity(LibraryDensity density) async { + await _prefs.setString(_keyLibraryDensity, density.name); + } + + LibraryDensity getLibraryDensity() { + final densityString = _prefs.getString(_keyLibraryDensity); + return LibraryDensity.values.firstWhere( + (density) => density.name == densityString, + orElse: () => LibraryDensity.normal, + ); + } + + // Use Season Poster + Future setUseSeasonPoster(bool enabled) async { + await _prefs.setBool(_keyUseSeasonPoster, enabled); + } + + bool getUseSeasonPoster() { + return _prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster) + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { @@ -553,6 +579,8 @@ class SettingsService { _prefs.remove(_keyEnableHardwareDecoding), _prefs.remove(_keyPreferredVideoCodec), _prefs.remove(_keyPreferredAudioCodec), + _prefs.remove(_keyLibraryDensity), + _prefs.remove(_keyUseSeasonPoster), ]); } @@ -575,6 +603,8 @@ class SettingsService { 'enableHardwareDecoding': getEnableHardwareDecoding(), 'preferredVideoCodec': getPreferredVideoCodec(), 'preferredAudioCodec': getPreferredAudioCodec(), + 'libraryDensity': getLibraryDensity().name, + 'useSeasonPoster': getUseSeasonPoster(), 'keyboardShortcuts': getKeyboardShortcuts(), 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index de5030e6..89e029fe 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../screens/media_detail_screen.dart'; @@ -172,7 +173,9 @@ class _MediaCardState extends State { } Widget _buildPosterImage(BuildContext context) { - if (widget.item.posterThumb != null) { + final useSeasonPoster = context.watch().useSeasonPoster; + final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster); + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; @@ -185,7 +188,7 @@ class _MediaCardState extends State { } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(widget.item.posterThumb), + imageUrl: client.getThumbnailUrl(posterUrl), fit: BoxFit.cover, width: double.infinity, height: double.infinity,