Files
plezy/lib/widgets/episode_card.dart
T

397 lines
16 KiB
Dart

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/focus_theme.dart';
import '../focus/focusable_wrapper.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/content_utils.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/download_status_icon.dart';
import '../widgets/plex_optimized_image.dart';
import '../models/plex_metadata.dart';
import '../utils/platform_detector.dart';
import '../utils/formatters.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/placeholder_container.dart';
import '../theme/mono_tokens.dart';
import '../../services/plex_client.dart';
/// Episode card widget with D-pad long-press support
class EpisodeCard extends StatefulWidget {
final PlexMetadata episode;
final PlexClient? client;
final VoidCallback onTap;
final Future<void> Function(String)? onRefresh;
final Future<void> Function()? onListRefresh;
final bool autofocus;
final bool isOffline;
final String? localPosterPath;
final FocusNode? focusNode;
final VoidCallback? onNavigateUp;
const EpisodeCard({
super.key,
required this.episode,
this.client,
required this.onTap,
this.onRefresh,
this.onListRefresh,
this.autofocus = false,
this.isOffline = false,
this.localPosterPath,
this.focusNode,
this.onNavigateUp,
});
@override
State<EpisodeCard> createState() => _EpisodeCardState();
}
class _EpisodeCardState extends State<EpisodeCard> {
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
Offset? _tapPosition;
void _storeTapPosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
void _showContextMenu() {
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
}
Widget _buildEpisodeMetaRow(BuildContext context) {
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
final dot = Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text('•', style: mutedStyle),
);
return Row(
children: [
if (widget.episode.duration != null)
Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
],
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
dot,
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber),
),
const SizedBox(width: 2),
Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
? '${(widget.episode.userRating! / 2).toInt()}'
: (widget.episode.userRating! / 2).toStringAsFixed(1),
style: mutedStyle,
),
],
],
);
}
@override
Widget build(BuildContext context) {
final hideSpoilers = context.watch<SettingsProvider>().hideSpoilers;
final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler;
// Hide progress when offline (not tracked)
final hasProgress =
!widget.isOffline &&
widget.episode.viewOffset != null &&
widget.episode.duration != null &&
widget.episode.viewOffset! > 0;
final progress = hasProgress ? widget.episode.viewOffset! / widget.episode.duration! : 0.0;
final hasActiveProgress = hasProgress && widget.episode.viewOffset! < widget.episode.duration!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: FocusableWrapper(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
enableLongPress: true,
onNavigateUp: widget.onNavigateUp,
onSelect: widget.onTap,
onLongPress: _showContextMenu,
disableScale: true,
child: MediaContextMenu(
key: _contextMenuKey,
item: widget.episode,
onRefresh: widget.onRefresh,
onListRefresh: widget.onListRefresh,
onTap: widget.onTap,
child: InkWell(
key: Key(widget.episode.ratingKey),
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap,
onTapDown: _storeTapPosition,
onLongPress: _showContextMenu,
onSecondaryTapDown: _storeTapPosition,
onSecondaryTap: _showContextMenu,
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode thumbnail (16:9 aspect ratio, fixed width)
SizedBox(
width: 160,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: shouldBlur
? ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: _buildEpisodeThumbnail(),
),
)
: _buildEpisodeThumbnail(),
),
),
// Play overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(6)),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)],
),
),
child: Center(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const AppIcon(
Symbols.play_arrow_rounded,
fill: 1,
color: Colors.white,
size: 20,
),
),
),
),
),
// Progress bar at bottom
if (hasActiveProgress)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
child: LinearProgressIndicator(
value: progress,
backgroundColor: tokens(context).outline,
minHeight: 3,
),
),
),
if (widget.episode.isWatched && !hasActiveProgress)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
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),
),
),
],
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode number and title with download status
Selector<DownloadProvider, _DownloadSlice>(
selector: (_, p) => _DownloadSlice.from(
p.getProgress(widget.episode.globalKey),
p.isQueueing(widget.episode.globalKey),
),
builder: (context, slice, _) {
// Build download status icon based on state
Widget? downloadStatusIcon;
// Only show download status in online mode
if (!widget.isOffline && widget.episode.serverId != null) {
final status = slice.status;
final mutedBase = tokens(context).textMuted;
if (slice.isQueueing) {
downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase);
} else if (status != null) {
final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0;
downloadStatusIcon = DownloadStatusIcon(
status: status,
size: iconSize,
variant: DownloadStatusIconVariant.muted,
mutedBase: mutedBase,
progress: slice.progressPercent,
);
}
// Note: No icon shown if not downloaded (null)
}
return Row(
children: [
// Episode number badge
if (widget.episode.index != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(3)),
),
child: Text(
'E${widget.episode.index}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
// Download status icon (if present)
if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon],
const SizedBox(width: 8),
// Episode title
Expanded(
child: Text(
widget.episode.title!,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
);
},
),
// Summary (hidden when spoiler protection is active)
if (!shouldBlur && widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
if (PlatformDetector.isTV())
Text(
widget.episode.summary!,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
else
CollapsibleText(
text: widget.episode.summary!,
maxLines: 3,
small: true,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
),
],
// Metadata row (duration, watched status)
const SizedBox(height: 8),
_buildEpisodeMetaRow(context),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildEpisodeThumbnail() {
if (widget.isOffline && widget.localPosterPath != null) {
return PlexOptimizedImage.thumb(
client: null,
imagePath: null,
localFilePath: widget.localPosterPath,
fit: BoxFit.cover,
errorWidget: (context, url, error) =>
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
);
}
if (widget.episode.thumb != null) {
return PlexOptimizedImage.thumb(
client: widget.client,
imagePath: widget.episode.thumb,
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
placeholder: (context, url) => const PlaceholderContainer(),
errorWidget: (context, url, error) =>
const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)),
);
}
return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32));
}
}
/// Captures only primitives so Selector equality avoids rebuilds on unrelated
/// download ticks (e.g. other episodes, unused `DownloadProgress` fields).
class _DownloadSlice {
final DownloadStatus? status;
final double? progressPercent;
final bool isQueueing;
const _DownloadSlice({required this.status, required this.progressPercent, required this.isQueueing});
factory _DownloadSlice.from(DownloadProgress? p, bool isQueueing) =>
_DownloadSlice(status: p?.status, progressPercent: p?.progressPercent, isQueueing: isQueueing);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _DownloadSlice &&
other.status == status &&
other.progressPercent == progressPercent &&
other.isQueueing == isQueueing;
}
@override
int get hashCode => Object.hash(status, progressPercent, isQueueing);
}