diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bd73cbc..0894619 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,8 +29,6 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", - "danmaku": "Danmaku", - "watchFullscreen": "Full screen", "seriesMeta": "Series · {episodes} eps | {viewers} watching", "@seriesMeta": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8ae2c4c..2549d49 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,8 +29,6 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", - "danmaku": "Danmaku", - "watchFullscreen": "Layar penuh", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "watchFullSeries": "Tonton serial lengkap · {episodes} eps", "feedTabAnime": "Anime", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1da0b30..e902046 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,18 +274,6 @@ abstract class AppLocalizations { /// **'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. /// /// In en, this message translates to: @@ -2734,6 +2722,12 @@ abstract class AppLocalizations { /// **'Clear-screen playback'** String get clearPlayback; + /// No description provided for @danmaku. + /// + /// In en, this message translates to: + /// **'Danmaku'** + String get danmaku; + /// No description provided for @danmakuUnavailable. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a0b04dc..6c47931 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,12 +98,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; - @override - String get danmaku => 'Danmaku'; - - @override - String get watchFullscreen => 'Full screen'; - @override String seriesMeta(String episodes, String viewers) { return 'Series · $episodes eps | $viewers watching'; @@ -1424,6 +1418,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearPlayback => 'Clear-screen playback'; + @override + String get danmaku => 'Danmaku'; + @override String get danmakuUnavailable => 'Coming soon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f0d6374..4a4f89a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,12 +98,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; - @override - String get danmaku => 'Komentar layar'; - - @override - String get watchFullscreen => 'Layar penuh'; - @override String seriesMeta(String episodes, String viewers) { return 'Serial · $episodes eps | $viewers menonton'; @@ -1424,6 +1418,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearPlayback => 'Putar tanpa kontrol'; + @override + String get danmaku => 'Komentar layar'; + @override String get danmakuUnavailable => 'Belum tersedia'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1f82f34..6e53bfa 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,12 +95,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; - @override - String get danmaku => '弹幕'; - - @override - String get watchFullscreen => '全屏观看'; - @override String seriesMeta(String episodes, String viewers) { return '系列剧 · 共$episodes集 | 共$viewers人在追'; @@ -1400,6 +1394,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearPlayback => '清屏播放'; + @override + String get danmaku => '弹幕'; + @override String get danmakuUnavailable => '暂未开放'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 039959f..dd70f0c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,8 +29,6 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", - "danmaku": "弹幕", - "watchFullscreen": "全屏观看", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "watchFullSeries": "观看完整剧集 · 全{episodes}集", "feedTabAnime": "漫剧", diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 084ecae..a03c011 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -73,6 +73,7 @@ class HomePage extends StatelessWidget { return _HomeTabs( key: ValueKey(catalog.dramas.first.id), dramas: catalog.dramas, + onRefreshFeed: catalog.load, ); } } @@ -97,9 +98,14 @@ class HomeFailureView extends StatelessWidget { /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { - const _HomeTabs({super.key, required this.dramas}); + const _HomeTabs({ + super.key, + required this.dramas, + required this.onRefreshFeed, + }); final List dramas; + final VoidCallback onRefreshFeed; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -138,7 +144,7 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: Colors.transparent, + backgroundColor: logic.tab == 0 ? Colors.black : Colors.transparent, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), @@ -165,6 +171,9 @@ class _HomeTabsState extends State<_HomeTabs> { drama: widget.dramas.first, initialIndex: 0, feedDramas: widget.dramas, + onOpenFullPlayer: (drama, episode) => + logic.playDrama(context, drama, episodeId: episode.id), + onRefreshFeed: widget.onRefreshFeed, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => logic.select(1, search: true), diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index ccf5d93..4d3ff00 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -55,6 +55,8 @@ class PlayerPage extends StatefulWidget { this.feedDramas, this.onDramaChanged, this.onImmersiveChanged, + this.onOpenFullPlayer, + this.onRefreshFeed, this.wallet, this.downloads, }); @@ -73,6 +75,8 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; + final void Function(Drama drama, Episode episode)? onOpenFullPlayer; + final VoidCallback? onRefreshFeed; final WalletController? wallet; final DownloadController? downloads; @@ -411,12 +415,20 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), onDetail: () => unawaited(logic.openDetail(context)), - onFullscreen: () => - unawaited(_openFullscreen(context, drama, episode)), + onOpenFullPlayer: () => + widget.onOpenFullPlayer?.call(drama, episode), + onRefreshFeed: () => widget.onRefreshFeed?.call(), onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onScrubStart: ready + ? () => logic.beginScrub(videoPort!) + : null, + onSeek: ready + ? (position) => + logic.seekTo(context, videoPort!, position) + : null, onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 52ecf84..3f9fd81 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -43,25 +43,6 @@ extension _PlayerViewActions on _PlayerPageState { ); } - /// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。 - Future _openFullscreen( - BuildContext context, - Drama drama, - Episode episode, - ) async { - final index = drama.episodes.indexWhere((item) => item.id == episode.id); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( - drama: drama, - initialIndex: index < 0 ? 0 : index, - wallet: widget.wallet, - downloads: widget.downloads, - ), - ), - ); - } - Future _openUnlockSheet( BuildContext context, int index, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index a6cd45b..939109f 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -9,6 +9,7 @@ import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; +import 'interaction_rail.dart'; part 'home_feed_overlay_parts.dart'; @@ -16,7 +17,6 @@ const Color _overlayPill = Color(0x66000000); const Color _fullBarColor = Color(0xCC000000); const double _topIconSize = 36; const double _topIconGlyph = 22; -const double _pillHeight = 34; const double _tabGap = 22; const double _tabFontSize = 15; const double _underlineWidth = 18; @@ -24,9 +24,9 @@ const double _underlineHeight = 2; const double _titleFontSize = 17; const double _metaFontSize = 12; const double _tagFontSize = 11; -const double _railIconSize = 22; const double _fullBarHeight = 48; const double _progressHeight = 2; +const double _progressHitHeight = 20; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -46,10 +46,13 @@ class HomeFeedOverlay extends StatefulWidget { required this.onShare, required this.onEpisodes, required this.onDetail, - required this.onFullscreen, + required this.onOpenFullPlayer, + required this.onRefreshFeed, required this.onDownload, required this.onClear, required this.onSearch, + this.onScrubStart, + this.onSeek, this.likeCount, this.favoriteCount, this.commentCount, @@ -73,11 +76,14 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onShare; final VoidCallback onEpisodes; final VoidCallback onDetail; - final VoidCallback onFullscreen; + final VoidCallback onOpenFullPlayer; + final VoidCallback onRefreshFeed; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final ValueChanged? onSeek; @override State createState() => _HomeFeedOverlayState(); @@ -145,13 +151,12 @@ class _HomeFeedOverlayState extends State { padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), child: Row( children: [ - _TopIconButton( - key: const Key('home-feed-menu'), - tooltip: widget.l10n.more, - icon: Icons.menu_rounded, + _RefreshButton( + tooltip: widget.l10n.retry, dimension: _topIconSize, glyphSize: _topIconGlyph, - onPressed: () => _openMore(context), + onTap: widget.onRefreshFeed, + onLongPress: () => _openMore(context), ), Expanded( child: Center( @@ -205,45 +210,42 @@ class _HomeFeedOverlayState extends State { } Widget _rail() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - _FeedRailItem( + return InteractionRail( + items: [ + InteractionRailItem( 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, countKey: const Key('rail-save-count'), - label: widget.l10n.favorite, selected: widget.saved, - iconSize: _railIconSize, onTap: widget.onSave, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, + activeIcon: Icons.chat_bubble_rounded, + label: widget.l10n.comments, count: widget.commentCount, countKey: const Key('rail-comments-count'), - label: widget.l10n.comments, - iconSize: _railIconSize, onTap: widget.onComments, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-like'), - icon: widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + icon: Icons.favorite_border_rounded, + activeIcon: Icons.favorite_rounded, + label: widget.l10n.like, count: widget.likeCount, countKey: const Key('rail-like-count'), - label: widget.l10n.like, selected: widget.liked, - iconSize: _railIconSize, onTap: widget.onLike, ), - _FeedRailItem( + InteractionRailItem( key: const Key('rail-share'), icon: Icons.ios_share_rounded, + activeIcon: Icons.ios_share_rounded, label: widget.l10n.share, - iconSize: _railIconSize, onTap: widget.onShare, ), ], @@ -257,28 +259,6 @@ class _HomeFeedOverlayState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, 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(), const SizedBox(height: 2), _titleRow(), @@ -405,7 +385,7 @@ class _HomeFeedOverlayState extends State { Widget _fullSeriesBar() { return GestureDetector( key: const Key('home-feed-full-series'), - onTap: widget.onEpisodes, + onTap: widget.onOpenFullPlayer, child: Container( height: _fullBarHeight, color: _fullBarColor, @@ -447,9 +427,12 @@ class _HomeFeedOverlayState extends State { left: 0, right: 0, bottom: 0, - child: _FeedProgressTrack( + child: _FeedProgressBar( controller: widget.controller, height: _progressHeight, + hitHeight: _progressHitHeight, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, ), ), ], diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index aed95ef..8e2485e 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -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 { const _FeedTabItem({ required this.tab, @@ -111,217 +150,100 @@ class _FeedTabItem extends StatelessWidget { } } -class _FeedRailItem extends StatelessWidget { - const _FeedRailItem({ - super.key, - required this.icon, - 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({ +/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 +class _FeedProgressBar extends StatefulWidget { + const _FeedProgressBar({ + required this.controller, required this.height, - required this.background, - required this.child, + required this.hitHeight, + 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? controller; final double height; + final double hitHeight; + final VoidCallback? onScrubStart; + final ValueChanged? onSeek; + + @override + State<_FeedProgressBar> createState() => _FeedProgressBarState(); +} + +class _FeedProgressBarState extends State<_FeedProgressBar> { + double? _preview; @override Widget build(BuildContext context) { - final source = controller; - if (source == null) return _track(0); - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) { - final total = value.duration.inMilliseconds; - final fraction = total <= 0 - ? 0.0 - : (value.position.inMilliseconds / total).clamp(0.0, 1.0); - return _track(fraction.toDouble()); + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final source = widget.controller; + final track = source == null + ? _track(0, width) + : ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) { + final total = value.duration.inMilliseconds; + final played = total <= 0 + ? 0.0 + : (value.position.inMilliseconds / total) + .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) { - return LayoutBuilder( - builder: (context, constraints) => SizedBox( - height: height, - child: Stack( - children: [ - Container( - width: constraints.maxWidth, - height: height, - color: Colors.white24, - ), - Container( - width: constraints.maxWidth * fraction, - height: height, - color: AppColors.blue, - ), - ], - ), + void _update(double fraction) { + setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); + } + + 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( + children: [ + Container(width: width, height: widget.height, color: Colors.white24), + Container( + width: width * fraction, + height: widget.height, + color: AppColors.blue, + ), + ], ), ); } diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart new file mode 100644 index 0000000..1411bc3 --- /dev/null +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -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 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)], + ), + ), + ], + ); + } +} diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index bcf586f..910cc13 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,12 +1,12 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import 'player_center_toggle.dart'; +import 'interaction_rail.dart'; import 'player_overlay_models.dart'; import 'seek_progress_bar.dart'; @@ -14,8 +14,6 @@ part 'player_overlay_parts.dart'; const double _topButtonSize = 36; const double _topBarHeight = 58; -const double _railButtonSize = 34; -const double _railGap = 18; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; const List _playerTextShadows = [ @@ -144,17 +142,46 @@ class PlayerPlaybackOverlay extends StatelessWidget { return Positioned( right: AppDimens.spaceMd, top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55), - child: _Rail( - l10n: data.l10n, - liked: data.liked, - saved: data.saved, - likeCount: data.likeCount, - favoriteCount: data.favoriteCount, - commentCount: data.commentCount, - onLike: actions.onLike, - onComments: actions.onComments, - onSave: actions.onSave, - onShare: actions.onShare, + child: InteractionRail( + key: const Key('player-interaction-rail'), + items: [ + InteractionRailItem( + key: const Key('player-rail-save'), + icon: Icons.star_border_rounded, + activeIcon: Icons.star_rounded, + label: data.l10n.favorite, + count: data.favoriteCount, + countKey: const Key('player-rail-save-count'), + 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, + ), + ], ), ); } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index c78b216..383365d 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -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 { const _EpisodeSynopsis({ super.key, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 8a5aacc..cc195f9 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -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/l10n/app_localizations.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_category.dart'; @@ -112,7 +113,8 @@ void main() { onShare: () {}, onEpisodes: () {}, onDetail: () {}, - onFullscreen: () {}, + onOpenFullPlayer: () {}, + onRefreshFeed: () {}, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -145,4 +147,82 @@ void main() { '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( + 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); + }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 148bdf8..6fcf919 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -65,27 +65,29 @@ void main() { tester.widget(find.byKey(const Key('rail-comments-count'))).data, '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(find.byType(Scaffold).first).backgroundColor, + Colors.black, + ); expect(tester.widget(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 tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isFalse); }); - testWidgets('should_show_coming_soon_for_danmaku_and_anime_tab', ( - tester, - ) async { + testWidgets('should_show_coming_soon_for_anime_tab', (tester) async { 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.pump(); expect(find.text('Coming soon'), findsOneWidget);