feat: list mode

This commit is contained in:
edde746
2025-11-05 13:26:02 +01:00
parent d51c3ff607
commit bb97e5a418
7 changed files with 585 additions and 117 deletions
+11
View File
@@ -4,6 +4,7 @@ import '../services/settings_service.dart';
class SettingsProvider extends ChangeNotifier {
late SettingsService _settingsService;
LibraryDensity _libraryDensity = LibraryDensity.normal;
ViewMode _viewMode = ViewMode.grid;
bool _useSeasonPoster = false;
bool _showHeroSection = true;
@@ -14,12 +15,14 @@ class SettingsProvider extends ChangeNotifier {
Future<void> _initializeSettings() async {
_settingsService = await SettingsService.getInstance();
_libraryDensity = _settingsService.getLibraryDensity();
_viewMode = _settingsService.getViewMode();
_useSeasonPoster = _settingsService.getUseSeasonPoster();
_showHeroSection = _settingsService.getShowHeroSection();
notifyListeners();
}
LibraryDensity get libraryDensity => _libraryDensity;
ViewMode get viewMode => _viewMode;
bool get useSeasonPoster => _useSeasonPoster;
bool get showHeroSection => _showHeroSection;
@@ -31,6 +34,14 @@ class SettingsProvider extends ChangeNotifier {
}
}
Future<void> setViewMode(ViewMode mode) async {
if (_viewMode != mode) {
_viewMode = mode;
await _settingsService.setViewMode(mode);
notifyListeners();
}
}
Future<void> setUseSeasonPoster(bool value) async {
if (_useSeasonPoster != value) {
_useSeasonPoster = value;
+1
View File
@@ -1291,6 +1291,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
width: cardWidth,
height: posterHeight,
onRefresh: updateItem,
forceGridMode: true,
),
);
},
+43 -20
View File
@@ -953,27 +953,50 @@ class _LibrariesScreenState extends State<LibrariesScreen>
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
childCount: _items.length,
),
),
);
}, childCount: _items.length),
),
} else {
return SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _items[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _items.length),
),
);
}
},
),
],
],
+43 -20
View File
@@ -213,27 +213,50 @@ class _SearchScreenState extends State<SearchScreen>
),
)
else
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
context.watch<SettingsProvider>().libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
},
childCount: _searchResults.length,
),
),
);
}, childCount: _searchResults.length),
),
} else {
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
context,
settingsProvider.libraryDensity,
),
childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return MediaCard(
key: Key(item.ratingKey),
item: item,
onRefresh: updateItem,
);
}, childCount: _searchResults.length),
),
);
}
},
),
],
),
+68
View File
@@ -126,6 +126,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
leading: const Icon(Icons.view_list),
title: const Text('View Mode'),
subtitle: Text(settingsProvider.viewMode == settings.ViewMode.grid ? 'Grid' : 'List'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showViewModeDialog(),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
@@ -827,6 +838,63 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
);
}
void _showViewModeDialog() {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
return AlertDialog(
title: const Text('View Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
provider.viewMode == settings.ViewMode.grid
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Grid'),
subtitle: const Text('Display items in a grid layout'),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.grid,
);
if (context.mounted) Navigator.pop(context);
},
),
ListTile(
leading: Icon(
provider.viewMode == settings.ViewMode.list
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('List'),
subtitle: const Text('Display items in a list layout'),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.list,
);
if (context.mounted) Navigator.pop(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
);
},
);
},
);
}
}
class _KeyboardShortcutsScreen extends StatefulWidget {
+18
View File
@@ -8,6 +8,8 @@ enum ThemeMode { system, light, dark }
enum LibraryDensity { compact, normal, comfortable }
enum ViewMode { grid, list }
class SettingsService {
static const String _keyThemeMode = 'theme_mode';
static const String _keyEnableDebugLogging = 'enable_debug_logging';
@@ -18,6 +20,7 @@ class SettingsService {
static const String _keyPreferredVideoCodec = 'preferred_video_codec';
static const String _keyPreferredAudioCodec = 'preferred_audio_codec';
static const String _keyLibraryDensity = 'library_density';
static const String _keyViewMode = 'view_mode';
static const String _keyUseSeasonPoster = 'use_season_poster';
static const String _keySeekTimeSmall = 'seek_time_small';
static const String _keySeekTimeLarge = 'seek_time_large';
@@ -115,6 +118,19 @@ class SettingsService {
);
}
// View Mode
Future<void> setViewMode(ViewMode mode) async {
await _prefs.setString(_keyViewMode, mode.name);
}
ViewMode getViewMode() {
final modeString = _prefs.getString(_keyViewMode);
return ViewMode.values.firstWhere(
(mode) => mode.name == modeString,
orElse: () => ViewMode.grid,
);
}
// Use Season Poster
Future<void> setUseSeasonPoster(bool enabled) async {
await _prefs.setBool(_keyUseSeasonPoster, enabled);
@@ -660,6 +676,7 @@ class SettingsService {
_prefs.remove(_keyPreferredVideoCodec),
_prefs.remove(_keyPreferredAudioCodec),
_prefs.remove(_keyLibraryDensity),
_prefs.remove(_keyViewMode),
_prefs.remove(_keyUseSeasonPoster),
_prefs.remove(_keyShowHeroSection),
_prefs.remove(_keySeekTimeSmall),
@@ -688,6 +705,7 @@ class SettingsService {
'preferredVideoCodec': getPreferredVideoCodec(),
'preferredAudioCodec': getPreferredAudioCodec(),
'libraryDensity': getLibraryDensity().name,
'viewMode': getViewMode().name,
'useSeasonPoster': getUseSeasonPoster(),
'seekTimeSmall': getSeekTimeSmall(),
'seekTimeLarge': getSeekTimeLarge(),
+401 -77
View File
@@ -4,8 +4,10 @@ import 'package:provider/provider.dart';
import '../models/plex_metadata.dart';
import '../providers/plex_client_provider.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/content_rating_formatter.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../theme/theme_helper.dart';
@@ -16,6 +18,7 @@ class MediaCard extends StatefulWidget {
final double? width;
final double? height;
final void Function(String ratingKey)? onRefresh;
final bool forceGridMode;
const MediaCard({
super.key,
@@ -23,6 +26,7 @@ class MediaCard extends StatefulWidget {
this.width,
this.height,
this.onRefresh,
this.forceGridMode = false,
});
@override
@@ -84,88 +88,124 @@ class _MediaCardState extends State<MediaCard> {
}
}
@override
Widget build(BuildContext context) {
final settingsProvider = context.watch<SettingsProvider>();
final viewMode = widget.forceGridMode
? ViewMode.grid
: settingsProvider.viewMode;
return MediaContextMenu(
metadata: widget.item,
onRefresh: widget.onRefresh,
onTap: () => _handleTap(context),
child: viewMode == ViewMode.grid
? _MediaCardGrid(
item: widget.item,
width: widget.width,
height: widget.height,
onTap: () => _handleTap(context),
)
: _MediaCardList(
item: widget.item,
onTap: () => _handleTap(context),
density: settingsProvider.libraryDensity,
),
);
}
}
/// Grid layout for media cards
class _MediaCardGrid extends StatelessWidget {
final PlexMetadata item;
final double? width;
final double? height;
final VoidCallback onTap;
const _MediaCardGrid({
required this.item,
this.width,
this.height,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
child: MediaContextMenu(
metadata: widget.item,
onRefresh: widget.onRefresh,
onTap: () => _handleTap(context),
child: Semantics(
label: "media-card-${widget.item.ratingKey}",
identifier: "media-card-${widget.item.ratingKey}",
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
if (widget.height != null)
SizedBox(
width: double.infinity,
height: widget.height,
child: _buildPosterWithOverlay(context),
)
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
width: width,
child: Semantics(
label: "media-card-${item.ratingKey}",
identifier: "media-card-${item.ratingKey}",
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
if (height != null)
SizedBox(
width: double.infinity,
height: height,
child: _buildPosterWithOverlay(context),
)
else
Expanded(child: _buildPosterWithOverlay(context)),
const SizedBox(height: 4),
// Text content
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
item.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
),
if (item.displaySubtitle != null)
Text(
widget.item.displayTitle,
item.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
height: 1.1,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (item.parentTitle != null)
Text(
item.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (item.year != null)
Text(
'${item.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
),
if (widget.item.displaySubtitle != null)
Text(
widget.item.displaySubtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.parentTitle != null)
Text(
widget.item.parentTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
)
else if (widget.item.year != null)
Text(
'${widget.item.year}',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
),
],
),
],
),
],
),
],
),
),
),
@@ -180,14 +220,298 @@ class _MediaCardState extends State<MediaCard> {
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context),
),
_PosterOverlay(item: widget.item),
_PosterOverlay(item: item),
],
);
}
Widget _buildPosterImage(BuildContext context) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster);
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
if (client == null) {
return const SkeletonLoader(
child: Center(
child: Icon(Icons.movie, size: 40, color: Colors.white54),
),
);
}
return CachedNetworkImage(
imageUrl: client.getThumbnailUrl(posterUrl),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
filterQuality: FilterQuality.medium,
fadeInDuration: const Duration(milliseconds: 300),
placeholder: (context, url) => const SkeletonLoader(),
errorWidget: (context, url, error) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.broken_image, size: 40)),
),
);
},
);
} else {
return const SkeletonLoader(
child: Center(
child: Icon(Icons.movie, size: 40, color: Colors.white54),
),
);
}
}
}
/// List layout for media cards
class _MediaCardList extends StatelessWidget {
final PlexMetadata item;
final VoidCallback onTap;
final LibraryDensity density;
const _MediaCardList({
required this.item,
required this.onTap,
required this.density,
});
double get _posterWidth {
switch (density) {
case LibraryDensity.compact:
return 80;
case LibraryDensity.normal:
return 100;
case LibraryDensity.comfortable:
return 120;
}
}
double get _posterHeight {
return _posterWidth * 1.5; // Maintain 2:3 aspect ratio
}
double get _titleFontSize {
switch (density) {
case LibraryDensity.compact:
return 14;
case LibraryDensity.normal:
return 15;
case LibraryDensity.comfortable:
return 16;
}
}
double get _metadataFontSize {
switch (density) {
case LibraryDensity.compact:
return 11;
case LibraryDensity.normal:
return 12;
case LibraryDensity.comfortable:
return 13;
}
}
double get _subtitleFontSize {
switch (density) {
case LibraryDensity.compact:
return 12;
case LibraryDensity.normal:
return 13;
case LibraryDensity.comfortable:
return 14;
}
}
double get _summaryFontSize {
switch (density) {
case LibraryDensity.compact:
return 11;
case LibraryDensity.normal:
return 12;
case LibraryDensity.comfortable:
return 13;
}
}
int get _summaryMaxLines {
switch (density) {
case LibraryDensity.compact:
return 2;
case LibraryDensity.normal:
return 3;
case LibraryDensity.comfortable:
return 4;
}
}
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
String _buildMetadataLine() {
final parts = <String>[];
// Add content rating
if (item.contentRating != null && item.contentRating!.isNotEmpty) {
final rating = formatContentRating(item.contentRating);
if (rating.isNotEmpty) {
parts.add(rating);
}
}
// Add year
if (item.year != null) {
parts.add('${item.year}');
}
// Add duration
if (item.duration != null) {
parts.add(_formatDuration(item.duration!));
}
// Add user rating
if (item.rating != null) {
parts.add('${item.rating!.toStringAsFixed(1)}');
}
// Add studio
if (item.studio != null && item.studio!.isNotEmpty) {
parts.add(item.studio!);
}
return parts.join('');
}
String? _buildSubtitleText() {
// For TV episodes, show S#E# format
if (item.parentIndex != null && item.index != null) {
return 'S${item.parentIndex} E${item.index}';
}
// Otherwise use existing subtitle logic
if (item.displaySubtitle != null) {
return item.displaySubtitle;
} else if (item.parentTitle != null) {
return item.parentTitle;
}
// Year is now shown in metadata line, so don't show it here
return null;
}
@override
Widget build(BuildContext context) {
final metadataLine = _buildMetadataLine();
final subtitle = _buildSubtitleText();
return Semantics(
label: "media-card-${item.ratingKey}",
identifier: "media-card-${item.ratingKey}",
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster (responsive size based on density)
SizedBox(
width: _posterWidth,
height: _posterHeight,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context),
),
_PosterOverlay(item: item),
],
),
),
const SizedBox(width: 12),
// Metadata
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Title
Text(
item.displayTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: _titleFontSize,
height: 1.2,
),
),
const SizedBox(height: 4),
// Metadata info line (rating, duration, score, studio)
if (metadataLine.isNotEmpty) ...[
Text(
metadataLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.9),
fontSize: _metadataFontSize,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
],
// Subtitle (S#E# or year/parent title)
if (subtitle != null) ...[
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.85),
fontSize: _subtitleFontSize,
),
),
const SizedBox(height: 4),
],
// Summary
if (item.summary != null) ...[
Text(
item.summary!,
maxLines: _summaryMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tokens(context).textMuted.withValues(alpha: 0.7),
fontSize: _summaryFontSize,
height: 1.3,
),
),
],
],
),
),
],
),
),
),
);
}
Widget _buildPosterImage(BuildContext context) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {