Files
ReelShort/lib/pages/player/widgets/home_feed_overlay.dart
T

524 lines
15 KiB
Dart

// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
part 'home_feed_overlay_parts.dart';
const Color _overlayPill = Color(0x66000000);
const Color _fullBarBackground = Color(0x4D000000);
const double _topIconSize = 36;
const double _topIconGlyph = 22;
const double _tabGap = 22;
const double _tabFontSize = 15;
const double _underlineWidth = 18;
const double _underlineHeight = 2;
const double _titleFontSize = 17;
const double _metaFontSize = 12;
const double _tagFontSize = 11;
const double _fullBarHeight = 44;
const double _progressHeight = 2;
const double _progressHitHeight = 24;
const List<Shadow> _boostTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
];
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
class HomeFeedOverlay extends StatefulWidget {
const HomeFeedOverlay({
super.key,
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.visible,
required this.boosting,
required this.onLike,
required this.onSave,
required this.onComments,
required this.onShare,
required this.onEpisodes,
required this.onDetail,
required this.onOpenFullPlayer,
required this.onRefreshFeed,
required this.onDownload,
required this.onClear,
required this.onSearch,
required this.onTogglePlay,
required this.onBoostStart,
required this.onBoostEnd,
this.onScrubStart,
this.onSeek,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.authorNote = '',
this.onPip,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final ValueListenable<VideoPlayerValue>? controller;
final bool liked;
final int? likeCount;
final int? favoriteCount;
final int? commentCount;
final bool saved;
final String authorNote;
final bool visible;
final bool boosting;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onEpisodes;
final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer;
final Future<bool> Function() onRefreshFeed;
final VoidCallback onDownload;
final VoidCallback onClear;
final VoidCallback onSearch;
final VoidCallback onTogglePlay;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
final VoidCallback onBoostStart;
final VoidCallback onBoostEnd;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: Stack(
children: [
Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerUp: (_) => widget.onBoostEnd(),
onPointerCancel: (_) => widget.onBoostEnd(),
child: GestureDetector(
key: const Key('home-feed-gesture'),
behavior: HitTestBehavior.opaque,
onTap: widget.onTogglePlay,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => widget.onBoostEnd(),
onLongPressCancel: widget.onBoostEnd,
),
),
),
),
if (widget.boosting)
Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()])
else if (widget.visible)
Column(
children: [
_header(context),
Expanded(
child: Align(
alignment: const Alignment(1.0, 0.80),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 10, 16),
child: _rail(),
),
),
),
_bottomInfo(),
_fullSeriesBar(),
],
),
],
),
);
}
void _handleLongPressStart(LongPressStartDetails details, double width) {
final third = width / 3;
final dx = details.localPosition.dx;
if (dx < third || dx > width - third) {
widget.onBoostStart();
return;
}
_openMore(context);
}
Widget _boostHint() {
return Padding(
key: const Key('home-feed-speed-boost-hint'),
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.play_arrow_rounded,
color: AppColors.blue,
size: 16,
shadows: _boostTextShadows,
),
const SizedBox(width: 4),
Text(
widget.l10n.speedBoosting('2.0'),
style: const TextStyle(
color: AppColors.blue,
fontSize: 13,
fontWeight: FontWeight.w700,
shadows: _boostTextShadows,
),
),
],
),
);
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
await widget.onRefreshFeed();
if (mounted) setState(() => _refreshing = false);
}
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(
children: [
_RefreshButton(
tooltip: widget.l10n.retry,
dimension: _topIconSize,
glyphSize: _topIconGlyph,
refreshing: _refreshing,
onTap: _refresh,
onLongPress: () => _openMore(context),
),
Expanded(
child: Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < _FeedTab.values.length; index++)
_tabSlot(_FeedTab.values[index], index),
],
),
),
),
),
_TopIconButton(
key: const Key('home-feed-search'),
tooltip: widget.l10n.search,
icon: Icons.search_rounded,
dimension: _topIconSize,
glyphSize: _topIconGlyph,
onPressed: widget.onSearch,
),
],
),
);
}
Widget _tabSlot(_FeedTab tab, int index) {
final item = _FeedTabItem(
tab: tab,
label: tab.label(widget.l10n),
selected: _tab == tab,
fontSize: _tabFontSize,
underlineWidth: _underlineWidth,
underlineHeight: _underlineHeight,
onTap: () => _selectTab(tab),
);
if (index == 0) return item;
return Padding(
padding: const EdgeInsets.only(left: _tabGap),
child: item,
);
}
void _selectTab(_FeedTab tab) {
if (tab.comingSoon) {
_showComingSoon();
return;
}
setState(() => _tab = tab);
}
Widget _rail() {
return InteractionRail(
items: [
InteractionRailItem(
key: const Key('rail-save'),
icon: Icons.star_border_rounded,
activeIcon: Icons.star_rounded,
label: widget.l10n.favorite,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
selected: widget.saved,
onTap: widget.onSave,
),
InteractionRailItem(
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
activeIcon: Icons.chat_bubble_rounded,
label: widget.l10n.comments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
onTap: widget.onComments,
),
InteractionRailItem(
key: const Key('rail-like'),
icon: Icons.favorite_border_rounded,
activeIcon: Icons.favorite_rounded,
label: widget.l10n.like,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
selected: widget.liked,
onTap: widget.onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
icon: Icons.ios_share_rounded,
activeIcon: Icons.ios_share_rounded,
label: widget.l10n.share,
onTap: widget.onShare,
),
],
);
}
Widget _bottomInfo() {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_seriesMetaRow(),
const SizedBox(height: 2),
_titleRow(),
const SizedBox(height: 6),
_tagsRow(),
if (widget.authorNote.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
key: const Key('home-feed-author-note'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
],
),
);
}
Widget _seriesMetaRow() {
return GestureDetector(
key: const Key('home-feed-series-meta'),
onTap: widget.onDetail,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.l10n.seriesMeta(
'${widget.drama.episodeCount}',
formatInteractionCount(widget.drama.viewCount),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: _metaFontSize,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white70,
size: 16,
),
],
),
);
}
Widget _titleRow() {
return GestureDetector(
onTap: widget.onDetail,
child: Row(
children: [
Flexible(
child: Text(
localizedDramaTitle(widget.l10n, widget.drama),
key: const Key('home-feed-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: Colors.white,
fontSize: _titleFontSize,
fontWeight: FontWeight.w800,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 20,
),
],
),
);
}
Widget _tagsRow() {
final tags = [
widget.l10n.episodeLabel(widget.episode.number),
..._tagValues(),
].take(_maxTags).toList();
return Wrap(
key: const Key('home-feed-tags'),
spacing: 6,
runSpacing: 4,
children: [
for (final tag in tags)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _overlayPill,
borderRadius: BorderRadius.circular(6),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: _tagFontSize,
),
),
),
],
);
}
List<String> _tagValues() {
final result = <String>[];
for (final value in [widget.drama.genre, widget.drama.category]) {
for (final part in value.split(_tagSplitter)) {
final tag = part.trim();
if (tag.isEmpty || result.contains(tag)) continue;
result.add(tag);
}
}
return result;
}
Widget _fullSeriesBar() {
return GestureDetector(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
child: SizedBox(
height: _fullBarHeight,
child: Stack(
children: [
Positioned.fill(
child: Container(
key: const Key('home-feed-full-series-bg'),
color: _fullBarBackground,
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.l10n.watchFullSeries(
'${widget.drama.episodeCount}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 18,
),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
),
),
],
),
),
);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
Future<void> _openMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (sheetContext) => _FeedMoreSheet(
l10n: widget.l10n,
onDownload: widget.onDownload,
onPip: widget.onPip,
onClear: widget.onClear,
),
);
}
}