diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ac06870..0483ed0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,26 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "danmaku": "Danmaku", + "watchFullscreen": "Full screen", + "seriesMeta": "Series · {episodes} eps | {viewers} watching", + "@seriesMeta": { + "placeholders": { + "episodes": {"type": "String"}, + "viewers": {"type": "String"} + } + }, + "watchFullSeries": "Watch full series · {episodes} eps", + "@watchFullSeries": { + "placeholders": { + "episodes": {"type": "String"} + } + }, + "feedTabFollowing": "Following", + "feedTabAnime": "Anime", + "feedTabReal": "Live-action", + "feedTabForYou": "For you", + "authorNoteLabel": "Author note", "reserve": "Reserve", "heatLabel": "{count} heat", "@heatLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9d469cd..98312cb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,15 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "danmaku": "Danmaku", + "watchFullscreen": "Layar penuh", + "seriesMeta": "Serial · {episodes} eps | {viewers} menonton", + "watchFullSeries": "Tonton serial lengkap · {episodes} eps", + "feedTabFollowing": "Mengikuti", + "feedTabAnime": "Anime", + "feedTabReal": "Live-action", + "feedTabForYou": "Untukmu", + "authorNoteLabel": "Catatan penulis", "reserve": "Reservasi", "heatLabel": "{count} panas", "topCharts": "Peringkat Teratas", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ed8a28..b01c917 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,60 @@ 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: + /// **'Series · {episodes} eps | {viewers} watching'** + String seriesMeta(String episodes, String viewers); + + /// No description provided for @watchFullSeries. + /// + /// In en, this message translates to: + /// **'Watch full series · {episodes} eps'** + String watchFullSeries(String episodes); + + /// No description provided for @feedTabFollowing. + /// + /// In en, this message translates to: + /// **'Following'** + String get feedTabFollowing; + + /// No description provided for @feedTabAnime. + /// + /// In en, this message translates to: + /// **'Anime'** + String get feedTabAnime; + + /// No description provided for @feedTabReal. + /// + /// In en, this message translates to: + /// **'Live-action'** + String get feedTabReal; + + /// No description provided for @feedTabForYou. + /// + /// In en, this message translates to: + /// **'For you'** + String get feedTabForYou; + + /// No description provided for @authorNoteLabel. + /// + /// In en, this message translates to: + /// **'Author note'** + String get authorNoteLabel; + /// No description provided for @reserve. /// /// In en, this message translates to: @@ -2686,12 +2740,6 @@ 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 94ed571..2a273c4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,37 @@ 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'; + } + + @override + String watchFullSeries(String episodes) { + return 'Watch full series · $episodes eps'; + } + + @override + String get feedTabFollowing => 'Following'; + + @override + String get feedTabAnime => 'Anime'; + + @override + String get feedTabReal => 'Live-action'; + + @override + String get feedTabForYou => 'For you'; + + @override + String get authorNoteLabel => 'Author note'; + @override String get reserve => 'Reserve'; @@ -1396,9 +1427,6 @@ 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 afd95c8..c257713 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,37 @@ 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'; + } + + @override + String watchFullSeries(String episodes) { + return 'Tonton serial lengkap · $episodes eps'; + } + + @override + String get feedTabFollowing => 'Mengikuti'; + + @override + String get feedTabAnime => 'Anime'; + + @override + String get feedTabReal => 'Live-action'; + + @override + String get feedTabForYou => 'Untukmu'; + + @override + String get authorNoteLabel => 'Catatan penulis'; + @override String get reserve => 'Reservasi'; @@ -1396,9 +1427,6 @@ 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 7a33e7b..3ca6a26 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,6 +95,37 @@ 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人在追'; + } + + @override + String watchFullSeries(String episodes) { + return '观看完整剧集 · 全$episodes集'; + } + + @override + String get feedTabFollowing => '关注'; + + @override + String get feedTabAnime => '漫剧'; + + @override + String get feedTabReal => '真人剧'; + + @override + String get feedTabForYou => '推荐'; + + @override + String get authorNoteLabel => '作者声明'; + @override String get reserve => '预约'; @@ -1372,9 +1403,6 @@ 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 bae635d..1c89f23 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,6 +29,15 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "danmaku": "弹幕", + "watchFullscreen": "全屏观看", + "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", + "watchFullSeries": "观看完整剧集 · 全{episodes}集", + "feedTabFollowing": "关注", + "feedTabAnime": "漫剧", + "feedTabReal": "真人剧", + "feedTabForYou": "推荐", + "authorNoteLabel": "作者声明", "reserve": "预约", "heatLabel": "{count}热度", "topCharts": "排行榜", diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index d7f0652..ccf5d93 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -367,8 +367,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, drama: drama, episode: episode, + controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id), + likeCount: + interactions.likeCount(drama.id) ?? drama.likeCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: drama.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -404,6 +410,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), + onDetail: () => unawaited(logic.openDetail(context)), + onFullscreen: () => + unawaited(_openFullscreen(context, drama, episode)), onDownload: () => requestEpisodeDownload(context, episode.id), onClear: () => setState(() => _chromeVisible = false), onSearch: diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 3f9fd81..52ecf84 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -43,6 +43,25 @@ 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 220d1ab..a6cd45b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -1,422 +1,479 @@ // Author: fruit.com.cn -import 'dart:async'; - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/glass_panel.dart'; import '../../../l10n/app_localizations.dart'; -/// Home feed layer from `docs/移动端设计预览.html` screen 01. -class HomeFeedOverlay extends StatelessWidget { +part 'home_feed_overlay_parts.dart'; + +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; +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 int _maxTags = 4; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。 +class HomeFeedOverlay extends StatefulWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, + required this.controller, required this.liked, - this.likeCount, required this.saved, required this.onLike, required this.onSave, required this.onComments, required this.onShare, required this.onEpisodes, + required this.onDetail, + required this.onFullscreen, required this.onDownload, required this.onClear, required this.onSearch, + this.likeCount, + this.favoriteCount, + this.commentCount, + this.authorNote = '', this.onPip, }); final AppLocalizations l10n; final Drama drama; final Episode episode; + final ValueListenable? controller; final bool liked; final int? likeCount; + final int? favoriteCount; + final int? commentCount; final bool saved; + final String authorNote; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onComments; final VoidCallback onShare; final VoidCallback onEpisodes; + final VoidCallback onDetail; + final VoidCallback onFullscreen; final VoidCallback onDownload; final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; + @override + State createState() => _HomeFeedOverlayState(); +} + +class _HomeFeedOverlayState extends State { + _FeedTab _tab = _FeedTab.recommend; + @override Widget build(BuildContext context) { return SafeArea( bottom: false, - child: Column( + child: Stack( children: [ - _header(context), - Expanded( - child: Align( - alignment: const Alignment(1.0, 0.45), - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), - child: _rail(), - ), - ), + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer(child: _gradient(top: true)), ), - Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), - child: _infoCard(), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer(child: _gradient(top: false)), + ), + Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: const Alignment(1.0, 0.45), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 10, 16), + child: _rail(), + ), + ), + ), + _bottomInfo(), + _fullSeriesBar(), + ], ), ], ), ); } + Widget _gradient({required bool top}) { + return Container( + height: top ? 140 : 360, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: top ? Alignment.topCenter : Alignment.bottomCenter, + end: top ? Alignment.bottomCenter : Alignment.topCenter, + colors: top + ? const [Color(0xB3000000), Color(0x00000000)] + : const [Color(0xE6000000), Color(0x00000000)], + ), + ), + ); + } + Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), + 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, + dimension: _topIconSize, + glyphSize: _topIconGlyph, + onPressed: () => _openMore(context), + ), Expanded( - child: Text( - localizedDramaTitle(l10n, drama), - key: const Key('home-feed-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < _FeedTab.values.length; index++) + _tabSlot(_FeedTab.values[index], index), + ], + ), ), ), ), - _RoundIcon( + _TopIconButton( key: const Key('home-feed-search'), - tooltip: l10n.search, + tooltip: widget.l10n.search, icon: Icons.search_rounded, - onPressed: onSearch, - ), - const SizedBox(width: 8), - _RoundIcon( - key: const Key('home-feed-clear-screen'), - tooltip: l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: onClear, - ), - const SizedBox(width: 8), - _RoundIcon( - key: const Key('home-feed-more'), - tooltip: l10n.more, - icon: Icons.menu_rounded, - onPressed: () => _openMore(context), + dimension: _topIconSize, + glyphSize: _topIconGlyph, + onPressed: widget.onSearch, ), ], ), ); } + Widget _tabSlot(_FeedTab tab, int index) { + final item = _FeedTabItem( + tab: tab, + label: tab.label(widget.l10n), + selected: _tab == tab, + fontSize: _tabFontSize, + underlineWidth: _underlineWidth, + underlineHeight: _underlineHeight, + onTap: () => _selectTab(tab), + ); + if (index == 0) return item; + return Padding( + padding: const EdgeInsets.only(left: _tabGap), + child: item, + ); + } + + void _selectTab(_FeedTab tab) { + if (tab.comingSoon) { + _showComingSoon(); + return; + } + setState(() => _tab = tab); + } + Widget _rail() { return Column( mainAxisSize: MainAxisSize.min, children: [ - _RailItem( - key: const Key('rail-like'), - icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - label: likeCount?.toString() ?? l10n.like, - countKey: likeCount == null ? null : const Key('rail-like-count'), - selected: liked, - onTap: onLike, + _FeedRailItem( + key: const Key('rail-save'), + icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + count: widget.favoriteCount, + countKey: const Key('rail-save-count'), + label: widget.l10n.favorite, + selected: widget.saved, + iconSize: _railIconSize, + onTap: widget.onSave, ), - _RailItem( + _FeedRailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, - onTap: onComments, + count: widget.commentCount, + countKey: const Key('rail-comments-count'), + label: widget.l10n.comments, + iconSize: _railIconSize, + onTap: widget.onComments, ), - _RailItem( + _FeedRailItem( + key: const Key('rail-like'), + icon: widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + count: widget.likeCount, + countKey: const Key('rail-like-count'), + label: widget.l10n.like, + selected: widget.liked, + iconSize: _railIconSize, + onTap: widget.onLike, + ), + _FeedRailItem( key: const Key('rail-share'), icon: Icons.ios_share_rounded, - label: l10n.share, - onTap: onShare, - ), - _RailItem( - key: const Key('rail-save'), - icon: saved ? Icons.star_rounded : Icons.star_border_rounded, - label: l10n.favorite, - selected: saved, - onTap: onSave, + label: widget.l10n.share, + iconSize: _railIconSize, + onTap: widget.onShare, ), ], ); } - Widget _infoCard() { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; - return GlassPanel( - key: const Key('home-feed-info-card'), - radius: 14, - blur: 6, - padding: const EdgeInsets.all(12), - borderColor: AppColors.border, + Widget _bottomInfo() { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), child: Column( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, + Row( children: [ - if (genre.isNotEmpty) - _tag( - genre, - background: AppColors.blue, - foreground: AppColors.onPrimary, + Flexible( + child: _DanmakuPill( + l10n: widget.l10n, + height: _pillHeight, + background: _overlayPill, + onTap: _showComingSoon, ), - if (drama.category.isNotEmpty && drama.category != genre) - _tag( - drama.category, - background: AppColors.raised, - foreground: AppColors.onSurface, + ), + const SizedBox(width: 12), + Flexible( + child: _FullscreenPill( + l10n: widget.l10n, + height: _pillHeight, + background: _overlayPill, + onTap: widget.onFullscreen, ), - Text( - l10n.episodeOfTotal(episode.number, drama.episodeCount), - key: const Key('home-feed-episode'), - style: const TextStyle(color: AppColors.muted, fontSize: 9), ), ], ), const SizedBox(height: 8), - Text( - episode.title.trim().isEmpty - ? localizedDramaTitle(l10n, drama) - : episode.title, - key: const Key('home-feed-episode-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w800, + _seriesMetaRow(), + const SizedBox(height: 2), + _titleRow(), + const SizedBox(height: 6), + _tagsRow(), + if (widget.authorNote.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + '${widget.l10n.authorNoteLabel}: ${widget.authorNote}', + key: const Key('home-feed-author-note'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ], + ), + ); + } + + Widget _seriesMetaRow() { + return GestureDetector( + key: const Key('home-feed-series-meta'), + onTap: widget.onDetail, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.l10n.seriesMeta( + '${widget.drama.episodeCount}', + formatInteractionCount(widget.drama.viewCount), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white70, + fontSize: _metaFontSize, + ), ), ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: SizedBox( - height: 34, - child: FilledButton.icon( - key: const Key('home-feed-follow-series'), - onPressed: onSave, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppDimens.buttonRadius, - ), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 16), - label: Text( - saved ? l10n.followingTab : l10n.followSeries, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 34, - child: FilledButton( - key: const Key('home-feed-episodes'), - onPressed: onEpisodes, - style: FilledButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - AppDimens.buttonRadius, - ), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - child: Text( - l10n.episodes, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - ], + const Icon( + Icons.chevron_right_rounded, + color: Colors.white70, + size: 16, ), ], ), ); } - Widget _tag( - String label, { - required Color background, - required Color foreground, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(6), + Widget _titleRow() { + return GestureDetector( + onTap: widget.onDetail, + child: Row( + children: [ + Flexible( + child: Text( + localizedDramaTitle(widget.l10n, widget.drama), + key: const Key('home-feed-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: Colors.white, + fontSize: _titleFontSize, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 20, + ), + ], ), - child: Text( - label, - maxLines: 1, - style: TextStyle( - color: foreground, - fontSize: 8, - fontWeight: FontWeight.w800, + ); + } + + Widget _tagsRow() { + final tags = [ + widget.l10n.episodeLabel(widget.episode.number), + ..._tagValues(), + ].take(_maxTags).toList(); + return Wrap( + key: const Key('home-feed-tags'), + spacing: 6, + runSpacing: 4, + children: [ + for (final tag in tags) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _overlayPill, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tag, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: _tagFontSize, + ), + ), + ), + ], + ); + } + + List _tagValues() { + final result = []; + for (final value in [widget.drama.genre, widget.drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + } + } + return result; + } + + Widget _fullSeriesBar() { + return GestureDetector( + key: const Key('home-feed-full-series'), + onTap: widget.onEpisodes, + child: Container( + height: _fullBarHeight, + color: _fullBarColor, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.l10n.watchFullSeries( + '${widget.drama.episodeCount}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _FeedProgressTrack( + controller: widget.controller, + height: _progressHeight, + ), + ), + ], ), ), ); } + void _showComingSoon() { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); + } + Future _openMore(BuildContext context) async { await showModalBottomSheet( context: context, backgroundColor: AppColors.card, showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('home-feed-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - onDownload(); - }, - ), - if (onPip != null) - ListTile( - key: const Key('home-feed-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.pictureInPicture), - onTap: () { - Navigator.pop(sheetContext); - onPip!(); - }, - ), - ], - ), - ), - ); - } -} - -class _RoundIcon extends StatelessWidget { - const _RoundIcon({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: AppColors.card.withValues(alpha: 0.94), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _RailItem extends StatelessWidget { - const _RailItem({ - super.key, - required this.icon, - required this.label, - required this.onTap, - this.selected = false, - this.countKey, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - final bool selected; - final Key? countKey; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Semantics( - button: true, - label: label, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(22), - child: SizedBox( - width: 44, - child: Column( - children: [ - Icon( - icon, - size: 22, - color: selected ? AppColors.blue : AppColors.onSurface, - ), - const SizedBox(height: 2), - Text( - label, - key: countKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 9, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ), - ), - ), + builder: (sheetContext) => _FeedMoreSheet( + l10n: widget.l10n, + onDownload: widget.onDownload, + onPip: widget.onPip, + onClear: widget.onClear, ), ); } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart new file mode 100644 index 0000000..629c157 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -0,0 +1,393 @@ +// Author: fruit.com.cn + +part of 'home_feed_overlay.dart'; + +/// 顶部频道 tab:关注/漫剧点击提示即将上线。 +enum _FeedTab { + following, + anime, + real, + recommend; + + bool get comingSoon => this == _FeedTab.following || this == _FeedTab.anime; + + String get keyName => switch (this) { + _FeedTab.following => 'follow', + _FeedTab.anime => 'anime', + _FeedTab.real => 'real', + _FeedTab.recommend => 'recommend', + }; + + String label(AppLocalizations l10n) => switch (this) { + _FeedTab.following => l10n.feedTabFollowing, + _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)], + ), + ), + ); + } +} + +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), + ), + ), + ], + ), + ); + } +} + +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({ + required this.height, + required this.background, + required this.child, + }); + + 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; + + @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()); + }, + ); + } + + 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, + ), + ], + ), + ), + ); + } +} + +class _FeedMoreSheet extends StatelessWidget { + const _FeedMoreSheet({ + required this.l10n, + required this.onDownload, + required this.onClear, + this.onPip, + }); + + final AppLocalizations l10n; + final VoidCallback onDownload; + final VoidCallback onClear; + final VoidCallback? onPip; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('home-feed-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(context); + onDownload(); + }, + ), + if (onPip != null) + ListTile( + key: const Key('home-feed-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(context); + onPip!(); + }, + ), + ListTile( + key: const Key('home-feed-clear'), + leading: const Icon( + Icons.fullscreen_rounded, + color: AppColors.secondary, + ), + title: Text(l10n.clearScreen), + onTap: () { + Navigator.pop(context); + onClear(); + }, + ), + ], + ), + ); + } +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 37e111b..8a5aacc 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -60,75 +60,29 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsNothing); expect(tester.takeException(), isNull); }); - testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( + testWidgets('should_render_new_feed_structure_with_formatted_counts', ( tester, ) async { - Future pumpEpisode(Episode episode) async { - 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: _drama, - episode: episode, - liked: false, - saved: false, - onLike: () {}, - onSave: () {}, - onComments: () {}, - onShare: () {}, - onEpisodes: () {}, - onDownload: () {}, - onClear: () {}, - onSearch: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - } - - await pumpEpisode( - const Episode( - id: 'featured', - number: 2, - title: 'Midnight Contract', - isFree: true, - ), + const drama = Drama( + id: 'feed-drama', + title: 'Feed Drama', + synopsis: 'synopsis', + posterUrl: '', + genre: 'Romance', + category: 'Urban', + totalEpisodes: 8, + freeEpisodes: 8, + viewCount: 419000, + likeCount: 1006, + favoriteCount: 32000, + commentCount: 12, ); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - 'Midnight Contract', - ); - - await pumpEpisode( - const Episode(id: 'fallback', number: 2, title: '', isFree: true), - ); - expect( - tester - .widget(find.byKey(const Key('home-feed-episode-title'))) - .data, - 'Fallback Drama', - ); - }); - - testWidgets('should_show_server_like_count_when_count_is_available', ( - tester, - ) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -139,21 +93,26 @@ void main() { builder: (context) => Scaffold( body: HomeFeedOverlay( l10n: AppLocalizations.of(context)!, - drama: _drama, + drama: drama, episode: const Episode( - id: 'count-episode', - number: 1, - title: 'Count Episode', + id: 'feed-episode', + number: 2, + title: 'Ignored Episode Title', isFree: true, ), + controller: null, liked: true, - likeCount: 128, saved: false, + likeCount: 1006, + favoriteCount: 32000, + commentCount: 12, onLike: () {}, onSave: () {}, onComments: () {}, onShare: () {}, onEpisodes: () {}, + onDetail: () {}, + onFullscreen: () {}, onDownload: () {}, onClear: () {}, onSearch: () {}, @@ -164,15 +123,26 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('rail-like-count')), findsOneWidget); - expect(find.text('128'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('home-feed-title'))).data, + 'Feed Drama', + ); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); + expect(find.text('Episode 2'), findsOneWidget); + expect(find.textContaining('41.9万'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('rail-like-count'))).data, + '1006', + ); + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '3.2万', + ); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '12', + ); }); } - -const _drama = Drama( - id: 'featured-drama', - title: 'Fallback Drama', - synopsis: 'synopsis', - posterUrl: '', - totalEpisodes: 8, -); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 6e0a8c4..79bc0c4 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -50,12 +50,47 @@ void main() { await _pumpApp(tester); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('home-browse-page')), findsNothing); + expect( + tester.widget(find.byKey(const Key('rail-like-count'))).data, + '41.9万', + ); + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '1006', + ); + expect( + tester.widget(find.byKey(const Key('rail-comments-count'))).data, + '3.2万', + ); expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); + testWidgets('should_open_episode_sheet_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); + }); + + testWidgets('should_show_coming_soon_for_danmaku_and_following_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-follow'))); + await tester.pump(); + expect(find.text('Coming soon'), findsOneWidget); + }); + testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { await _pumpApp(tester); final before = tester diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 799ac78..8b5598c 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -44,7 +44,16 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository { } final List demoDramas = [ - _drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1), + _drama( + 'the-signal-demo', + 'The Signal', + 'Suspense · Mystery', + format: 1, + viewCount: 419000, + likeCount: 419000, + favoriteCount: 1006, + commentCount: 32000, + ), _drama( 'city-lights-demo', 'City Lights & Velvet Nights', @@ -65,6 +74,7 @@ Drama _drama( int? likeCount, int? favoriteCount, int? commentCount, + int viewCount = 0, }) => Drama( id: id, title: title, @@ -73,6 +83,7 @@ Drama _drama( genre: genre, language: 'id', detailLoaded: true, + viewCount: viewCount, likeCount: likeCount, favoriteCount: favoriteCount, commentCount: commentCount,