394 lines
16 KiB
Dart
394 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),
|
|
mouseCursor: SystemMouseCursors.click,
|
|
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);
|
|
}
|