merge: 合并 K33-C 首页 feed 精修

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:56:55 +08:00
commit c3f5f43744
17 files changed
+417 -438

No files matched your search

-2
View File
@@ -29,8 +29,6 @@
"romance": "Romance", "romance": "Romance",
"actionGenre": "Action", "actionGenre": "Action",
"filter": "Filter", "filter": "Filter",
"danmaku": "Danmaku",
"watchFullscreen": "Full screen",
"seriesMeta": "Series · {episodes} eps | {viewers} watching", "seriesMeta": "Series · {episodes} eps | {viewers} watching",
"@seriesMeta": { "@seriesMeta": {
"placeholders": { "placeholders": {
-2
View File
@@ -29,8 +29,6 @@
"romance": "Romantis", "romance": "Romantis",
"actionGenre": "Aksi", "actionGenre": "Aksi",
"filter": "Filter", "filter": "Filter",
"danmaku": "Danmaku",
"watchFullscreen": "Layar penuh",
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
"watchFullSeries": "Tonton serial lengkap · {episodes} eps", "watchFullSeries": "Tonton serial lengkap · {episodes} eps",
"feedTabAnime": "Anime", "feedTabAnime": "Anime",
+6 -12
View File
@@ -274,18 +274,6 @@ abstract class AppLocalizations {
/// **'Filter'** /// **'Filter'**
String get filter; String get filter;
/// No description provided for @danmaku.
///
/// In en, this message translates to:
/// **'Danmaku'**
String get danmaku;
/// No description provided for @watchFullscreen.
///
/// In en, this message translates to:
/// **'Full screen'**
String get watchFullscreen;
/// No description provided for @seriesMeta. /// No description provided for @seriesMeta.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2734,6 +2722,12 @@ abstract class AppLocalizations {
/// **'Clear-screen playback'** /// **'Clear-screen playback'**
String get clearPlayback; String get clearPlayback;
/// No description provided for @danmaku.
///
/// In en, this message translates to:
/// **'Danmaku'**
String get danmaku;
/// No description provided for @danmakuUnavailable. /// No description provided for @danmakuUnavailable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3 -6
View File
@@ -98,12 +98,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get filter => 'Filter'; String get filter => 'Filter';
@override
String get danmaku => 'Danmaku';
@override
String get watchFullscreen => 'Full screen';
@override @override
String seriesMeta(String episodes, String viewers) { String seriesMeta(String episodes, String viewers) {
return 'Series · $episodes eps | $viewers watching'; return 'Series · $episodes eps | $viewers watching';
@@ -1424,6 +1418,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get clearPlayback => 'Clear-screen playback'; String get clearPlayback => 'Clear-screen playback';
@override
String get danmaku => 'Danmaku';
@override @override
String get danmakuUnavailable => 'Coming soon'; String get danmakuUnavailable => 'Coming soon';
+3 -6
View File
@@ -98,12 +98,6 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get filter => 'Filter'; String get filter => 'Filter';
@override
String get danmaku => 'Komentar layar';
@override
String get watchFullscreen => 'Layar penuh';
@override @override
String seriesMeta(String episodes, String viewers) { String seriesMeta(String episodes, String viewers) {
return 'Serial · $episodes eps | $viewers menonton'; return 'Serial · $episodes eps | $viewers menonton';
@@ -1424,6 +1418,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get clearPlayback => 'Putar tanpa kontrol'; String get clearPlayback => 'Putar tanpa kontrol';
@override
String get danmaku => 'Komentar layar';
@override @override
String get danmakuUnavailable => 'Belum tersedia'; String get danmakuUnavailable => 'Belum tersedia';
+3 -6
View File
@@ -95,12 +95,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get filter => '筛选'; String get filter => '筛选';
@override
String get danmaku => '弹幕';
@override
String get watchFullscreen => '全屏观看';
@override @override
String seriesMeta(String episodes, String viewers) { String seriesMeta(String episodes, String viewers) {
return '系列剧 · 共$episodes集 | 共$viewers人在追'; return '系列剧 · 共$episodes集 | 共$viewers人在追';
@@ -1400,6 +1394,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get clearPlayback => '清屏播放'; String get clearPlayback => '清屏播放';
@override
String get danmaku => '弹幕';
@override @override
String get danmakuUnavailable => '暂未开放'; String get danmakuUnavailable => '暂未开放';
-2
View File
@@ -29,8 +29,6 @@
"romance": "都市爱情", "romance": "都市爱情",
"actionGenre": "动作", "actionGenre": "动作",
"filter": "筛选", "filter": "筛选",
"danmaku": "弹幕",
"watchFullscreen": "全屏观看",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集", "watchFullSeries": "观看完整剧集 · 全{episodes}集",
"feedTabAnime": "漫剧", "feedTabAnime": "漫剧",
+11 -2
View File
@@ -73,6 +73,7 @@ class HomePage extends StatelessWidget {
return _HomeTabs( return _HomeTabs(
key: ValueKey(catalog.dramas.first.id), key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas, dramas: catalog.dramas,
onRefreshFeed: catalog.load,
); );
} }
} }
@@ -97,9 +98,14 @@ class HomeFailureView extends StatelessWidget {
/// 四个根页面共享同一路由并保持各自状态。 /// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget { class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.dramas}); const _HomeTabs({
super.key,
required this.dramas,
required this.onRefreshFeed,
});
final List<Drama> dramas; final List<Drama> dramas;
final VoidCallback onRefreshFeed;
@override @override
State<_HomeTabs> createState() => _HomeTabsState(); State<_HomeTabs> createState() => _HomeTabsState();
@@ -138,7 +144,7 @@ class _HomeTabsState extends State<_HomeTabs> {
}, },
child: Scaffold( child: Scaffold(
key: logic.scaffoldKey, key: logic.scaffoldKey,
backgroundColor: Colors.transparent, backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent,
body: _body(context), body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
), ),
@@ -165,6 +171,9 @@ class _HomeTabsState extends State<_HomeTabs> {
drama: widget.dramas.first, drama: widget.dramas.first,
initialIndex: 0, initialIndex: 0,
feedDramas: widget.dramas, feedDramas: widget.dramas,
onOpenFullPlayer: (drama, episode) =>
logic.playDrama(context, drama, episodeId: episode.id),
onRefreshFeed: widget.onRefreshFeed,
onDramaChanged: logic.onDramaChanged, onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true), onSearch: () => logic.select(1, search: true),
+14 -2
View File
@@ -55,6 +55,8 @@ class PlayerPage extends StatefulWidget {
this.feedDramas, this.feedDramas,
this.onDramaChanged, this.onDramaChanged,
this.onImmersiveChanged, this.onImmersiveChanged,
this.onOpenFullPlayer,
this.onRefreshFeed,
this.wallet, this.wallet,
this.downloads, this.downloads,
}); });
@@ -73,6 +75,8 @@ class PlayerPage extends StatefulWidget {
final List<Drama>? feedDramas; final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged; final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged; final ValueChanged<bool>? onImmersiveChanged;
final void Function(Drama drama, Episode episode)? onOpenFullPlayer;
final VoidCallback? onRefreshFeed;
final WalletController? wallet; final WalletController? wallet;
final DownloadController? downloads; final DownloadController? downloads;
@@ -411,12 +415,20 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onDetail: () => unawaited(logic.openDetail(context)), onDetail: () => unawaited(logic.openDetail(context)),
onFullscreen: () => onOpenFullPlayer: () =>
unawaited(_openFullscreen(context, drama, episode)), widget.onOpenFullPlayer?.call(drama, episode),
onRefreshFeed: () => widget.onRefreshFeed?.call(),
onDownload: () => requestEpisodeDownload(context, episode.id), onDownload: () => requestEpisodeDownload(context, episode.id),
onClear: () => setState(() => _chromeVisible = false), onClear: () => setState(() => _chromeVisible = false),
onSearch: onSearch:
widget.onSearch ?? () => logic.showDiscover(context), widget.onSearch ?? () => logic.showDiscover(context),
onScrubStart: ready
? () => logic.beginScrub(videoPort!)
: null,
onSeek: ready
? (position) =>
logic.seekTo(context, videoPort!, position)
: null,
onPip: logic.pictureInPicture.isPlatformSupported onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
-19
View File
@@ -43,25 +43,6 @@ extension _PlayerViewActions on _PlayerPageState {
); );
} }
/// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。
Future<void> _openFullscreen(
BuildContext context,
Drama drama,
Episode episode,
) async {
final index = drama.episodes.indexWhere((item) => item.id == episode.id);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index < 0 ? 0 : index,
wallet: widget.wallet,
downloads: widget.downloads,
),
),
);
}
Future<void> _openUnlockSheet( Future<void> _openUnlockSheet(
BuildContext context, BuildContext context,
int index, int index,
+34 -51
View File
@@ -9,6 +9,7 @@ import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
part 'home_feed_overlay_parts.dart'; part 'home_feed_overlay_parts.dart';
@@ -16,7 +17,6 @@ const Color _overlayPill = Color(0x66000000);
const Color _fullBarColor = Color(0xCC000000); const Color _fullBarColor = Color(0xCC000000);
const double _topIconSize = 36; const double _topIconSize = 36;
const double _topIconGlyph = 22; const double _topIconGlyph = 22;
const double _pillHeight = 34;
const double _tabGap = 22; const double _tabGap = 22;
const double _tabFontSize = 15; const double _tabFontSize = 15;
const double _underlineWidth = 18; const double _underlineWidth = 18;
@@ -24,9 +24,9 @@ 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;
const double _railIconSize = 22;
const double _fullBarHeight = 48; const double _fullBarHeight = 48;
const double _progressHeight = 2; const double _progressHeight = 2;
const double _progressHitHeight = 20;
const int _maxTags = 4; const int _maxTags = 4;
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
@@ -46,10 +46,13 @@ class HomeFeedOverlay extends StatefulWidget {
required this.onShare, required this.onShare,
required this.onEpisodes, required this.onEpisodes,
required this.onDetail, required this.onDetail,
required this.onFullscreen, required this.onOpenFullPlayer,
required this.onRefreshFeed,
required this.onDownload, required this.onDownload,
required this.onClear, required this.onClear,
required this.onSearch, required this.onSearch,
this.onScrubStart,
this.onSeek,
this.likeCount, this.likeCount,
this.favoriteCount, this.favoriteCount,
this.commentCount, this.commentCount,
@@ -73,11 +76,14 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onShare; final VoidCallback onShare;
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
final VoidCallback onDetail; final VoidCallback onDetail;
final VoidCallback onFullscreen; final VoidCallback onOpenFullPlayer;
final VoidCallback onRefreshFeed;
final VoidCallback onDownload; final VoidCallback onDownload;
final VoidCallback onClear; final VoidCallback onClear;
final VoidCallback onSearch; final VoidCallback onSearch;
final VoidCallback? onPip; final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
@override @override
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState(); State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
@@ -145,13 +151,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row( child: Row(
children: [ children: [
_TopIconButton( _RefreshButton(
key: const Key('home-feed-menu'), tooltip: widget.l10n.retry,
tooltip: widget.l10n.more,
icon: Icons.menu_rounded,
dimension: _topIconSize, dimension: _topIconSize,
glyphSize: _topIconGlyph, glyphSize: _topIconGlyph,
onPressed: () => _openMore(context), onTap: widget.onRefreshFeed,
onLongPress: () => _openMore(context),
), ),
Expanded( Expanded(
child: Center( child: Center(
@@ -205,45 +210,42 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
} }
Widget _rail() { Widget _rail() {
return Column( return InteractionRail(
mainAxisSize: MainAxisSize.min, items: [
children: [ InteractionRailItem(
_FeedRailItem(
key: const Key('rail-save'), key: const Key('rail-save'),
icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded, icon: Icons.star_border_rounded,
activeIcon: Icons.star_rounded,
label: widget.l10n.favorite,
count: widget.favoriteCount, count: widget.favoriteCount,
countKey: const Key('rail-save-count'), countKey: const Key('rail-save-count'),
label: widget.l10n.favorite,
selected: widget.saved, selected: widget.saved,
iconSize: _railIconSize,
onTap: widget.onSave, onTap: widget.onSave,
), ),
_FeedRailItem( InteractionRailItem(
key: const Key('rail-comments'), key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded, icon: Icons.chat_bubble_outline_rounded,
activeIcon: Icons.chat_bubble_rounded,
label: widget.l10n.comments,
count: widget.commentCount, count: widget.commentCount,
countKey: const Key('rail-comments-count'), countKey: const Key('rail-comments-count'),
label: widget.l10n.comments,
iconSize: _railIconSize,
onTap: widget.onComments, onTap: widget.onComments,
), ),
_FeedRailItem( InteractionRailItem(
key: const Key('rail-like'), key: const Key('rail-like'),
icon: widget.liked icon: Icons.favorite_border_rounded,
? Icons.favorite_rounded activeIcon: Icons.favorite_rounded,
: Icons.favorite_border_rounded, label: widget.l10n.like,
count: widget.likeCount, count: widget.likeCount,
countKey: const Key('rail-like-count'), countKey: const Key('rail-like-count'),
label: widget.l10n.like,
selected: widget.liked, selected: widget.liked,
iconSize: _railIconSize,
onTap: widget.onLike, onTap: widget.onLike,
), ),
_FeedRailItem( InteractionRailItem(
key: const Key('rail-share'), key: const Key('rail-share'),
icon: Icons.ios_share_rounded, icon: Icons.ios_share_rounded,
activeIcon: Icons.ios_share_rounded,
label: widget.l10n.share, label: widget.l10n.share,
iconSize: _railIconSize,
onTap: widget.onShare, onTap: widget.onShare,
), ),
], ],
@@ -257,28 +259,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(
children: [
Flexible(
child: _DanmakuPill(
l10n: widget.l10n,
height: _pillHeight,
background: _overlayPill,
onTap: _showComingSoon,
),
),
const SizedBox(width: 12),
Flexible(
child: _FullscreenPill(
l10n: widget.l10n,
height: _pillHeight,
background: _overlayPill,
onTap: widget.onFullscreen,
),
),
],
),
const SizedBox(height: 8),
_seriesMetaRow(), _seriesMetaRow(),
const SizedBox(height: 2), const SizedBox(height: 2),
_titleRow(), _titleRow(),
@@ -405,7 +385,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
Widget _fullSeriesBar() { Widget _fullSeriesBar() {
return GestureDetector( return GestureDetector(
key: const Key('home-feed-full-series'), key: const Key('home-feed-full-series'),
onTap: widget.onEpisodes, onTap: widget.onOpenFullPlayer,
child: Container( child: Container(
height: _fullBarHeight, height: _fullBarHeight,
color: _fullBarColor, color: _fullBarColor,
@@ -447,9 +427,12 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: _FeedProgressTrack( child: _FeedProgressBar(
controller: widget.controller, controller: widget.controller,
height: _progressHeight, height: _progressHeight,
hitHeight: _progressHitHeight,
onScrubStart: widget.onScrubStart,
onSeek: widget.onSeek,
), ),
), ),
], ],
@@ -58,6 +58,45 @@ class _TopIconButton extends StatelessWidget {
} }
} }
/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。
class _RefreshButton extends StatelessWidget {
const _RefreshButton({
required this.tooltip,
required this.dimension,
required this.glyphSize,
required this.onTap,
required this.onLongPress,
});
final String tooltip;
final double dimension;
final double glyphSize;
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: Icon(
Icons.refresh_rounded,
size: glyphSize,
color: Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
),
),
);
}
}
class _FeedTabItem extends StatelessWidget { class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({ const _FeedTabItem({
required this.tab, required this.tab,
@@ -111,218 +150,101 @@ class _FeedTabItem extends StatelessWidget {
} }
} }
class _FeedRailItem extends StatelessWidget { /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
const _FeedRailItem({ class _FeedProgressBar extends StatefulWidget {
super.key, const _FeedProgressBar({
required this.icon, required this.controller,
required this.label,
required this.iconSize,
required this.onTap,
this.count,
this.countKey,
this.selected = false,
});
final IconData icon;
final String label;
final double iconSize;
final VoidCallback onTap;
final int? count;
final Key? countKey;
final bool selected;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Semantics(
button: true,
label: label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 48,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: selected ? AppColors.blue : Colors.white,
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
),
const SizedBox(height: 2),
Text(
count == null ? label : formatInteractionCount(count!),
key: countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
),
),
),
),
);
}
}
class _FeedPill extends StatelessWidget {
const _FeedPill({
required this.height, required this.height,
required this.background, required this.hitHeight,
required this.child, this.onScrubStart,
this.onSeek,
}); });
final double height;
final Color background;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
),
child: Center(child: child),
);
}
}
class _DanmakuPill extends StatelessWidget {
const _DanmakuPill({
required this.l10n,
required this.height,
required this.background,
required this.onTap,
});
final AppLocalizations l10n;
final double height;
final Color background;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-danmaku'),
onTap: onTap,
child: _FeedPill(
height: height,
background: background,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'弹',
style: TextStyle(color: Colors.white, fontSize: 12),
),
const SizedBox(width: 6),
Flexible(
child: Text(
l10n.danmaku,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
],
),
),
);
}
}
class _FullscreenPill extends StatelessWidget {
const _FullscreenPill({
required this.l10n,
required this.height,
required this.background,
required this.onTap,
});
final AppLocalizations l10n;
final double height;
final Color background;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-fullscreen'),
onTap: onTap,
child: _FeedPill(
height: height,
background: background,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fullscreen_rounded, color: Colors.white, size: 14),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.watchFullscreen,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
],
),
),
);
}
}
class _FeedProgressTrack extends StatelessWidget {
const _FeedProgressTrack({required this.controller, required this.height});
final ValueListenable<VideoPlayerValue>? controller; final ValueListenable<VideoPlayerValue>? controller;
final double height; final double height;
final double hitHeight;
final VoidCallback? onScrubStart;
final ValueChanged<Duration>? onSeek;
@override
State<_FeedProgressBar> createState() => _FeedProgressBarState();
}
class _FeedProgressBarState extends State<_FeedProgressBar> {
double? _preview;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final source = controller; return LayoutBuilder(
if (source == null) return _track(0); builder: (context, constraints) {
return ValueListenableBuilder<VideoPlayerValue>( final width = constraints.maxWidth;
final source = widget.controller;
final track = source == null
? _track(0, width)
: ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source, valueListenable: source,
builder: (context, value, _) { builder: (context, value, _) {
final total = value.duration.inMilliseconds; final total = value.duration.inMilliseconds;
final fraction = total <= 0 final played = total <= 0
? 0.0 ? 0.0
: (value.position.inMilliseconds / total).clamp(0.0, 1.0); : (value.position.inMilliseconds / total)
return _track(fraction.toDouble()); .clamp(0.0, 1.0)
.toDouble();
return _track(_preview ?? played, width);
},
);
return GestureDetector(
key: const Key('home-feed-progress'),
behavior: HitTestBehavior.opaque,
onTapDown: (details) {
widget.onScrubStart?.call();
_commit(details.localPosition.dx / width);
},
onHorizontalDragStart: (details) {
widget.onScrubStart?.call();
_update(details.localPosition.dx / width);
},
onHorizontalDragUpdate: (details) =>
_update(details.localPosition.dx / width),
onHorizontalDragEnd: (_) => _commit(_preview),
onHorizontalDragCancel: () => setState(() => _preview = null),
child: SizedBox(
height: widget.hitHeight,
child: Align(alignment: Alignment.bottomCenter, child: track),
),
);
}, },
); );
} }
Widget _track(double fraction) { void _update(double fraction) {
return LayoutBuilder( setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
builder: (context, constraints) => SizedBox( }
height: height,
void _commit(double? fraction) {
if (fraction == null) return;
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
if (total > 0) {
widget.onSeek?.call(
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
);
}
setState(() => _preview = null);
}
Widget _track(double fraction, double width) {
return SizedBox(
height: widget.height,
child: Stack( child: Stack(
children: [ children: [
Container(width: width, height: widget.height, color: Colors.white24),
Container( Container(
width: constraints.maxWidth, width: width * fraction,
height: height, height: widget.height,
color: Colors.white24,
),
Container(
width: constraints.maxWidth * fraction,
height: height,
color: AppColors.blue, color: AppColors.blue,
), ),
], ],
), ),
),
); );
} }
} }
@@ -0,0 +1,99 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
const double _buttonSize = 34;
const double _iconSize = 24;
const double _itemGap = 18;
const Color _buttonBackground = Color(0x80000000);
/// 互动栏单项:图标、计数与点击。
class InteractionRailItem {
const InteractionRailItem({
required this.key,
required this.icon,
required this.activeIcon,
required this.label,
required this.onTap,
this.count,
this.countKey,
this.selected = false,
});
final Key key;
final IconData icon;
final IconData activeIcon;
final String label;
final VoidCallback onTap;
final int? count;
final Key? countKey;
final bool selected;
}
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
class InteractionRail extends StatelessWidget {
const InteractionRail({super.key, required this.items});
final List<InteractionRailItem> items;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < items.length; index++) ...[
if (index > 0) const SizedBox(height: _itemGap),
_RailButton(item: items[index]),
],
],
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({required this.item});
final InteractionRailItem item;
@override
Widget build(BuildContext context) {
return Column(
key: item.key,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: _buttonSize,
child: IconButton(
onPressed: item.onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: _buttonBackground,
foregroundColor: item.selected ? AppColors.blue : Colors.white,
),
icon: Icon(
item.selected ? item.activeIcon : item.icon,
size: _iconSize,
),
),
),
const SizedBox(height: 2),
Text(
item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
height: 1,
fontSize: 11,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
),
],
);
}
}
+41 -14
View File
@@ -1,12 +1,12 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'player_center_toggle.dart'; import 'player_center_toggle.dart';
import 'interaction_rail.dart';
import 'player_overlay_models.dart'; import 'player_overlay_models.dart';
import 'seek_progress_bar.dart'; import 'seek_progress_bar.dart';
@@ -14,8 +14,6 @@ part 'player_overlay_parts.dart';
const double _topButtonSize = 36; const double _topButtonSize = 36;
const double _topBarHeight = 58; const double _topBarHeight = 58;
const double _railButtonSize = 34;
const double _railGap = 18;
const double _boostHintIconSize = 16; const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13; const double _boostHintFontSize = 13;
const List<Shadow> _playerTextShadows = [ const List<Shadow> _playerTextShadows = [
@@ -144,17 +142,46 @@ class PlayerPlaybackOverlay extends StatelessWidget {
return Positioned( return Positioned(
right: AppDimens.spaceMd, right: AppDimens.spaceMd,
top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55), top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55),
child: _Rail( child: InteractionRail(
l10n: data.l10n, key: const Key('player-interaction-rail'),
liked: data.liked, items: [
saved: data.saved, InteractionRailItem(
likeCount: data.likeCount, key: const Key('player-rail-save'),
favoriteCount: data.favoriteCount, icon: Icons.star_border_rounded,
commentCount: data.commentCount, activeIcon: Icons.star_rounded,
onLike: actions.onLike, label: data.l10n.favorite,
onComments: actions.onComments, count: data.favoriteCount,
onSave: actions.onSave, countKey: const Key('player-rail-save-count'),
onShare: actions.onShare, selected: data.saved,
onTap: actions.onSave,
),
InteractionRailItem(
key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
activeIcon: Icons.chat_bubble_rounded,
label: data.l10n.comments,
count: data.commentCount,
countKey: const Key('player-rail-comments-count'),
onTap: actions.onComments,
),
InteractionRailItem(
key: const Key('player-rail-like'),
icon: Icons.favorite_border_rounded,
activeIcon: Icons.favorite_rounded,
label: data.l10n.like,
count: data.likeCount,
countKey: const Key('player-rail-like-count'),
selected: data.liked,
onTap: actions.onLike,
),
InteractionRailItem(
key: const Key('player-rail-share'),
icon: Icons.ios_share_rounded,
activeIcon: Icons.ios_share_rounded,
label: data.l10n.share,
onTap: actions.onShare,
),
],
), ),
); );
} }
@@ -105,121 +105,6 @@ class _BottomIconButton extends StatelessWidget {
} }
} }
class _Rail extends StatelessWidget {
const _Rail({
required this.l10n,
required this.liked,
required this.saved,
required this.likeCount,
required this.favoriteCount,
required this.commentCount,
required this.onLike,
required this.onComments,
required this.onSave,
required this.onShare,
});
final AppLocalizations l10n;
final bool liked;
final bool saved;
final int? likeCount;
final int? favoriteCount;
final int? commentCount;
final VoidCallback onLike;
final VoidCallback onComments;
final VoidCallback onSave;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
return Column(
key: const Key('player-interaction-rail'),
children: [
_RailButton(
key: const Key('player-rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: favoriteCount == null
? l10n.favorite
: formatInteractionCount(favoriteCount!),
active: saved,
onTap: onSave,
),
const SizedBox(height: _railGap),
_RailButton(
key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: commentCount == null
? l10n.comments
: formatInteractionCount(commentCount!),
onTap: onComments,
),
const SizedBox(height: _railGap),
_RailButton(
key: const Key('player-rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: likeCount == null
? l10n.like
: formatInteractionCount(likeCount!),
active: liked,
onTap: onLike,
),
const SizedBox(height: _railGap),
_RailButton(
key: const Key('player-rail-share'),
icon: Icons.ios_share_rounded,
label: l10n.share,
onTap: onShare,
),
],
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.active = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool active;
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox.square(
dimension: _railButtonSize,
child: IconButton(
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0x80000000),
foregroundColor: active ? AppColors.blue : AppColors.onPrimary,
),
icon: Icon(icon, size: 24),
),
),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class _EpisodeSynopsis extends StatefulWidget { class _EpisodeSynopsis extends StatefulWidget {
const _EpisodeSynopsis({ const _EpisodeSynopsis({
super.key, super.key,
+81 -1
View File
@@ -14,6 +14,7 @@ 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_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:video_player/video_player.dart';
import '../support/fake_catalog.dart'; import '../support/fake_catalog.dart';
import '../support/fake_category.dart'; import '../support/fake_category.dart';
@@ -112,7 +113,8 @@ void main() {
onShare: () {}, onShare: () {},
onEpisodes: () {}, onEpisodes: () {},
onDetail: () {}, onDetail: () {},
onFullscreen: () {}, onOpenFullPlayer: () {},
onRefreshFeed: () {},
onDownload: () {}, onDownload: () {},
onClear: () {}, onClear: () {},
onSearch: () {}, onSearch: () {},
@@ -145,4 +147,82 @@ void main() {
'12', '12',
); );
}); });
testWidgets('should_refresh_seek_and_open_player_from_feed_actions', (
tester,
) async {
var refreshed = 0;
var opened = 0;
Duration? seeked;
final controller = ValueNotifier<VideoPlayerValue>(
VideoPlayerValue(
duration: const Duration(seconds: 100),
position: const Duration(seconds: 10),
isInitialized: true,
),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedOverlay(
l10n: AppLocalizations.of(context)!,
drama: const Drama(
id: 'action-drama',
title: 'Action Drama',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 8,
viewCount: 1200,
),
episode: const Episode(
id: 'action-episode',
number: 1,
title: 'Episode 1',
isFree: true,
),
controller: controller,
liked: false,
saved: false,
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onEpisodes: () {},
onDetail: () {},
onOpenFullPlayer: () => opened++,
onRefreshFeed: () => refreshed++,
onDownload: () {},
onClear: () {},
onSearch: () {},
onScrubStart: () {},
onSeek: (value) => seeked = value,
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('home-feed-refresh')));
await tester.pump();
expect(refreshed, 1);
await tester.drag(
find.byKey(const Key('home-feed-progress')),
const Offset(120, 0),
);
await tester.pumpAndSettle();
expect(seeked, isNotNull);
expect(seeked!.inSeconds, greaterThan(0));
await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pump();
expect(opened, 1);
});
} }
+11 -9
View File
@@ -65,27 +65,29 @@ void main() {
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data, tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
'3.2万', '3.2万',
); );
expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget);
expect(find.byKey(const Key('home-feed-danmaku')), findsNothing);
expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing);
expect(
tester.widget<Scaffold>(find.byType(Scaffold).first).backgroundColor,
Colors.black,
);
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue); expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
}); });
testWidgets('should_open_episode_sheet_from_full_series_bar', (tester) async { testWidgets('should_open_full_player_from_full_series_bar', (tester) async {
await _pumpApp(tester); await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.tap(find.byKey(const Key('home-feed-full-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); final player = tester.widget<PlayerPage>(find.byType(PlayerPage).last);
expect(player.homeMode, isFalse);
}); });
testWidgets('should_show_coming_soon_for_danmaku_and_anime_tab', ( testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
tester,
) async {
await _pumpApp(tester); await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-feed-danmaku')));
await tester.pump();
expect(find.text('Coming soon'), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pump(); await tester.pump();
expect(find.text('Coming soon'), findsOneWidget); expect(find.text('Coming soon'), findsOneWidget);