diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b6db44d..ccf4bcf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,11 +29,8 @@ "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 e839cd1..22a79f2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,11 +29,8 @@ "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 5ae6fac..ee0214e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,18 +274,6 @@ abstract class AppLocalizations { /// **'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: @@ -298,12 +286,6 @@ abstract class AppLocalizations { /// **'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 f93e1e7..d39d132 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -96,21 +96,12 @@ class AppLocalizationsEn extends AppLocalizations { @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 8cfe7fa..06dd59f 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -96,21 +96,12 @@ class AppLocalizationsId extends AppLocalizations { @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 ef96d79..8453aca 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,21 +95,12 @@ class AppLocalizationsZh extends AppLocalizations { @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 5967360..374f17a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,11 +29,8 @@ "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", "seasonOneOriginalRelease": "第 1 季 · 原创上线", "startWatching": "开始观看", - "autoNextOn": "自动连播", - "viewAll": "查看全部", "showLess": "收起", "views": "次播放", - "enterFullscreen": "全屏", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 64c459e..aa4e50b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,18 +531,6 @@ 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 0140278..a195c69 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -21,14 +21,10 @@ 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'; @@ -118,9 +114,6 @@ 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( @@ -150,533 +143,6 @@ 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, @@ -1095,92 +561,6 @@ 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 deleted file mode 100644 index efe2477..0000000 --- a/lib/pages/player/widgets/card_seek_bar.dart +++ /dev/null @@ -1,257 +0,0 @@ -// 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 deleted file mode 100644 index 86dda19..0000000 --- a/lib/pages/player/widgets/player_featured_comment.dart +++ /dev/null @@ -1,164 +0,0 @@ -// 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 deleted file mode 100644 index 5ce75de..0000000 --- a/test/player/player_card_view_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -// 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/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart new file mode 100644 index 0000000..1b4468a --- /dev/null +++ b/test/player/player_fullscreen_test.dart @@ -0,0 +1,101 @@ +// 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/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.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_open_fullscreen_overlay_from_discover_card', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.pumpAndSettle(); + + _expectFullscreenOverlay(); + expect(find.byType(PageView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(); + expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +void _expectFullscreenOverlay() { + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byTooltip('Picture in picture'), findsOneWidget); + expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + + expect(find.byKey(const Key('card-player-video-frame')), findsNothing); + expect(find.byKey(const Key('card-player-progress')), findsNothing); + expect(find.byKey(const Key('card-player-next')), findsNothing); + expect(find.byKey(const Key('player-title')), findsNothing); +} + +Future _pumpApp(WidgetTester tester) 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: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7a4c685..0d90f10 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -445,10 +445,7 @@ void main() { expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); + expect(find.text('Episode 2 · Playing now'), findsOneWidget); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, );