From 157669bd762b1c5357d81362fa98ed45a196422a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:05:23 +0200 Subject: [PATCH] feat: folder view thumbnails and rich metadata close #821 --- lib/screens/libraries/folder_tree_item.dart | 295 +++++++++++++++++--- lib/screens/libraries/folder_tree_view.dart | 1 + 2 files changed, 264 insertions(+), 32 deletions(-) diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 23c8d934..62066fe0 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,9 +1,20 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../models/plex_metadata.dart'; +import '../../providers/settings_provider.dart'; +import '../../services/settings_service.dart' show EpisodePosterMode; +import '../../utils/content_utils.dart'; +import '../../utils/formatters.dart'; +import '../../utils/provider_extensions.dart'; +import '../../widgets/media_progress_bar.dart'; +import '../../widgets/plex_optimized_image.dart'; +import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; /// Individual item in the folder tree @@ -20,6 +31,7 @@ class FolderTreeItem extends StatelessWidget { final bool isLoading; final FocusNode? focusNode; final VoidCallback? onNavigateUp; + final String? serverId; const FolderTreeItem({ super.key, @@ -34,6 +46,7 @@ class FolderTreeItem extends StatelessWidget { this.isLoading = false, this.focusNode, this.onNavigateUp, + this.serverId, }); IconData _getIcon() { @@ -41,7 +54,6 @@ class FolderTreeItem extends StatelessWidget { return Symbols.folder_rounded; } - // File icons based on type return switch (item.mediaType) { PlexMediaType.movie => Symbols.movie_rounded, PlexMediaType.show => Symbols.tv_rounded, @@ -60,63 +72,282 @@ class FolderTreeItem extends StatelessWidget { } } - @override - Widget build(BuildContext context) { + String? _buildSubtitle() { + if (item.isEpisode) { + final parts = []; + if (item.parentIndex != null && item.index != null) { + parts.add('S${item.parentIndex} E${item.index}'); + } + if (item.title != null && item.title!.isNotEmpty) { + parts.add(item.title!); + } + return parts.isNotEmpty ? parts.join(' · ') : null; + } + if (item.isSeason) { + return item.displaySubtitle; + } + return item.displaySubtitle; + } + + String _buildMetadataLine() { + final parts = []; + + if (item.contentRating != null && item.contentRating!.isNotEmpty) { + parts.add(item.contentRating!); + } + if (item.year != null) { + parts.add(item.year.toString()); + } + if (item.duration != null && item.duration! > 0) { + parts.add(formatDurationTextual(item.duration!)); + } + if (item.rating != null) { + parts.add('★ ${item.rating!.toStringAsFixed(1)}'); + } + + return parts.join(' · '); + } + + Widget _buildFolderRow(BuildContext context) { final indentation = depth * 24.0; final expandIcon = isExpanded ? Symbols.keyboard_arrow_down_rounded : Symbols.keyboard_arrow_right_rounded; - final rowContent = Container( + return Container( padding: EdgeInsets.only(left: 16.0 + indentation, right: 8.0, top: 8.0, bottom: 8.0), child: Row( children: [ - // Expand/collapse icon for folders - if (isFolder) - SizedBox( - width: 24, - child: isLoading - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : AppIcon(expandIcon, fill: 1, size: 20), - ) - else - const SizedBox(width: 24), - + SizedBox( + width: 24, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : AppIcon(expandIcon, fill: 1, size: 20), + ), const SizedBox(width: 8), - - // File/folder icon AppIcon( _getIcon(), fill: 1, size: 20, - color: isFolder - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context).colorScheme.primary, ), - const SizedBox(width: 12), - - // Item title Expanded( child: Text( item.displayTitle, - style: TextStyle(fontSize: 14, fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - - // Additional metadata for files - if (!isFolder && item.year != null) - Text( - item.year.toString(), - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), - ), ], ), ); + } + + Widget _buildMediaRow(BuildContext context) { + final indentation = depth * 24.0; + final episodePosterMode = context.select((s) => s.episodePosterMode); + final hideSpoilers = context.select((s) => s.hideSpoilers); + final showUnwatchedCount = context.select((s) => s.showUnwatchedCount); + + final isWide = item.usesWideAspectRatio(episodePosterMode); + final thumbWidth = isWide ? 130.0 : 53.0; + final thumbHeight = isWide ? 73.0 : 80.0; + + final subtitle = _buildSubtitle(); + final metadataLine = _buildMetadataLine(); + + return Container( + padding: EdgeInsets.only(left: 16.0 + indentation, right: 16.0, top: 6.0, bottom: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Thumbnail with progress overlay + SizedBox( + width: thumbWidth, + height: thumbHeight, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: _buildThumbnail(context, episodePosterMode, hideSpoilers, thumbWidth, thumbHeight), + ), + // Watch progress overlay + _buildWatchOverlay(context, showUnwatchedCount), + ], + ), + ), + + const SizedBox(width: 12), + + // Metadata column + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.displayTitle, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, height: 1.2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + color: tokens(context).textMuted.withValues(alpha: 0.85), + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (metadataLine.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + metadataLine, + style: TextStyle( + fontSize: 10, + color: tokens(context).textMuted.withValues(alpha: 0.7), + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildThumbnail( + BuildContext context, + EpisodePosterMode episodePosterMode, + bool hideSpoilers, + double width, + double height, + ) { + final posterUrl = item.posterThumb(mode: episodePosterMode); + final client = serverId != null ? context.getClientForServer(serverId!) : null; + final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; + + Widget image; + if (item.usesWideAspectRatio(episodePosterMode)) { + image = PlexOptimizedImage.thumb( + client: client, + imagePath: posterUrl, + width: width, + height: height, + fit: BoxFit.cover, + ); + } else { + image = PlexOptimizedImage.poster( + client: client, + imagePath: posterUrl, + width: width, + height: height, + fit: BoxFit.cover, + ); + } + + if (shouldBlur) { + return ClipRect( + child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: image), + ); + } + return image; + } + + Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) { + final hasActiveProgress = + item.viewOffset != null && + item.duration != null && + item.viewOffset! > 0 && + item.viewOffset! < item.duration!; + + return Stack( + children: [ + // Watched checkmark + if (item.isWatched && !hasActiveProgress) + Positioned( + top: 3, + right: 3, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: tokens(context).text, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + ), + child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12), + ), + ), + // Unwatched count for shows/seasons + if (showUnwatchedCount && + !item.isWatched && + (item.mediaType == PlexMediaType.show || item.mediaType == PlexMediaType.season) && + (item.leafCount != null && item.leafCount! > 0 && item.viewedLeafCount != null)) + Positioned( + top: 3, + right: 3, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: tokens(context).text, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + ), + alignment: Alignment.center, + child: Text( + '${item.leafCount! - item.viewedLeafCount!}', + style: TextStyle(color: tokens(context).bg, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + // Progress bar + if (hasActiveProgress) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6)), + child: MediaProgressBar(viewOffset: item.viewOffset!, duration: item.duration!), + ), + ), + // Season progress + if (item.isSeason && item.isPartiallyWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6)), + child: LinearProgressIndicator( + value: item.viewedLeafCount! / item.leafCount!, + backgroundColor: tokens(context).outline, + valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), + minHeight: 3, + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final rowContent = isFolder ? _buildFolderRow(context) : _buildMediaRow(context); return Row( children: [ - // Main item row — expand/navigate on select + // Main item row Expanded( child: FocusableWrapper( focusNode: focusNode, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index bb2a612e..9ae55832 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -186,6 +186,7 @@ class _FolderTreeViewState extends State { isFolder: isFolder, isExpanded: isExpanded, isLoading: isLoading, + serverId: widget.serverId, onExpand: isFolder ? () => _toggleFolder(item) : null, onTap: !isFolder ? () => _handleItemTap(item) : null, onPlayAll: isFolder ? () => _handleFolderPlay(item) : null,