feat: library density settings

also add season/show poster toggle for episodes
This commit is contained in:
edde746
2025-11-04 04:17:39 +01:00
parent 3a5b812a22
commit b5349d7b3d
9 changed files with 303 additions and 10 deletions
+6
View File
@@ -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<ThemeProvider>(
builder: (context, themeProvider, child) {
+15 -4
View File
@@ -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;
+2
View File
@@ -27,6 +27,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> 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<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
+49
View File
@@ -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<void> _initializeSettings() async {
_settingsService = await SettingsService.getInstance();
_libraryDensity = _settingsService.getLibraryDensity();
_useSeasonPoster = _settingsService.getUseSeasonPoster();
notifyListeners();
}
LibraryDensity get libraryDensity => _libraryDensity;
bool get useSeasonPoster => _useSeasonPoster;
Future<void> setLibraryDensity(LibraryDensity density) async {
if (_libraryDensity != density) {
_libraryDensity = density;
await _settingsService.setLibraryDensity(density);
notifyListeners();
}
}
Future<void> 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';
}
}
}
+52 -2
View File
@@ -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<LibrariesScreen>
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
@@ -457,6 +462,51 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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 {
+53 -2
View File
@@ -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<SearchScreen>
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
@@ -234,4 +240,49 @@ class _SearchScreenState extends State<SearchScreen>
),
);
}
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
final screenWidth = MediaQuery.of(context).size.width;
final padding = 32.0; // 16px left + 16px right from SliverPadding
final availableWidth = screenWidth - padding;
if (screenWidth >= 900) {
// Wide screens (desktop/large tablet landscape): Responsive division
double divisor;
double maxItemWidth;
switch (density) {
case LibraryDensity.comfortable:
divisor = 6.5;
maxItemWidth = 280;
break;
case LibraryDensity.normal:
divisor = 8.0;
maxItemWidth = 200;
break;
case LibraryDensity.compact:
divisor = 10.0;
maxItemWidth = 160;
break;
}
return (availableWidth / divisor).clamp(0, maxItemWidth);
} else if (screenWidth >= 600) {
// Medium screens (tablets): Fixed 4-5-6 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 4,
LibraryDensity.normal => 5,
LibraryDensity.compact => 6,
};
return availableWidth / targetItemCount;
} else {
// Small screens (phones): Fixed 2-3-4 items
int targetItemCount = switch (density) {
LibraryDensity.comfortable => 2,
LibraryDensity.normal => 3,
LibraryDensity.compact => 4,
};
return availableWidth / targetItemCount;
}
}
}
+91
View File
@@ -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<SettingsScreen> {
);
},
),
Consumer<SettingsProvider>(
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<SettingsProvider>(
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<SettingsScreen> {
},
);
}
void _showLibraryDensityDialog() {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
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 {
+30
View File
@@ -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<void> 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<void> 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<String, String> 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)),
+5 -2
View File
@@ -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<MediaCard> {
}
Widget _buildPosterImage(BuildContext context) {
if (widget.item.posterThumb != null) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
@@ -185,7 +188,7 @@ class _MediaCardState extends State<MediaCard> {
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(widget.item.posterThumb),
imageUrl: client.getThumbnailUrl(posterUrl),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,