diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index d19820e7..cd9ffa3c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -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 _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 setViewMode(ViewMode mode) async { + if (_viewMode != mode) { + _viewMode = mode; + await _settingsService.setViewMode(mode); + notifyListeners(); + } + } + Future setUseSeasonPoster(bool value) async { if (_useSeasonPoster != value) { _useSeasonPoster = value; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b1c217f1..b55d3b50 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1291,6 +1291,7 @@ class _DiscoverScreenState extends State width: cardWidth, height: posterHeight, onRefresh: updateItem, + forceGridMode: true, ), ); }, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 6f750366..a5365662 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -953,27 +953,50 @@ class _LibrariesScreenState extends State ), ) else - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( - context, - context.watch().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( + 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), + ), + ); + } + }, ), ], ], diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index fe2a2d7a..39c26769 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -213,27 +213,50 @@ class _SearchScreenState extends State ), ) else - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( - context, - context.watch().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( + 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), + ), + ); + } + }, ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7e7bd8cf..ff9cfc9c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -126,6 +126,17 @@ class _SettingsScreenState extends State { ); }, ), + Consumer( + 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( builder: (context, settingsProvider, child) { return SwitchListTile( @@ -827,6 +838,63 @@ class _SettingsScreenState extends State { }, ); } + + void _showViewModeDialog() { + final settingsProvider = context.read(); + showDialog( + context: context, + builder: (BuildContext context) { + return Consumer( + 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 { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 1c35f8e5..a2f08ef3 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 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 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(), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 6eac3140..963d0c18 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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 { } } + @override + Widget build(BuildContext context) { + final settingsProvider = context.watch(); + 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 { borderRadius: BorderRadius.circular(8), child: _buildPosterImage(context), ), - _PosterOverlay(item: widget.item), + _PosterOverlay(item: item), ], ); } Widget _buildPosterImage(BuildContext context) { final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = widget.item.posterThumb(useSeasonPoster: useSeasonPoster); + final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + if (posterUrl != null) { + return Consumer( + 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 = []; + + // 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().useSeasonPoster; + final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) {