From ec163f905dd9361f9f83bb4a8c4609ab18646421 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:16:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(detail):=20=E9=87=8D=E5=81=9A=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E8=A7=86=E8=A7=89=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 7 + lib/l10n/app_id.arb | 7 + lib/l10n/app_localizations.dart | 42 + lib/l10n/app_localizations_en.dart | 21 + lib/l10n/app_localizations_id.dart | 21 + lib/l10n/app_localizations_zh.dart | 21 + lib/l10n/app_zh.arb | 7 + lib/pages/drama_detail/drama_detail_view.dart | 1115 +++++++++++++---- test/drama_detail/drama_detail_view_test.dart | 151 +++ 9 files changed, 1153 insertions(+), 239 deletions(-) create mode 100644 test/drama_detail/drama_detail_view_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 40fa98a..b6db44d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "VERTICAL MINI-SERIES", "followSeries": "Follow Series", "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", + "seasonOneOriginalRelease": "Season 1 • Original Release", + "startWatching": "Start Watching", + "autoNextOn": "Auto-Next On", + "viewAll": "View all", + "showLess": "Less", + "views": "views", + "enterFullscreen": "Fullscreen", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 459faeb..e839cd1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "SERIAL MINI VERTIKAL", "followSeries": "Ikuti Series", "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", + "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", + "startWatching": "Mulai Menonton", + "autoNextOn": "Lanjut Otomatis", + "viewAll": "Lihat semua", + "showLess": "Lebih sedikit", + "views": "tayangan", + "enterFullscreen": "Layar penuh", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3dee2a6..5ae6fac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -262,6 +262,48 @@ abstract class AppLocalizations { /// **'Season 1 • Vertical Cinema'** String get seasonOneVerticalCinema; + /// No description provided for @seasonOneOriginalRelease. + /// + /// In en, this message translates to: + /// **'Season 1 • Original Release'** + String get seasonOneOriginalRelease; + + /// No description provided for @startWatching. + /// + /// In en, this message translates to: + /// **'Start Watching'** + String get startWatching; + + /// No description provided for @autoNextOn. + /// + /// In en, this message translates to: + /// **'Auto-Next On'** + String get autoNextOn; + + /// No description provided for @viewAll. + /// + /// In en, this message translates to: + /// **'View all'** + String get viewAll; + + /// No description provided for @showLess. + /// + /// In en, this message translates to: + /// **'Less'** + String get showLess; + + /// No description provided for @views. + /// + /// In en, this message translates to: + /// **'views'** + String get views; + + /// No description provided for @enterFullscreen. + /// + /// In en, this message translates to: + /// **'Fullscreen'** + String get enterFullscreen; + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e40f0bd..f93e1e7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -90,6 +90,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + @override + String get seasonOneOriginalRelease => 'Season 1 • Original Release'; + + @override + String get startWatching => 'Start Watching'; + + @override + String get autoNextOn => 'Auto-Next On'; + + @override + String get viewAll => 'View all'; + + @override + String get showLess => 'Less'; + + @override + String get views => 'views'; + + @override + String get enterFullscreen => 'Fullscreen'; + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 66e2973..8cfe7fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -90,6 +90,27 @@ class AppLocalizationsId extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + @override + String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal'; + + @override + String get startWatching => 'Mulai Menonton'; + + @override + String get autoNextOn => 'Lanjut Otomatis'; + + @override + String get viewAll => 'Lihat semua'; + + @override + String get showLess => 'Lebih sedikit'; + + @override + String get views => 'tayangan'; + + @override + String get enterFullscreen => 'Layar penuh'; + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e3cac4a..ef96d79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -89,6 +89,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + @override + String get seasonOneOriginalRelease => '第 1 季 · 原创上线'; + + @override + String get startWatching => '开始观看'; + + @override + String get autoNextOn => '自动连播'; + + @override + String get viewAll => '查看全部'; + + @override + String get showLess => '收起'; + + @override + String get views => '次播放'; + + @override + String get enterFullscreen => '全屏'; + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ae47bc8..5967360 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "竖屏短剧", "followSeries": "追剧", "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", + "seasonOneOriginalRelease": "第 1 季 · 原创上线", + "startWatching": "开始观看", + "autoNextOn": "自动连播", + "viewAll": "查看全部", + "showLess": "收起", + "views": "次播放", + "enterFullscreen": "全屏", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index e3b25d2..f358240 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -6,30 +6,33 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/remote_image.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/glass_panel.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/repositories/playback_repository.dart'; const String _reportMenuValue = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); + final Drama drama; @override @@ -38,6 +41,7 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; + bool _synopsisExpanded = false; @override void initState() { @@ -78,211 +82,261 @@ class _DramaDetailPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (logic.loading) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), - ); - } - if (logic.failed) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - errorText(l10n, logic.failureMessage, l10n.detailError), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - FilledButton(onPressed: _load, child: Text(l10n.retry)), - ], - ), - ), - ), - ); - } + if (logic.loading) return _loading(l10n); + if (logic.failed) return _failed(l10n); final drama = logic.drama; - if (drama.episodes.isEmpty) { - // 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏 - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( - key: const Key('drama-detail-empty'), - icon: Icons.video_library_outlined, - title: l10n.noEpisodesYet, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: _load, - ), - ); - } + if (drama.episodes.isEmpty) return _empty(l10n, drama); + return Scaffold( backgroundColor: AppColors.ink, body: CustomScrollView( + key: const Key('detail-scroll'), slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - actions: [ - _reportAction(context, l10n), - _downloadAction(context, l10n), - _favoriteAction(context, l10n), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - RemoteImage(drama.posterUrl), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.center, - colors: [Color(0x99000000), Color(0x00000000)], - ), - ), + SliverToBoxAdapter(child: _hero(context, l10n, drama)), + SliverToBoxAdapter(child: _metadata(l10n, drama)), + SliverToBoxAdapter(child: _synopsis(l10n, drama)), + SliverToBoxAdapter(child: _primaryActions(context, l10n)), + SliverToBoxAdapter(child: _episodeHeader(l10n, drama)), + SliverToBoxAdapter(child: _episodeChips(drama)), + _episodeGrid(context, l10n, drama), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _loading(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: const Center(child: CircularProgressIndicator()), + ); + + Widget _failed(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, logic.failureMessage, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + + Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: EmptyState( + key: const Key('drama-detail-empty'), + icon: Icons.video_library_outlined, + title: l10n.noEpisodesYet, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: _load, + ), + ); + + Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { + return Stack( + children: [ + AspectRatio( + key: const Key('detail-hero'), + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x260B0C10), + Color(0x000B0C10), + AppColors.ink, + ], + stops: [0, 0.5, 1], ), - ], + ), + ), + ], + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + left: 16, + right: 16, + child: Row( + children: [ + _GlassCircleButton( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), + ), + const Spacer(), + _GlassCircleButton( + key: const Key('detail-share'), + tooltip: l10n.share, + icon: Icons.share_outlined, + onPressed: () => unawaited(_shareDrama(l10n, drama)), + ), + const SizedBox(width: 8), + _favoriteButton( + context, + l10n, + key: const Key('detail-favorite-top'), + ), + const SizedBox(width: 8), + _reportAction(context, l10n), + ], + ), + ), + ], + ); + } + + Widget _metadata(AppLocalizations l10n, Drama drama) { + final genres = [ + if (drama.genre.isNotEmpty) drama.genre, + if (drama.category.isNotEmpty) drama.category, + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: const Key('detail-episode-count'), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.3), ), ), - ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - l10n.technicalDemoCount(drama.episodes.length), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - letterSpacing: 1.4, - color: AppColors.blueSoft, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - fontSize: AppDimens.detailHeadline, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppColors.muted), - ), - const SizedBox(height: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 6, + height: 6, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.secondary, + shape: BoxShape.circle, ), - if (_continueFraction != null) ...[ - const SizedBox(height: 8), - _ResumeBar( - fraction: _continueFraction!, - label: l10n.resumeProgress( - _clock(logic.progress!.position), - _clock(logic.progress!.duration), - ), - ), - ], - if (logic.progressError != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - errorText( - l10n, - logic.progressError, - l10n.progressError, - ), - style: const TextStyle( - color: Colors.white60, - fontSize: 12, - ), - ), - ), - TextButton( - onPressed: () => unawaited(logic.loadContinue()), - child: Text(l10n.retry), - ), - ], - ), - ], - ], - ), - const SizedBox(height: 30), - Text( - l10n.episodes, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, ), ), - ]), + const SizedBox(width: 5), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.secondary, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), - sliver: SliverList.builder( - itemCount: drama.episodes.length, - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final fraction = logic.progressFractionFor(episode.id); - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - minTileHeight: 76, - leading: CircleAvatar( - backgroundColor: AppColors.blue.withValues(alpha: 0.18), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: fraction == null - ? null - : Padding( - padding: const EdgeInsets.only(top: 6), - child: _ResumeBar(fraction: fraction), - ), - trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => logic.openPlayer(context, index), - ), - ); - }, + const SizedBox(height: 10), + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + letterSpacing: 0, + ), + ), + const SizedBox(height: 5), + Text( + l10n.seasonOneOriginalRelease, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (drama.viewCount > 0) + _metaPill( + icon: Icons.local_fire_department_rounded, + label: '${_compactCount(drama.viewCount)} ${l10n.views}', + color: AppColors.blue, + ), + for (final genre in genres) + _metaPill(label: genre, color: AppColors.cream), + ], + ), + ], + ), + ); + } + + Widget _metaPill({ + IconData? icon, + required String label, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w500, ), ), ], @@ -290,42 +344,582 @@ class _DramaDetailPageState extends State { ); } - double? get _continueFraction { - final record = logic.progress; - if (record == null || !record.hasPosition) return null; - if (logic.continueIndex == null) return null; - return record.fraction; - } - - /// 下载当前续看集或第一集;业务判定完全交给服务端。 - Widget _downloadAction(BuildContext context, AppLocalizations l10n) { - final episodes = logic.drama.episodes; - final index = logic.continueIndex ?? 0; - final episode = episodes.isEmpty - ? null - : episodes[index.clamp(0, episodes.length - 1)]; - return IconButton( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: const Icon(Icons.download_outlined), - onPressed: episode == null - ? null - : () => requestEpisodeDownload(context, episode.id), + Widget _synopsis(AppLocalizations l10n, Drama drama) { + final text = localizedDramaSynopsis(l10n, drama); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: GlassPanel( + key: const Key('detail-synopsis-card'), + radius: AppDimens.glassSmallRadius, + blur: 12, + padding: const EdgeInsets.fromLTRB(13, 11, 13, 9), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x1A12141A), Color(0x3312141A)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + key: const Key('detail-synopsis'), + maxLines: _synopsisExpanded ? 100 : 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.45, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const Key('detail-synopsis-toggle'), + onPressed: () => + setState(() => _synopsisExpanded = !_synopsisExpanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + minimumSize: const Size(44, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_synopsisExpanded ? l10n.showLess : l10n.more), + Icon( + _synopsisExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 16, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _primaryActions(BuildContext context, AppLocalizations l10n) { + final continueIndex = logic.continueIndex; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: SizedBox( + height: 52, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => + logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-favorite'), + tooltip: l10n.favorite, + icon: + context.watch().isSaved(logic.drama.id) + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + selected: context.watch().isSaved( + logic.drama.id, + ), + onPressed: () => _toggleFavorite(l10n), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-download'), + tooltip: l10n.downloads, + icon: Icons.download_outlined, + onPressed: _downloadCurrent, + ), + ], + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 9), + _ResumeBar( + fraction: _continueFraction!, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), + ), + ], + if (logic.progressError != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, logic.progressError, l10n.progressError), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _episodeHeader(AppLocalizations l10n, Drama drama) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), + child: Row( + children: [ + Text( + l10n.episodes, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 5), + Text( + '(${drama.episodeCount})', + style: const TextStyle( + color: AppColors.subtle, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Container( + key: const Key('detail-episodes-range'), + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.all, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 15, + color: AppColors.muted, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _episodeChips(Drama drama) { + final selected = logic.continueIndex ?? 0; + return SizedBox( + key: const Key('detail-episode-chips'), + height: 44, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + itemCount: drama.episodes.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final episode = drama.episodes[index]; + final active = index == selected; + final locked = episode.locked || (!episode.isFree && index > 0); + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 46, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.35) + : AppColors.border, + ), + ), + child: Text( + '${episode.number}', + style: TextStyle( + color: active + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _episodeGrid( + BuildContext context, + AppLocalizations l10n, + Drama drama, + ) { + return SliverPadding( + key: const Key('detail-episode-grid'), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 108, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => + _episodeCard(context, l10n, drama, drama.episodes[index], index), + childCount: drama.episodes.length, + ), + ), + ); + } + + Widget _episodeCard( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + int index, + ) { + final locked = episode.locked || (!episode.isFree && index > 0); + return Material( + key: Key('detail-episode-card-${episode.number}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => logic.openPlayer(context, index), + child: LayoutBuilder( + builder: (context, constraints) { + final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0); + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + SizedBox( + key: Key('detail-episode-thumb-${episode.number}'), + width: thumbWidth, + height: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration(color: Color(0x33000000)), + ), + Center( + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: locked + ? Colors.black54 + : AppColors.blue.withValues(alpha: 0.9), + shape: BoxShape.circle, + ), + child: Icon( + locked + ? Icons.lock_rounded + : Icons.play_arrow_rounded, + size: 14, + color: AppColors.onPrimary, + ), + ), + ), + if (episode.durationSec > 0) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _clock( + Duration(seconds: episode.durationSec), + ), + style: const TextStyle( + color: AppColors.cream, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Wrap( + spacing: 4, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _episodeBadge( + episode.isFree && !locked + ? l10n.free + : l10n.coins, + locked ? AppColors.secondary : AppColors.success, + ), + Text( + l10n.episodeLabel(episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9.5, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + localizedEpisodeTitle(l10n, episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: locked + ? AppColors.muted + : AppColors.onSurface, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + GestureDetector( + onTap: () => + requestEpisodeDownload(context, episode.id), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_outlined, + size: 12, + color: AppColors.subtle, + ), + const SizedBox(width: 3), + Flexible( + child: Text( + l10n.downloads, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _episodeBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 8.5, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _squareAction({ + required Key key, + required String tooltip, + required IconData icon, + required VoidCallback onPressed, + bool selected = false, + }) { + return SizedBox( + key: key, + width: 48, + height: 48, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + style: IconButton.styleFrom( + backgroundColor: selected ? AppColors.raised : AppColors.card, + foregroundColor: selected ? AppColors.blue : AppColors.cream, + side: BorderSide( + color: selected ? AppColors.blue : AppColors.outlineDark, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + icon: Icon(icon, size: 21), + ), + ); + } + + Widget _favoriteButton( + BuildContext context, + AppLocalizations l10n, { + required Key key, + }) { + final saved = context.watch().isSaved(logic.drama.id); + return _GlassCircleButton( + key: key, + tooltip: l10n.favorite, + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + selected: saved, + onPressed: () => _toggleFavorite(l10n), ); } - /// 详情页更多菜单:本批只提供举报短剧。 Widget _reportAction(BuildContext context, AppLocalizations l10n) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, + color: AppColors.card, onSelected: (_) => unawaited(_reportDrama(l10n)), itemBuilder: (_) => [ PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), ], + child: const _GlassCircle( + child: Icon( + Icons.more_horiz_rounded, + color: AppColors.onSurface, + size: 20, + ), + ), ); } + Future _shareDrama(AppLocalizations l10n, Drama drama) async { + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.shareFailed))); + } + } + + Future _toggleFavorite(AppLocalizations l10n) async { + if (!await ensureSignedIn(context)) return; + if (!mounted) return; + unawaited(context.read().lightImpact()); + final ok = await logic.toggleFavorite(); + if (ok || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + void _downloadCurrent() { + final episodes = logic.drama.episodes; + if (episodes.isEmpty) return; + final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1); + requestEpisodeDownload(context, episodes[index].id); + } + Future _reportDrama(AppLocalizations l10n) async { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); @@ -348,25 +942,11 @@ class _DramaDetailPageState extends State { ); } - /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 - Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { - final favorites = context.watch(); - final saved = favorites.isSaved(logic.drama.id); - return IconButton( - tooltip: l10n.favorite, - icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), - onPressed: () async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await logic.toggleFavorite(); - if (ok || !context.mounted) return; - // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); - }, - ); + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; } String _clock(Duration duration) { @@ -378,7 +958,52 @@ class _DramaDetailPageState extends State { } } -/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。 +class _GlassCircleButton extends StatelessWidget { + const _GlassCircleButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool selected; + + @override + Widget build(BuildContext context) { + return _GlassCircle( + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: 20, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + ), + ); + } +} + +class _GlassCircle extends StatelessWidget { + const _GlassCircle({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: GlassPanel(radius: 999, blur: 14, child: child), + ); + } +} + class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -394,9 +1019,9 @@ class _ResumeBar extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressHeight, + minHeight: AppDimens.progressLineHeight, backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), if (label != null) ...[ @@ -413,3 +1038,15 @@ class _ResumeBar extends StatelessWidget { ); } } + +String _compactCount(int value) { + if (value >= 1000000) { + final scaled = value / 1000000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; + } + if (value >= 1000) { + final scaled = value / 1000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; + } + return '$value'; +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart new file mode 100644 index 0000000..3bd89f3 --- /dev/null +++ b/test/drama_detail/drama_detail_view_test.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +void main() { + testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + + final hero = tester.widget( + find.byKey(const Key('detail-hero')), + ); + expect(hero.aspectRatio, closeTo(16 / 9, 0.001)); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-chip-1'))), + const Size(46, 44), + ); + + final synopsis = tester.widget( + find.byKey(const Key('detail-synopsis')), + ); + expect(synopsis.maxLines, 2); + await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('detail-synopsis'))).maxLines, + 100, + ); + + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('detail-episode-grid')), + matching: find.byType(SliverGrid), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 2); + expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))), + const Size(96, 64), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -520), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Widget _host(Drama drama) { + final catalog = FakeCatalogRepository(dramas: [drama]); + return MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider( + create: (_) => FavoriteController(InMemoryFavoriteRepository()), + ), + ChangeNotifierProvider( + create: (_) => InteractionController(FakeInteractionRepository()), + ), + ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), + ], + child: MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: drama), + ), + ); +} + +Drama _detailDrama() { + return Drama( + id: 'detail-k25', + title: 'Detail K25', + synopsis: + 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls.', + posterUrl: '/files/detail-k25.png', + genre: 'Urban Romance', + category: 'Romance', + totalEpisodes: 8, + viewCount: 4800000, + detailLoaded: true, + episodes: [ + for (var number = 1; number <= 8; number++) + Episode( + id: 'detail-k25-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + durationSec: 135, + locked: number > 1, + videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + ), + ], + ); +} From 689063a6945cf7a6c306a0bb22d3837d753d5cff Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:16:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(player):=20=E6=96=B0=E5=A2=9E=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=BC=8F=E6=92=AD=E6=94=BE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_logic.dart | 12 + lib/pages/player/player_view.dart | 620 ++++++++++++++++++ lib/pages/player/widgets/card_seek_bar.dart | 257 ++++++++ .../widgets/player_featured_comment.dart | 164 +++++ test/player/player_card_view_test.dart | 169 +++++ test/widget_test.dart | 11 +- 6 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 lib/pages/player/widgets/card_seek_bar.dart create mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 test/player/player_card_view_test.dart diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..64c459e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a195c69..0140278 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -21,10 +21,14 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; +import '../../core/widgets/glass_panel.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/card_seek_bar.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -114,6 +118,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); + if (!widget.homeMode && !logic.fullscreen) { + return _buildCardPlayer(context, interactions); + } final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( @@ -143,6 +150,533 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } + Widget _buildCardPlayer( + BuildContext context, + InteractionController interactions, + ) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: AppTheme.dark(), + child: Scaffold( + backgroundColor: AppColors.ink, + body: SafeArea( + child: AnimatedBuilder( + animation: logic.session, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + final index = logic.session.currentIndex; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = logic.displayEpisode(drama, entry.episode); + final port = + (index == logic.session.currentIndex + ? logic.session.active + : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final ready = + videoPort?.controller.value.isInitialized ?? false; + return Column( + children: [ + _cardTopBar(context, l10n, drama, episode), + Expanded( + child: SingleChildScrollView( + key: const Key('card-player-scroll'), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 20), + child: Column( + children: [ + AspectRatio( + key: const Key('card-player-video-frame'), + aspectRatio: 9 / 10, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AnimatedBuilder( + animation: logic.session, + builder: (context, _) => PageView.builder( + controller: logic.pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: logic.visibleIndices.length, + onPageChanged: (position) { + final next = + logic.visibleIndices[position]; + widget.onDramaChanged?.call( + logic.queue.entryAt(next).drama, + ); + unawaited(logic.selectEpisode(next)); + }, + itemBuilder: (context, position) => + _buildCardVideo( + context, + logic.visibleIndices[position], + interactions, + ), + ), + ), + ), + ), + const SizedBox(height: 6), + CardSeekBar( + controller: ready ? videoPort!.controller : null, + semanticsLabel: l10n.seekVideo, + episodeLabel: l10n.episodeLabel(episode.number), + totalEpisodes: drama.episodeCount, + onScrubStart: ready + ? () => logic.beginScrub(videoPort!) + : null, + onSeek: ready + ? (position) => logic.seekTo( + context, + videoPort!, + position, + ) + : null, + ), + const SizedBox(height: 10), + _cardActions(context, l10n, drama, episode), + const SizedBox(height: 12), + PlayerFeaturedComment( + drama: drama, + onViewAll: () => + logic.showComments(context, drama), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + Widget _cardTopBar( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 6), + child: Row( + children: [ + IconButton( + key: const Key('player-back'), + tooltip: l10n.backToSeries, + onPressed: () => unawaited(logic.leave(context)), + style: IconButton.styleFrom( + backgroundColor: AppColors.raised, + foregroundColor: AppColors.onSurface, + ), + icon: const Icon(Icons.chevron_left_rounded), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${localizedDramaTitle(l10n, drama)} · ' + '${l10n.episodeLabel(episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + l10n.seasonOneOriginalRelease, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + key: const Key('player-more'), + tooltip: l10n.more, + onPressed: () => unawaited(_showCardMore(context, l10n, episode)), + style: IconButton.styleFrom( + backgroundColor: AppColors.raised, + foregroundColor: AppColors.onSurface, + ), + icon: const Icon(Icons.more_horiz_rounded), + ), + ], + ), + ); + } + + Widget _buildCardVideo( + BuildContext context, + int index, + InteractionController interactions, + ) { + final l10n = AppLocalizations.of(context)!; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = logic.displayEpisode(drama, entry.episode); + final selected = index == logic.session.currentIndex; + final port = + (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final ready = videoPort?.controller.value.isInitialized ?? false; + + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.ink), + if (ready) + GestureDetector( + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + child: FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: videoPort!.controller.value.size.width, + height: videoPort.controller.value.size.height, + child: VideoPlayer( + videoPort.controller, + key: ValueKey(videoPort.controller), + ), + ), + ), + ) + else + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x66000000), + Color(0x11000000), + Color(0x99000000), + ], + stops: [0, 0.46, 1], + ), + ), + ), + ), + Positioned( + top: 10, + left: 10, + right: 10, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GlassPanel( + radius: 999, + blur: 12, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.skip_next_rounded, + size: 12, + color: AppColors.secondary, + ), + const SizedBox(width: 3), + Text( + l10n.autoNextOn, + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + ), + child: const Text( + '1.0x', + style: TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + if (selected && logic.ticketLoading) + const Center(child: CircularProgressIndicator(color: AppColors.blue)), + if (selected && !logic.ticketLoading && logic.ticketError != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.wifi_off_rounded, + color: AppColors.muted, + size: 30, + ), + const SizedBox(height: 8), + Text( + errorText(l10n, logic.ticketError, l10n.playbackError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.cream), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () => unawaited(logic.retryTicket()), + child: Text(l10n.retry), + ), + ], + ), + ), + ), + if (selected && logic.session.loading) + const Center(child: CircularProgressIndicator(color: AppColors.blue)), + if (selected && !ready && logic.session.error != null) + Center( + child: Text( + l10n.playbackError, + style: const TextStyle(color: AppColors.cream), + ), + ), + if (selected && !ready && logic.blockedEpisodeId == episode.id) + _BlockedNotice( + l10n: l10n, + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + ), + if (selected && + !ready && + logic.blockedEpisodeId != episode.id && + logic.session.error == null && + logic.session.isLocked) + Center( + child: Text( + episode.locked + ? l10n.lockedTitle(episode.number) + : l10n.noMediaNotice, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.cream), + ), + ), + if (selected && ready && !logic.session.userWantsPlay) + Center( + child: GestureDetector( + key: const Key('card-player-play'), + onTap: () => logic.session.setUserWantsPlay(true), + child: Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.92), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 2, + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + ), + Positioned( + right: 10, + bottom: 12, + child: Column( + children: [ + _CardIconButton( + key: const Key('card-player-like'), + tooltip: l10n.like, + icon: interactions.isLiked(drama.id) + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: interactions.isLiked(drama.id) + ? AppColors.blue + : AppColors.onSurface, + onPressed: () => + unawaited(_toggleLike(context, drama, interactions)), + ), + const SizedBox(height: 8), + _CardIconButton( + key: const Key('card-player-fullscreen'), + tooltip: l10n.enterFullscreen, + icon: Icons.fullscreen_rounded, + onPressed: () => unawaited(logic.setFullscreen(true)), + ), + ], + ), + ), + ], + ); + } + + Widget _cardActions( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + ) { + return Row( + children: [ + Expanded( + child: _CardActionButton( + key: const Key('card-player-episodes'), + icon: Icons.view_list_rounded, + label: l10n.episodes, + onPressed: () => unawaited(logic.showEpisodes(context)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _CardActionButton( + key: const Key('card-player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onPressed: () => unawaited(logic.showComments(context, drama)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 42, + child: FilledButton( + key: const Key('card-player-next'), + onPressed: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.skip_next_rounded, size: 15), + ], + ), + ), + ), + ), + ], + ); + } + + Future _showCardMore( + BuildContext context, + AppLocalizations l10n, + Episode episode, + ) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('player-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + requestEpisodeDownload(context, episode.id); + }, + ), + if (logic.pictureInPicture.isPlatformSupported) + ListTile( + key: const Key('player-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + unawaited(logic.enterPip(context)); + }, + ), + ], + ), + ), + ); + } + + Future _toggleLike( + BuildContext context, + Drama drama, + InteractionController interactions, + ) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + Widget _buildEpisode( BuildContext context, int index, @@ -561,6 +1095,92 @@ class _BlockedNotice extends StatelessWidget { } } +class _CardIconButton extends StatelessWidget { + const _CardIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.color = AppColors.onSurface, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.55), + foregroundColor: color, + side: BorderSide(color: Colors.white.withValues(alpha: 0.15)), + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _CardActionButton extends StatelessWidget { + const _CardActionButton({ + super.key, + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.raised, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 15, color: AppColors.secondary), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + /// Coin lock presented as a reward card: gold chip, chili only on the icon. class _UnlockCard extends StatelessWidget { const _UnlockCard({ diff --git a/lib/pages/player/widgets/card_seek_bar.dart b/lib/pages/player/widgets/card_seek_bar.dart new file mode 100644 index 0000000..efe2477 --- /dev/null +++ b/lib/pages/player/widgets/card_seek_bar.dart @@ -0,0 +1,257 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// 1.5px player scrubber with a buffered segment and white thumb. +class CardSeekBar extends StatefulWidget { + const CardSeekBar({ + super.key, + required this.controller, + required this.semanticsLabel, + required this.episodeLabel, + required this.totalEpisodes, + this.onScrubStart, + this.onSeek, + }); + + final VideoPlayerController? controller; + final String semanticsLabel; + final String episodeLabel; + final int totalEpisodes; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + + @override + State createState() => _CardSeekBarState(); +} + +class _CardSeekBarState extends State { + final ValueNotifier _preview = ValueNotifier(null); + + @override + void dispose() { + _preview.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + if (controller == null) { + return _track(context, const VideoPlayerValue.uninitialized()); + } + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + return ValueListenableBuilder( + valueListenable: _preview, + builder: (context, _, _) => _track(context, value), + ); + }, + ); + } + + Widget _track(BuildContext context, VideoPlayerValue video) { + final total = video.duration.inMilliseconds; + final enabled = video.isInitialized && total > 0 && widget.onSeek != null; + final played = total > 0 + ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) + : 0.0; + final fraction = _preview.value ?? played; + final buffered = _bufferedFraction(video); + final previewPosition = _preview.value == null + ? video.position + : Duration(milliseconds: (fraction * total).round()); + + return Column( + key: const Key('card-player-progress-bar'), + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 24, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 1.5, + activeTrackColor: AppColors.blue, + inactiveTrackColor: Colors.white.withValues(alpha: 0.15), + thumbColor: Colors.white, + overlayColor: AppColors.blue.withValues(alpha: 0.16), + trackShape: _BufferedTrackShape(buffered: buffered), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Semantics( + label: widget.semanticsLabel, + child: Slider( + key: const Key('card-player-progress'), + value: fraction, + onChangeStart: enabled + ? (value) { + _preview.value = value; + widget.onScrubStart?.call(); + } + : null, + onChanged: enabled ? (value) => _preview.value = value : null, + onChangeEnd: enabled + ? (value) => unawaited(_finish(value, video.duration)) + : null, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Text( + _format(previewPosition), + key: const Key('card-player-time-current'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + Expanded( + child: Center( + child: Text( + '${widget.episodeLabel} / ${widget.totalEpisodes}', + key: const Key('card-player-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + ), + ), + Text( + _format(video.duration), + key: const Key('card-player-time-total'), + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ), + ], + ); + } + + Future _finish(double fraction, Duration duration) async { + final source = widget.controller; + try { + final total = duration.inMilliseconds; + final target = total > 250 + ? (fraction * total).round().clamp(0, total - 250) + : 0; + await widget.onSeek?.call(Duration(milliseconds: target)); + } finally { + if (mounted && widget.controller == source) _preview.value = null; + } + } + + double _bufferedFraction(VideoPlayerValue video) { + final total = video.duration.inMilliseconds; + if (total <= 0 || video.buffered.isEmpty) return 0; + var end = Duration.zero; + for (final range in video.buffered) { + if (range.end > end) end = range.end; + } + return (end.inMilliseconds / total).clamp(0.0, 1.0); + } + + String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} + +class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape { + const _BufferedTrackShape({required this.buffered}); + + final double buffered; + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final thumbWidth = + sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ?? + 0; + final overlayWidth = + sliderTheme.overlayShape + ?.getPreferredSize(isEnabled, isDiscrete) + .width ?? + 0; + final trackHeight = sliderTheme.trackHeight ?? 1.5; + final trackLeft = offset.dx + overlayWidth / 2; + final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + final trackWidth = + parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isEnabled = false, + bool isDiscrete = false, + bool isSecondaryTrack = false, + }) { + final rect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final radius = Radius.circular(rect.height); + final canvas = context.canvas; + canvas.drawRRect( + RRect.fromRectAndRadius(rect, radius), + Paint()..color = Colors.white.withValues(alpha: 0.15), + ); + if (buffered > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + rect.left, + rect.top, + rect.width * buffered, + rect.height, + ), + radius, + ), + Paint()..color = Colors.white.withValues(alpha: 0.30), + ); + } + final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0); + if (played > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height), + radius, + ), + Paint()..color = AppColors.blue, + ); + } + } +} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..86dda19 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,164 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// First loaded top-level comment shown under the card player. +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('player-featured-comment'), + padding: const EdgeInsets.fromLTRB(13, 12, 13, 10), + decoration: BoxDecoration( + color: const Color(0xFF14161E), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.18), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.4), + ), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ), + if (comment.likeCount > 0) ...[ + const Icon( + Icons.favorite_rounded, + size: 13, + color: AppColors.blue, + ), + const SizedBox(width: 3), + Text( + '${comment.likeCount}', + style: const TextStyle(color: AppColors.muted, fontSize: 10), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11.5, + height: 1.4, + ), + ), + const SizedBox(height: 7), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const Key('player-comments-view-all'), + onPressed: widget.onViewAll, + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + minimumSize: const Size(44, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + child: Text(l10n.viewAll), + ), + ), + ], + ), + ); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/test/player/player_card_view_test.dart b/test/player/player_card_view_test.dart new file mode 100644 index 0000000..5ce75de --- /dev/null +++ b/test/player/player_card_view_test.dart @@ -0,0 +1,169 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_card_player_progress_and_three_action_buttons', ( + tester, + ) async { + await _pumpApp(tester); + await _openCityLights(tester); + + final frame = tester.widget( + find.byKey(const Key('card-player-video-frame')), + ); + expect(frame.aspectRatio, closeTo(9 / 10, 0.001)); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + expect(find.byKey(const Key('card-player-progress')), findsOneWidget); + expect(find.byKey(const Key('card-player-episodes')), findsOneWidget); + expect(find.byKey(const Key('card-player-comments')), findsOneWidget); + expect(find.byKey(const Key('card-player-next')), findsOneWidget); + + final theme = tester.widget( + find.ancestor( + of: find.byKey(const Key('card-player-progress')), + matching: find.byType(SliderTheme), + ), + ); + expect(theme.data.trackHeight, 1.5); + expect(theme.data.activeTrackColor, AppColors.blue); + expect(theme.data.thumbColor, Colors.white); + expect(theme.data.inactiveTrackColor, Colors.white.withValues(alpha: 0.15)); + + final next = tester.widget( + find.byKey(const Key('card-player-next')), + ); + expect(next.onPressed, isNotNull); + await tester.tap(find.byKey(const Key('card-player-next'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_disable_next_episode_on_the_last_episode', ( + tester, + ) async { + await _pumpApp(tester); + await _openCityLights(tester); + for (var index = 0; index < 6; index++) { + await tester.tap(find.byKey(const Key('card-player-next'))); + await tester.pumpAndSettle(); + } + + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 7'), + ); + final next = tester.widget( + find.byKey(const Key('card-player-next')), + ); + expect(next.onPressed, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_featured_comment_when_comments_exist', ( + tester, + ) async { + await _pumpApp( + tester, + comments: FakeCommentRepository( + pages: const [ + CommentPage( + comments: [ + DramaComment( + id: 'c1', + dramaId: 'city-lights-demo', + episodeId: '0', + parentId: '0', + rootId: '0', + userId: '9', + uid: 'U9', + nickname: 'Lina', + avatarUrl: '', + content: 'The banquet scene gave me chills.', + status: 'approved', + auditReason: '', + likeCount: 428, + replyCount: 0, + reportCount: 0, + createdAt: null, + mine: false, + liked: false, + ), + ], + ), + ], + ), + ); + await _openCityLights(tester); + + expect(find.byKey(const Key('player-featured-comment')), findsOneWidget); + expect(find.text('Lina'), findsOneWidget); + expect(find.text('428'), findsOneWidget); + expect(find.byKey(const Key('player-comments-view-all')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-comments-view-all'))); + await tester.pumpAndSettle(); + expect(find.text('The banquet scene gave me chills.'), findsWidgets); + }); +} + +Future _openCityLights(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.pumpAndSettle(); +} + +Future _pumpApp( + WidgetTester tester, { + CommentRepository? comments, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: comments ?? FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index f5648ed..7a4c685 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -184,7 +184,7 @@ void main() { await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect( @@ -445,8 +445,11 @@ void main() { expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -654,7 +657,7 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('The Signal')); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); }, ); }