Files
plezy/lib/widgets/episode_card.dart
T

393 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 '../focus/focus_theme.dart';
import '../focus/focusable_wrapper.dart';
import '../mixins/context_menu_tap_mixin.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
import '../providers/watch_state_overlay_provider.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/download_status_icon.dart';
import '../widgets/optimized_media_image.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 '../media/media_server_client.dart';
/// Episode card widget with D-pad long-press support
class EpisodeCard extends StatefulWidget {
final MediaItem episode;
final MediaServerClient? 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> with ContextMenuTapMixin<EpisodeCard> {
MediaItem _effectiveEpisode(BuildContext context) {
try {
final patch = context.select<WatchStateOverlayProvider, WatchStateOverlayPatch?>(
(provider) => provider.patchForGlobalKey(widget.episode.globalKey),
);
return WatchStateOverlayProvider.applyPatch(widget.episode, patch);
} on ProviderNotFoundException {
return widget.episode;
}
}
Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) {
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 (episode.durationMs != null)
Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle),
if (episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle),
],
if (episode.userRating != null && 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(
(episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble()
? '${(episode.userRating! / 2).toInt()}'
: formatRating(episode.userRating! / 2),
style: mutedStyle,
),
],
],
);
}
@override
Widget build(BuildContext context) {
return SettingValueBuilder<bool>(
pref: SettingsService.hideSpoilers,
builder: (context, hideSpoilers, _) => _buildContent(context, hideSpoilers: hideSpoilers),
);
}
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
final episode = _effectiveEpisode(context);
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
// Hide progress when offline (not tracked)
final hasProgress =
!widget.isOffline && episode.viewOffsetMs != null && episode.durationMs != null && episode.viewOffsetMs! > 0;
final progress = hasProgress ? episode.viewOffsetMs! / episode.durationMs! : 0.0;
final hasActiveProgress = hasProgress && episode.viewOffsetMs! < episode.durationMs!;
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: showContextMenuFromTap,
disableScale: true,
child: MediaContextMenu(
key: contextMenuKey,
item: episode,
onRefresh: widget.onRefresh,
onListRefresh: widget.onListRefresh,
onTap: widget.onTap,
child: InkWell(
key: Key(episode.id),
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap,
canRequestFocus: false,
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
onSecondaryTap: showContextMenuFromTap,
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: [
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(episode),
),
)
: _buildEpisodeThumbnail(episode),
),
),
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,
),
),
),
),
),
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 (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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Selector<DownloadProvider, _DownloadSlice>(
selector: (_, p) =>
_DownloadSlice.from(p.getProgress(episode.globalKey), p.isQueueing(episode.globalKey)),
builder: (context, slice, _) {
Widget? downloadStatusIcon;
// Only show download status in online mode
if (!widget.isOffline && 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: [
if (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${episode.index}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon],
const SizedBox(width: 8),
Expanded(
child: Text(
episode.title!,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
);
},
),
if (!shouldBlur && episode.summary != null && episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
if (PlatformDetector.isTV())
Text(
episode.summary!,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
else
CollapsibleText(
text: episode.summary!,
maxLines: 3,
small: true,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3),
),
],
const SizedBox(height: 8),
_buildEpisodeMetaRow(context, episode),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildEpisodeThumbnail(MediaItem episode) {
if (widget.isOffline && widget.localPosterPath != null) {
return OptimizedMediaImage.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 (episode.thumbPath != null) {
return OptimizedMediaImage.thumb(
client: widget.client,
imagePath: episode.thumbPath,
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);
}