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] =?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); }, ); }