merge: 合并 K33-K 首页 feed 固定顶栏

This commit is contained in:
Zhou Weidong committed 2026-10-20 14:46:13 +08:00
commit f9d4f982f0
6 files changed
+341 -345

No files matched your search

+39 -16
View File
@@ -23,6 +23,7 @@ import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_header.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/exit_clear_screen_button.dart';
import 'widgets/player_center_toggle.dart';
@@ -187,6 +188,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
@@ -205,21 +207,55 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
_buildEpisode(context, indices[position], interactions),
),
);
final body = Stack(
fit: StackFit.expand,
children: [
content,
if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: HomeFeedHeader(
l10n: l10n,
onRefresh: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
}
return ok;
},
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onMore: _openCurrentMore,
),
),
),
],
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Theme(
data: AppTheme.light(),
child: widget.embedded
? content
? body
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
body: content,
body: body,
),
),
);
}
void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode);
}
Widget _buildEpisode(
BuildContext context,
int index,
@@ -417,26 +453,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
},
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onEpisodes: () => logic.showEpisodes(context),
onDetail: () => unawaited(logic.openDetail(context)),
onOpenFullPlayer: () async {
await widget.onOpenFullPlayer?.call(drama, episode);
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
},
onRefreshFeed: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.catalogError)));
}
return ok;
},
onDownload: () => requestEpisodeDownload(context, episode.id),
onClear: () => setState(() => _chromeVisible = false),
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
onMore: () => _openMore(context, episode),
onMore: _openCurrentMore,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
@@ -0,0 +1,275 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
const double _topIconSize = 36;
const double _topIconGlyph = 22;
const double _tabGap = 22;
const double _tabFontSize = 15;
const double _underlineWidth = 18;
const double _underlineHeight = 2;
/// 首页 feed 固定顶栏:刷新 / 漫剧·真人剧·推荐 / 搜索。
class HomeFeedHeader extends StatefulWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.onRefresh,
required this.onSearch,
required this.onMore,
});
final AppLocalizations l10n;
final Future<bool> Function() onRefresh;
final VoidCallback onSearch;
final VoidCallback onMore;
@override
State<HomeFeedHeader> createState() => _HomeFeedHeaderState();
}
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
Widget build(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: widget.onMore,
),
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);
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
await widget.onRefresh();
if (mounted) setState(() => _refreshing = false);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
}
/// 顶部频道 tab:漫剧点击提示即将上线。
enum _FeedTab {
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
};
}
class _TopIconButton extends StatelessWidget {
const _TopIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.dimension,
required this.glyphSize,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final double dimension;
final double glyphSize;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
icon: Icon(
icon,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
);
}
}
/// 刷新入口:点击重新加载 feed,长按打开更多(清屏等)。
class _RefreshButton extends StatelessWidget {
const _RefreshButton({
required this.tooltip,
required this.dimension,
required this.glyphSize,
required this.refreshing,
required this.onTap,
required this.onLongPress,
});
final String tooltip;
final double dimension;
final double glyphSize;
final bool refreshing;
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
key: const Key('home-feed-refresh'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox.square(
dimension: dimension,
child: refreshing
? const Center(
child: SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
key: Key('home-feed-refreshing'),
strokeWidth: 2,
color: Colors.white,
),
),
)
: Icon(
Icons.refresh_rounded,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
),
);
}
}
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.label,
required this.selected,
required this.fontSize,
required this.underlineWidth,
required this.underlineHeight,
required this.onTap,
});
final _FeedTab tab;
final String label;
final bool selected;
final double fontSize;
final double underlineWidth;
final double underlineHeight;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : Colors.white70,
fontSize: fontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
const SizedBox(height: 4),
Container(
width: underlineWidth,
height: underlineHeight,
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
],
),
);
}
}
+8 -109
View File
@@ -15,12 +15,6 @@ 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;
@@ -33,7 +27,7 @@ const List<Shadow> _boostTextShadows = [
const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。
class HomeFeedOverlay extends StatefulWidget {
const HomeFeedOverlay({
super.key,
@@ -49,23 +43,18 @@ class HomeFeedOverlay extends StatefulWidget {
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.onMore,
required this.onTogglePlay,
required this.onBoostStart,
required this.onBoostEnd,
this.onScrubStart,
this.onSeek,
required this.onMore,
this.likeCount,
this.favoriteCount,
this.commentCount,
this.authorNote = '',
this.onScrubStart,
this.onSeek,
});
final AppLocalizations l10n;
@@ -84,28 +73,20 @@ class HomeFeedOverlay extends StatefulWidget {
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 onMore;
final VoidCallback onTogglePlay;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
final VoidCallback onBoostStart;
final VoidCallback onBoostEnd;
final VoidCallback onMore;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
@override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
}
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
Widget build(BuildContext context) {
return SafeArea(
@@ -134,7 +115,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
else
Column(
children: [
_header(context),
const Spacer(),
if (widget.visible) _bottomInfo(),
_fullSeriesBar(),
@@ -185,77 +165,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
);
}
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: widget.onMore,
),
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: [
@@ -377,11 +286,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: Colors.white,
size: 20,
),
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
],
),
);
@@ -494,10 +399,4 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
}
@@ -7,167 +7,6 @@ const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8;
/// 顶部频道 tab:漫剧点击提示即将上线。
enum _FeedTab {
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
};
}
class _TopIconButton extends StatelessWidget {
const _TopIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.dimension,
required this.glyphSize,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final double dimension;
final double glyphSize;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: dimension,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
icon: Icon(
icon,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
);
}
}
/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。
class _RefreshButton extends StatelessWidget {
const _RefreshButton({
required this.tooltip,
required this.dimension,
required this.glyphSize,
required this.refreshing,
required this.onTap,
required this.onLongPress,
});
final String tooltip;
final double dimension;
final double glyphSize;
final bool refreshing;
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
key: const Key('home-feed-refresh'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox.square(
dimension: dimension,
child: refreshing
? const Center(
child: SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
key: Key('home-feed-refreshing'),
strokeWidth: 2,
color: Colors.white,
),
),
)
: Icon(
Icons.refresh_rounded,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
),
);
}
}
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.label,
required this.selected,
required this.fontSize,
required this.underlineWidth,
required this.underlineHeight,
required this.onTap,
});
final _FeedTab tab;
final String label;
final bool selected;
final double fontSize;
final double underlineWidth;
final double underlineHeight;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: selected ? Colors.white : Colors.white70,
fontSize: fontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
const SizedBox(height: 4),
Container(
width: underlineWidth,
height: underlineHeight,
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
],
),
);
}
}
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
class _FeedProgressBar extends StatefulWidget {
const _FeedProgressBar({
+4 -59
View File
@@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:video_player/video_player.dart';
@@ -118,13 +119,8 @@ void main() {
onSave: () {},
onComments: () {},
onShare: () {},
onEpisodes: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onRefreshFeed: () async => true,
onDownload: () {},
onClear: () {},
onSearch: () {},
onMore: () {},
),
),
@@ -156,10 +152,7 @@ void main() {
);
});
testWidgets('should_refresh_seek_and_open_player_from_feed_actions', (
tester,
) async {
var refreshed = 0;
testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async {
var opened = 0;
Duration? seeked;
final controller = ValueNotifier<VideoPlayerValue>(
@@ -206,16 +199,8 @@ void main() {
onSave: () {},
onComments: () {},
onShare: () {},
onEpisodes: () {},
onDetail: () {},
onOpenFullPlayer: () => opened++,
onRefreshFeed: () async {
refreshed++;
return true;
},
onDownload: () {},
onClear: () {},
onSearch: () {},
onMore: () {},
onScrubStart: () {},
onSeek: (value) => seeked = value,
@@ -260,10 +245,6 @@ void main() {
),
);
await tester.tap(find.byKey(const Key('home-feed-refresh')));
await tester.pump();
expect(refreshed, 1);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
);
@@ -321,42 +302,12 @@ void main() {
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedOverlay(
body: HomeFeedHeader(
l10n: AppLocalizations.of(context)!,
drama: const Drama(
id: 'refresh-drama',
title: 'Refresh Drama',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 8,
),
episode: const Episode(
id: 'refresh-episode',
number: 1,
title: 'Episode 1',
isFree: true,
),
controller: null,
liked: false,
saved: false,
visible: true,
boosting: false,
onTogglePlay: () {},
onBoostStart: () {},
onBoostEnd: () {},
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onEpisodes: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onRefreshFeed: () {
onRefresh: () {
calls++;
return completer.future;
},
onDownload: () {},
onClear: () {},
onSearch: () {},
onMore: () {},
),
@@ -427,13 +378,8 @@ void main() {
onSave: () {},
onComments: () {},
onShare: () {},
onEpisodes: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onRefreshFeed: () async => true,
onDownload: () {},
onClear: () {},
onSearch: () {},
onMore: () {},
onScrubStart: () {},
onSeek: (value) => seeked = value,
@@ -444,7 +390,6 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-feed-progress')), findsOneWidget);
+15
View File
@@ -170,12 +170,14 @@ void main() {
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
expect(find.byKey(const Key('home-feed-title')), findsNothing);
expect(find.byKey(const Key('rail-save')), findsNothing);
expect(find.byKey(const Key('home-feed-search')), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
expect(find.byKey(const Key('rail-save')), findsOneWidget);
expect(find.byKey(const Key('home-feed-search')), findsOneWidget);
});
testWidgets('should_resume_playback_when_boosting_while_paused', (
@@ -243,6 +245,19 @@ void main() {
expect(after, isNot(before));
});
testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async {
await _pumpApp(tester);
final before = tester.getRect(find.byKey(const Key('home-feed-search')));
await tester.drag(find.byType(PageView).first, const Offset(0, -600));
await tester.pumpAndSettle();
final after = tester.getRect(find.byKey(const Key('home-feed-search')));
expect(after.top, closeTo(before.top, 0.5));
expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget);
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
});
testWidgets('should_resume_feed_after_returning_from_full_player', (
tester,
) async {