fix(flutter): 首页 feed 顶栏固定悬浮
This commit is contained in:
1 parent
1cb4c56261
commit
9175a7f253
6 files changed
+341
-345
No files matched your search
@@ -23,6 +23,7 @@ import '../../core/widgets/download_action.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'player_binding.dart';
|
import 'player_binding.dart';
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
|
import 'widgets/home_feed_header.dart';
|
||||||
import 'widgets/home_feed_overlay.dart';
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/exit_clear_screen_button.dart';
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
@@ -187,6 +188,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Widget _buildPlayer(BuildContext context) {
|
Widget _buildPlayer(BuildContext context) {
|
||||||
final interactions = context.watch<InteractionController>();
|
final interactions = context.watch<InteractionController>();
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final indices = logic.visibleIndices;
|
final indices = logic.visibleIndices;
|
||||||
final pages = logic.pages;
|
final pages = logic.pages;
|
||||||
final content = AnimatedBuilder(
|
final content = AnimatedBuilder(
|
||||||
@@ -205,21 +207,55 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
_buildEpisode(context, indices[position], interactions),
|
_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>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.dark,
|
value: SystemUiOverlayStyle.dark,
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: AppTheme.light(),
|
data: AppTheme.light(),
|
||||||
child: widget.embedded
|
child: widget.embedded
|
||||||
? content
|
? body
|
||||||
: Scaffold(
|
: Scaffold(
|
||||||
key: const Key('player-scaffold'),
|
key: const Key('player-scaffold'),
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: content,
|
body: body,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openCurrentMore() {
|
||||||
|
final entry = logic.queue.entryAt(logic.session.currentIndex);
|
||||||
|
_openMore(context, entry.episode);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEpisode(
|
Widget _buildEpisode(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int index,
|
int index,
|
||||||
@@ -417,26 +453,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
},
|
},
|
||||||
onComments: () => logic.showComments(context, drama),
|
onComments: () => logic.showComments(context, drama),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onEpisodes: () => logic.showEpisodes(context),
|
|
||||||
onDetail: () => unawaited(logic.openDetail(context)),
|
onDetail: () => unawaited(logic.openDetail(context)),
|
||||||
onOpenFullPlayer: () async {
|
onOpenFullPlayer: () async {
|
||||||
await widget.onOpenFullPlayer?.call(drama, episode);
|
await widget.onOpenFullPlayer?.call(drama, episode);
|
||||||
if (!mounted || !widget.active) return;
|
if (!mounted || !widget.active) return;
|
||||||
logic.session.setUserWantsPlay(true);
|
logic.session.setUserWantsPlay(true);
|
||||||
},
|
},
|
||||||
onRefreshFeed: () async {
|
onMore: _openCurrentMore,
|
||||||
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),
|
|
||||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
? (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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,6 @@ part 'home_feed_overlay_parts.dart';
|
|||||||
|
|
||||||
const Color _overlayPill = Color(0x66000000);
|
const Color _overlayPill = Color(0x66000000);
|
||||||
const Color _fullBarBackground = Color(0x4D000000);
|
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 _titleFontSize = 17;
|
||||||
const double _metaFontSize = 12;
|
const double _metaFontSize = 12;
|
||||||
const double _tagFontSize = 11;
|
const double _tagFontSize = 11;
|
||||||
@@ -33,7 +27,7 @@ const List<Shadow> _boostTextShadows = [
|
|||||||
const int _maxTags = 4;
|
const int _maxTags = 4;
|
||||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||||
|
|
||||||
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
|
/// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。
|
||||||
class HomeFeedOverlay extends StatefulWidget {
|
class HomeFeedOverlay extends StatefulWidget {
|
||||||
const HomeFeedOverlay({
|
const HomeFeedOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -49,23 +43,18 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onComments,
|
required this.onComments,
|
||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onEpisodes,
|
|
||||||
required this.onDetail,
|
required this.onDetail,
|
||||||
required this.onOpenFullPlayer,
|
required this.onOpenFullPlayer,
|
||||||
required this.onRefreshFeed,
|
|
||||||
required this.onDownload,
|
|
||||||
required this.onClear,
|
|
||||||
required this.onSearch,
|
|
||||||
required this.onMore,
|
|
||||||
required this.onTogglePlay,
|
required this.onTogglePlay,
|
||||||
required this.onBoostStart,
|
required this.onBoostStart,
|
||||||
required this.onBoostEnd,
|
required this.onBoostEnd,
|
||||||
this.onScrubStart,
|
required this.onMore,
|
||||||
this.onSeek,
|
|
||||||
this.likeCount,
|
this.likeCount,
|
||||||
this.favoriteCount,
|
this.favoriteCount,
|
||||||
this.commentCount,
|
this.commentCount,
|
||||||
this.authorNote = '',
|
this.authorNote = '',
|
||||||
|
this.onScrubStart,
|
||||||
|
this.onSeek,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
@@ -84,28 +73,20 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onComments;
|
final VoidCallback onComments;
|
||||||
final VoidCallback onShare;
|
final VoidCallback onShare;
|
||||||
final VoidCallback onEpisodes;
|
|
||||||
final VoidCallback onDetail;
|
final VoidCallback onDetail;
|
||||||
final VoidCallback onOpenFullPlayer;
|
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 onTogglePlay;
|
||||||
final VoidCallback? onScrubStart;
|
|
||||||
final ValueChanged<Duration>? onSeek;
|
|
||||||
final VoidCallback onBoostStart;
|
final VoidCallback onBoostStart;
|
||||||
final VoidCallback onBoostEnd;
|
final VoidCallback onBoostEnd;
|
||||||
|
final VoidCallback onMore;
|
||||||
|
final VoidCallback? onScrubStart;
|
||||||
|
final ValueChanged<Duration>? onSeek;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||||
_FeedTab _tab = _FeedTab.recommend;
|
|
||||||
bool _refreshing = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -134,7 +115,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
else
|
else
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
_header(context),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (widget.visible) _bottomInfo(),
|
if (widget.visible) _bottomInfo(),
|
||||||
_fullSeriesBar(),
|
_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() {
|
Widget _rail() {
|
||||||
return InteractionRail(
|
return InteractionRail(
|
||||||
items: [
|
items: [
|
||||||
@@ -377,11 +286,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20),
|
||||||
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 _trackActiveHeight = 6;
|
||||||
const double _lineCenterFromBottom = 8;
|
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。
|
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
||||||
class _FeedProgressBar extends StatefulWidget {
|
class _FeedProgressBar extends StatefulWidget {
|
||||||
const _FeedProgressBar({
|
const _FeedProgressBar({
|
||||||
|
|||||||
@@ -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/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.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:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
@@ -118,13 +119,8 @@ void main() {
|
|||||||
onSave: () {},
|
onSave: () {},
|
||||||
onComments: () {},
|
onComments: () {},
|
||||||
onShare: () {},
|
onShare: () {},
|
||||||
onEpisodes: () {},
|
|
||||||
onDetail: () {},
|
onDetail: () {},
|
||||||
onOpenFullPlayer: () {},
|
onOpenFullPlayer: () {},
|
||||||
onRefreshFeed: () async => true,
|
|
||||||
onDownload: () {},
|
|
||||||
onClear: () {},
|
|
||||||
onSearch: () {},
|
|
||||||
onMore: () {},
|
onMore: () {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -156,10 +152,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_refresh_seek_and_open_player_from_feed_actions', (
|
testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
var refreshed = 0;
|
|
||||||
var opened = 0;
|
var opened = 0;
|
||||||
Duration? seeked;
|
Duration? seeked;
|
||||||
final controller = ValueNotifier<VideoPlayerValue>(
|
final controller = ValueNotifier<VideoPlayerValue>(
|
||||||
@@ -206,16 +199,8 @@ void main() {
|
|||||||
onSave: () {},
|
onSave: () {},
|
||||||
onComments: () {},
|
onComments: () {},
|
||||||
onShare: () {},
|
onShare: () {},
|
||||||
onEpisodes: () {},
|
|
||||||
onDetail: () {},
|
onDetail: () {},
|
||||||
onOpenFullPlayer: () => opened++,
|
onOpenFullPlayer: () => opened++,
|
||||||
onRefreshFeed: () async {
|
|
||||||
refreshed++;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onDownload: () {},
|
|
||||||
onClear: () {},
|
|
||||||
onSearch: () {},
|
|
||||||
onMore: () {},
|
onMore: () {},
|
||||||
onScrubStart: () {},
|
onScrubStart: () {},
|
||||||
onSeek: (value) => seeked = value,
|
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(
|
final gesture = await tester.startGesture(
|
||||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||||
);
|
);
|
||||||
@@ -321,42 +302,12 @@ void main() {
|
|||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
home: Builder(
|
home: Builder(
|
||||||
builder: (context) => Scaffold(
|
builder: (context) => Scaffold(
|
||||||
body: HomeFeedOverlay(
|
body: HomeFeedHeader(
|
||||||
l10n: AppLocalizations.of(context)!,
|
l10n: AppLocalizations.of(context)!,
|
||||||
drama: const Drama(
|
onRefresh: () {
|
||||||
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: () {
|
|
||||||
calls++;
|
calls++;
|
||||||
return completer.future;
|
return completer.future;
|
||||||
},
|
},
|
||||||
onDownload: () {},
|
|
||||||
onClear: () {},
|
|
||||||
onSearch: () {},
|
onSearch: () {},
|
||||||
onMore: () {},
|
onMore: () {},
|
||||||
),
|
),
|
||||||
@@ -427,13 +378,8 @@ void main() {
|
|||||||
onSave: () {},
|
onSave: () {},
|
||||||
onComments: () {},
|
onComments: () {},
|
||||||
onShare: () {},
|
onShare: () {},
|
||||||
onEpisodes: () {},
|
|
||||||
onDetail: () {},
|
onDetail: () {},
|
||||||
onOpenFullPlayer: () {},
|
onOpenFullPlayer: () {},
|
||||||
onRefreshFeed: () async => true,
|
|
||||||
onDownload: () {},
|
|
||||||
onClear: () {},
|
|
||||||
onSearch: () {},
|
|
||||||
onMore: () {},
|
onMore: () {},
|
||||||
onScrubStart: () {},
|
onScrubStart: () {},
|
||||||
onSeek: (value) => seeked = value,
|
onSeek: (value) => seeked = value,
|
||||||
@@ -444,7 +390,6 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
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-full-series')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-feed-progress')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-progress')), findsOneWidget);
|
||||||
|
|
||||||
|
|||||||
@@ -170,12 +170,14 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
expect(find.byKey(const Key('home-feed-title')), findsNothing);
|
||||||
expect(find.byKey(const Key('rail-save')), findsNothing);
|
expect(find.byKey(const Key('rail-save')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('home-feed-search')), findsNothing);
|
||||||
|
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing);
|
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('home-feed-title')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('rail-save')), 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', (
|
testWidgets('should_resume_playback_when_boosting_while_paused', (
|
||||||
@@ -243,6 +245,19 @@ void main() {
|
|||||||
expect(after, isNot(before));
|
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', (
|
testWidgets('should_resume_feed_after_returning_from_full_player', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in new issue
Block a user