feat: folder view thumbnails and rich metadata

close #821
This commit is contained in:
edde746
2026-04-16 17:06:07 +02:00
parent f055759c7a
commit 157669bd76
2 changed files with 264 additions and 32 deletions
+263 -32
View File
@@ -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 = <String>[];
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 = <String>[];
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<SettingsProvider, EpisodePosterMode>((s) => s.episodePosterMode);
final hideSpoilers = context.select<SettingsProvider, bool>((s) => s.hideSpoilers);
final showUnwatchedCount = context.select<SettingsProvider, bool>((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<Color>(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,
@@ -186,6 +186,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
isFolder: isFolder,
isExpanded: isExpanded,
isLoading: isLoading,
serverId: widget.serverId,
onExpand: isFolder ? () => _toggleFolder(item) : null,
onTap: !isFolder ? () => _handleItemTap(item) : null,
onPlayAll: isFolder ? () => _handleFolderPlay(item) : null,