diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dfa073d..6913b35 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -578,5 +578,17 @@ "qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.", "searchFailed": "Search failed. Please try again.", "searchResultCount": "{count} results", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "{count} episodes", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "Comments ({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "Text size", + "textSizeSmall": "Small", + "textSizeStandard": "Standard", + "textSizeLarge": "Large", + "watchAdToUnlock": "Watch an ad to unlock", + "unlockWithVip": "Unlock with VIP", + "unlockMethodsVary": "Available unlock methods vary by title.", + "unlockEpisode": "Unlock this episode" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 26d598b..586de55 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -576,5 +576,17 @@ "qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.", "searchFailed": "Pencarian gagal. Coba lagi.", "searchResultCount": "{count} hasil", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "{count} episode", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "Komentar ({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "Ukuran teks", + "textSizeSmall": "Kecil", + "textSizeStandard": "Standar", + "textSizeLarge": "Besar", + "watchAdToUnlock": "Tonton iklan untuk membuka", + "unlockWithVip": "Buka dengan VIP", + "unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", + "unlockEpisode": "Buka episode ini" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2e4be26..b861cd5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2571,6 +2571,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{count} results'** String searchResultCount(int count); + + /// No description provided for @episodeCountLabel. + /// + /// In en, this message translates to: + /// **'{count} episodes'** + String episodeCountLabel(int count); + + /// No description provided for @commentsWithCount. + /// + /// In en, this message translates to: + /// **'Comments ({count})'** + String commentsWithCount(int count); + + /// No description provided for @textSize. + /// + /// In en, this message translates to: + /// **'Text size'** + String get textSize; + + /// No description provided for @textSizeSmall. + /// + /// In en, this message translates to: + /// **'Small'** + String get textSizeSmall; + + /// No description provided for @textSizeStandard. + /// + /// In en, this message translates to: + /// **'Standard'** + String get textSizeStandard; + + /// No description provided for @textSizeLarge. + /// + /// In en, this message translates to: + /// **'Large'** + String get textSizeLarge; + + /// No description provided for @watchAdToUnlock. + /// + /// In en, this message translates to: + /// **'Watch an ad to unlock'** + String get watchAdToUnlock; + + /// No description provided for @unlockWithVip. + /// + /// In en, this message translates to: + /// **'Unlock with VIP'** + String get unlockWithVip; + + /// No description provided for @unlockMethodsVary. + /// + /// In en, this message translates to: + /// **'Available unlock methods vary by title.'** + String get unlockMethodsVary; + + /// No description provided for @unlockEpisode. + /// + /// In en, this message translates to: + /// **'Unlock this episode'** + String get unlockEpisode; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5265724..a16aca9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1330,4 +1330,38 @@ class AppLocalizationsEn extends AppLocalizations { String searchResultCount(int count) { return '$count results'; } + + @override + String episodeCountLabel(int count) { + return '$count episodes'; + } + + @override + String commentsWithCount(int count) { + return 'Comments ($count)'; + } + + @override + String get textSize => 'Text size'; + + @override + String get textSizeSmall => 'Small'; + + @override + String get textSizeStandard => 'Standard'; + + @override + String get textSizeLarge => 'Large'; + + @override + String get watchAdToUnlock => 'Watch an ad to unlock'; + + @override + String get unlockWithVip => 'Unlock with VIP'; + + @override + String get unlockMethodsVary => 'Available unlock methods vary by title.'; + + @override + String get unlockEpisode => 'Unlock this episode'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e56a798..690217a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1329,4 +1329,39 @@ class AppLocalizationsId extends AppLocalizations { String searchResultCount(int count) { return '$count hasil'; } + + @override + String episodeCountLabel(int count) { + return '$count episode'; + } + + @override + String commentsWithCount(int count) { + return 'Komentar ($count)'; + } + + @override + String get textSize => 'Ukuran teks'; + + @override + String get textSizeSmall => 'Kecil'; + + @override + String get textSizeStandard => 'Standar'; + + @override + String get textSizeLarge => 'Besar'; + + @override + String get watchAdToUnlock => 'Tonton iklan untuk membuka'; + + @override + String get unlockWithVip => 'Buka dengan VIP'; + + @override + String get unlockMethodsVary => + 'Metode pembukaan tersedia dapat berbeda per judul.'; + + @override + String get unlockEpisode => 'Buka episode ini'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f7ee1e3..a487f6d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1306,4 +1306,38 @@ class AppLocalizationsZh extends AppLocalizations { String searchResultCount(int count) { return '$count 个结果'; } + + @override + String episodeCountLabel(int count) { + return '共 $count 集'; + } + + @override + String commentsWithCount(int count) { + return '评论($count)'; + } + + @override + String get textSize => '文字大小'; + + @override + String get textSizeSmall => '小'; + + @override + String get textSizeStandard => '标准'; + + @override + String get textSizeLarge => '大'; + + @override + String get watchAdToUnlock => '看广告解锁'; + + @override + String get unlockWithVip => '会员解锁'; + + @override + String get unlockMethodsVary => '具体解锁方式以作品而定。'; + + @override + String get unlockEpisode => '解锁本集'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 687245a..f8be167 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -538,5 +538,17 @@ "qualityAutoHint": "HLS 会根据当前网络自动切换码率。", "searchFailed": "搜索失败,请重试。", "searchResultCount": "{count} 个结果", - "@searchResultCount": { "placeholders": { "count": { "type": "int" } } } + "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, + "episodeCountLabel": "共 {count} 集", + "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, + "commentsWithCount": "评论({count})", + "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, + "textSize": "文字大小", + "textSizeSmall": "小", + "textSizeStandard": "标准", + "textSizeLarge": "大", + "watchAdToUnlock": "看广告解锁", + "unlockWithVip": "会员解锁", + "unlockMethodsVary": "具体解锁方式以作品而定。", + "unlockEpisode": "解锁本集" } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 14f3bc0..c9c94aa 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -21,7 +21,6 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; -import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; @@ -30,9 +29,7 @@ import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; -import 'widgets/episode_picker_cell.dart'; - -const double _episodeCellAspect = 1.05; +import 'widgets/episode_picker_sheet.dart'; class PlayerLogic extends ChangeNotifier { PlayerLogic({ @@ -628,12 +625,10 @@ class PlayerLogic extends ChangeNotifier { } Future showEpisodes(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; final entry = queue.entryAt(session.currentIndex); final drama = await _withFullEpisodes(context, entry.drama); if (!context.mounted) return; final homeFeed = feedDramas != null; - final episodes = drama.episodes; final currentNumber = entry.episode.number; session.setOverlayOpen(true); await session.playbackSettled; @@ -644,62 +639,13 @@ class PlayerLogic extends ChangeNotifier { try { await showModalBottomSheet( context: context, - backgroundColor: AppColors.ink, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: SizedBox( - height: MediaQuery.sizeOf(sheetContext).height * 0.46, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), - child: Text( - l10n.episodes, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Text( - localizedDramaTitle(l10n, drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: _episodeCellAspect, - ), - itemCount: episodes.length, - itemBuilder: (_, index) => _episodeCell( - context, - sheetContext, - drama, - episodes[index], - index, - homeFeed, - currentNumber, - ), - ), - ), - ], - ), - ), + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => EpisodePickerSheet( + drama: drama, + currentNumber: currentNumber, + onSelect: (index) => + _selectFromSheet(context, drama, index, homeFeed, currentNumber), ), ); } finally { @@ -724,37 +670,25 @@ class PlayerLogic extends ChangeNotifier { } } - Widget _episodeCell( + void _selectFromSheet( BuildContext pageContext, - BuildContext sheetContext, Drama drama, - Episode episode, int index, bool homeFeed, int currentNumber, ) { - final selected = episode.number == currentNumber; - final l10n = AppLocalizations.of(pageContext)!; - return EpisodePickerCell( - episode: episode, - selected: selected, - lockLabel: l10n.locked, - onTap: () { - Navigator.of(sheetContext).pop(); - if (homeFeed) { - if (episode.number == currentNumber) return; - unawaited(openSeriesAt(pageContext, drama, index)); - return; - } - if (index == session.currentIndex) return; - unawaited( - pages.animateToPage( - index, - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - ), - ); - }, + if (homeFeed) { + if (drama.episodes[index].number == currentNumber) return; + unawaited(openSeriesAt(pageContext, drama, index)); + return; + } + if (index == session.currentIndex) return; + unawaited( + pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), ); } @@ -839,8 +773,8 @@ class PlayerLogic extends ChangeNotifier { await showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: AppColors.ink, - showDragHandle: true, + backgroundColor: Colors.transparent, + showDragHandle: false, builder: (_) => DramaCommentsSheet(drama: drama), ); } finally { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 95935ea..4ae4cc9 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -8,7 +8,6 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/play_ticket.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; @@ -16,24 +15,31 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.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_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; +import '../interaction/widgets/report_dialog.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; +import 'widgets/player_more_sheet.dart'; +import 'widgets/player_overlay.dart'; +import 'widgets/player_overlay_models.dart'; +import 'widgets/player_speed_sheet.dart'; +import 'widgets/player_text_size_sheet.dart'; +import 'widgets/player_unlock_sheet.dart'; import 'widgets/quality_sheet.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'; +part 'player_view_actions.dart'; + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -80,6 +86,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; + double _textScale = playerTextSizeStandard; @override void initState() { @@ -117,6 +124,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { super.dispose(); } + void _updatePlaybackSpeed(double value) { + setState(() => _playbackSpeed = value); + } + + void _updateTextScale(double value) { + setState(() => _textScale = value); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -198,7 +213,16 @@ class _PlayerPageState extends State with WidgetsBindingObserver { return Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: AppColors.ink), + ColoredBox( + color: AppColors.onSurface, + child: widget.homeMode + ? null + : RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), ?video, if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) IgnorePointer( @@ -287,19 +311,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 - if (selected && !ready && logic.blockedEpisodeId == episode.id) - _BlockedNotice( - l10n: l10n, - episode: episode, - accessType: logic.blockedTicket?.accessType, - unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, - unlocking: logic.unlockingEpisodeId == episode.id, - error: logic.unlockError, - onUnlock: logic.wallet == null - ? null - : () => _unlockEpisode(context, index), - ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && !ready && @@ -384,72 +395,57 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ) : const SizedBox.shrink() else - _PlaybackOverlay( - l10n: l10n, - drama: drama, - episode: episode, - controller: ready ? videoPort!.controller : null, - liked: interactions.isLiked(drama.id), - likeCount: interactions.likeCount(drama.id), - // 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载) - saved: context.watch().isSaved(drama.id), - onBack: widget.embedded - ? null - : () => unawaited(logic.leave(context)), - onEpisodes: () => logic.showEpisodes(context), - onNext: logic.hasNextEpisode - ? () => unawaited(logic.playNextEpisode()) - : null, - playing: logic.session.userWantsPlay, - playbackSpeed: _playbackSpeed, - onSpeedTap: () => unawaited(_openSpeedSheet(context)), - qualities: logic.qualities, - onQualityTap: () => unawaited(_openQualitySheet(context)), + PlayerPlaybackOverlay( + data: PlayerOverlayData( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + liked: interactions.isLiked(drama.id), + likeCount: interactions.likeCount(drama.id), + saved: context.watch().isSaved(drama.id), + playing: logic.session.userWantsPlay, + textScale: _textScale, + currentIndex: logic.session.currentIndex, + totalEpisodes: logic.visibleIndices.length, + ), + actions: PlayerOverlayActions( + onBack: widget.embedded + ? null + : () => unawaited(logic.leave(context)), + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onEpisodes: () => logic.showEpisodes(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + onComments: () => logic.showComments(context, drama), + onLike: () => + unawaited(_toggleLike(context, interactions, drama)), + onSave: () => unawaited(_toggleSave(context, drama)), + onShare: () => logic.shareDrama(context, drama), + onMore: () => _openMore(context, drama, episode), + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + ), controlsVisible: _chromeVisible, - onClear: () => setState(() => _chromeVisible = false), - onExitClear: () => setState(() => _chromeVisible = true), - onTogglePlay: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) - onLike: () async { - if (await ensureSignedIn(context)) { - 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, - ), - ), - ); - } - } - }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onDownload: () => requestEpisodeDownload(context, episode.id), - onComments: () => logic.showComments(context, drama), - onShare: () => logic.shareDrama(context, drama), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, + ), + // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 + if (selected && !ready && logic.blockedEpisodeId == episode.id) + PlayerBlockedNotice( + l10n: l10n, + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + unlocking: logic.unlockingEpisodeId == episode.id, + error: logic.unlockError, + onOpenUnlock: logic.wallet == null + ? null + : () => _openUnlockSheet(context, index, episode), ), if (widget.homeMode && !_chromeVisible) Positioned.fill( @@ -490,723 +486,4 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ], ); } - - Future _openSpeedSheet(BuildContext context) async { - final selected = await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed), - ); - if (selected == null || !mounted) return; - setState(() => _playbackSpeed = selected); - await logic.setPlaybackSpeed(selected); - } - - Future _openQualitySheet(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (_) => QualitySheet(qualities: logic.qualities), - ); - } - - Future _unlockEpisode(BuildContext context, int index) async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - final ok = await logic.unlockEpisode(index); - if (!context.mounted || ok) return; - final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)), - ), - ); - } -} - -/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 -class _BlockedNotice extends StatelessWidget { - const _BlockedNotice({ - required this.l10n, - required this.episode, - required this.accessType, - required this.unlockCoin, - required this.unlocking, - required this.error, - required this.onUnlock, - }); - - final AppLocalizations l10n; - final Episode episode; - final String? accessType; - final int unlockCoin; - final bool unlocking; - final String? error; - final VoidCallback? onUnlock; - - @override - Widget build(BuildContext context) { - final needsUnlock = - accessType == PlayTicket.coinRequired || - accessType == PlayTicket.vipRequired; - final title = needsUnlock - ? l10n.lockedTitle(episode.number) - : accessType == PlayTicket.noMedia - ? l10n.noMediaNotice - : episode.locked - ? l10n.lockedTitle(episode.number) - : l10n.noMediaNotice; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: _UnlockCard( - locked: needsUnlock, - title: title, - coinLabel: needsUnlock && unlockCoin > 0 - ? '$unlockCoin ${l10n.coins}' - : null, - footnote: needsUnlock && error != null - ? errorText(l10n, error, l10n.unlockFailed) - : null, - actionLabel: needsUnlock ? l10n.unlockWithCoins : null, - busy: unlocking, - onAction: needsUnlock ? onUnlock : null, - ), - ), - ); - } -} - -/// Coin lock presented as a reward card: gold chip, chili only on the icon. -class _UnlockCard extends StatelessWidget { - const _UnlockCard({ - required this.locked, - required this.title, - required this.coinLabel, - required this.footnote, - this.actionLabel, - this.busy = false, - this.onAction, - }); - - final bool locked; - final String title; - final String? coinLabel; - final String? footnote; - final String? actionLabel; - final bool busy; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined, - color: locked ? AppColors.gold : AppColors.cream, - size: 32, - ), - const SizedBox(height: 12), - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.cream, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - if (coinLabel != null) ...[ - const SizedBox(height: 12), - DecoratedBox( - decoration: BoxDecoration( - color: AppColors.gold, - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - child: Text( - coinLabel!, - style: const TextStyle( - color: AppColors.onGold, - fontSize: 13, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - if (footnote != null) ...[ - const SizedBox(height: 8), - Text( - footnote!, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ], - if (actionLabel != null) ...[ - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - height: 40, - child: FilledButton( - key: const Key('unlock-with-coins'), - onPressed: busy ? null : onAction, - style: FilledButton.styleFrom( - backgroundColor: AppColors.gold, - foregroundColor: AppColors.onGold, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text( - actionLabel!, - style: const TextStyle(fontWeight: FontWeight.w800), - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -class _PlaybackOverlay extends StatefulWidget { - const _PlaybackOverlay({ - required this.l10n, - required this.drama, - required this.episode, - required this.controller, - required this.liked, - this.likeCount, - required this.saved, - required this.playing, - required this.playbackSpeed, - required this.onSpeedTap, - required this.qualities, - required this.onQualityTap, - required this.controlsVisible, - required this.onTogglePlay, - required this.onClear, - required this.onExitClear, - required this.onBack, - required this.onEpisodes, - required this.onComments, - required this.onLike, - required this.onSave, - required this.onDownload, - required this.onShare, - required this.onPip, - required this.onScrubStart, - required this.onSeek, - this.onNext, - }); - - final AppLocalizations l10n; - final Drama drama; - final Episode episode; - final VideoPlayerController? controller; - final bool liked; - final int? likeCount; - final bool saved; - final bool playing; - final double playbackSpeed; - final VoidCallback onSpeedTap; - final List qualities; - final VoidCallback onQualityTap; - final bool controlsVisible; - final VoidCallback onTogglePlay; - final VoidCallback onClear; - final VoidCallback onExitClear; - final VoidCallback? onBack; - final VoidCallback onEpisodes; - final VoidCallback onComments; - final VoidCallback? onNext; - final VoidCallback onLike; - final VoidCallback onSave; - final VoidCallback onDownload; - final VoidCallback onShare; - final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; - - @override - State<_PlaybackOverlay> createState() => _PlaybackOverlayState(); -} - -class _PlaybackOverlayState extends State<_PlaybackOverlay> { - @override - Widget build(BuildContext context) { - return SafeArea( - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - key: const Key('player-video-tap'), - behavior: HitTestBehavior.opaque, - onTap: widget.onTogglePlay, - ), - ), - if (widget.controlsVisible) - Column( - key: const Key('player-controls-visible'), - children: [ - _topBar(context), - const Spacer(), - Padding( - key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), - child: Column( - children: [ - SeekProgressBar( - key: const Key('player-progress'), - valueListenable: widget.controller, - semanticsLabel: widget.l10n.seekVideo, - showTimes: true, - onScrubStart: widget.onScrubStart, - onSeek: widget.onSeek, - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: _PlayerAction( - key: const Key('player-episodes'), - icon: Icons.view_list_rounded, - label: widget.l10n.episodes, - onTap: widget.onEpisodes, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _PlayerAction( - key: const Key('player-comments'), - icon: Icons.chat_bubble_outline_rounded, - label: widget.l10n.comments, - onTap: widget.onComments, - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 32, - child: FilledButton( - key: const Key('player-next'), - onPressed: widget.onNext, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - disabledBackgroundColor: AppColors.raised, - disabledForegroundColor: AppColors.subtle, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - widget.l10n.nextEpisode, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.skip_next_rounded, - size: 14, - ), - ], - ), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - PlayerFeaturedComment( - drama: widget.drama, - onViewAll: widget.onComments, - ), - ], - ), - ), - ], - ), - if (!widget.controlsVisible) - Positioned( - top: 8, - right: 12, - child: ExitClearScreenButton( - key: const Key('player-exit-clear-screen'), - tooltip: widget.l10n.exitClearScreen, - onPressed: widget.onExitClear, - ), - ), - if (!widget.playing) - Center( - child: PlayerCenterToggle( - key: const Key('player-play-toggle'), - playing: false, - onTap: widget.onTogglePlay, - ), - ), - ], - ), - ); - } - - Widget _topBar(BuildContext context) { - return SizedBox( - key: const Key('player-controls-top'), - height: 46, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - if (widget.onBack != null) - _TopAction( - key: const Key('player-back'), - tooltip: widget.l10n.backToSeries, - icon: Icons.chevron_left_rounded, - onPressed: widget.onBack!, - ) - else - const SizedBox(width: 36), - const SizedBox(width: 8), - Expanded( - child: Text( - '${localizedDramaTitle(widget.l10n, widget.drama)} · ' - '${widget.l10n.episodeLabel(widget.episode.number)}', - key: const Key('player-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 8), - _TopAction( - key: const Key('player-clear-screen'), - tooltip: widget.l10n.clearScreen, - icon: Icons.fullscreen_rounded, - onPressed: widget.onClear, - ), - const SizedBox(width: 8), - _TopAction( - key: const Key('player-more'), - tooltip: widget.l10n.more, - icon: Icons.more_horiz_rounded, - onPressed: () => _openMore(context), - ), - ], - ), - ), - ); - } - - Future _openMore(BuildContext context) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('player-more-speed'), - leading: const Icon( - Icons.speed_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackSpeed), - trailing: Text( - _speedLabel(widget.playbackSpeed), - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onSpeedTap(); - }); - }, - ), - if (widget.qualities.isNotEmpty) - ListTile( - key: const Key('player-more-quality'), - leading: const Icon( - Icons.high_quality_rounded, - color: AppColors.secondary, - ), - title: Text(widget.l10n.playbackQuality), - trailing: Text( - widget.qualities.contains(QualitySheet.autoQuality) - ? widget.l10n.qualityAuto - : widget.qualities.first, - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - Navigator.pop(sheetContext); - widget.onQualityTap(); - }, - ), - ListTile( - key: const Key('player-more-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - widget.onDownload(); - }, - ), - if (widget.onPip != null) - ListTile( - key: const Key('player-more-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(widget.l10n.pictureInPicture), - onTap: () { - Navigator.pop(sheetContext); - widget.onPip!(); - }, - ), - ListTile( - key: const Key('player-more-like'), - leading: Icon( - widget.liked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: widget.liked ? AppColors.blue : AppColors.muted, - ), - title: Text(widget.l10n.like), - trailing: widget.likeCount == null - ? null - : Text( - '${widget.likeCount}', - key: const Key('player-more-like-count'), - ), - onTap: () { - Navigator.pop(sheetContext); - widget.onLike(); - }, - ), - ListTile( - key: const Key('player-more-favorite'), - leading: Icon( - widget.saved ? Icons.star_rounded : Icons.star_border_rounded, - color: widget.saved ? AppColors.blue : AppColors.muted, - ), - title: Text(widget.l10n.favorite), - onTap: () { - Navigator.pop(sheetContext); - widget.onSave(); - }, - ), - ListTile( - key: const Key('player-more-share'), - leading: const Icon( - Icons.share_outlined, - color: AppColors.muted, - ), - title: Text(widget.l10n.share), - onTap: () { - Navigator.pop(sheetContext); - widget.onShare(); - }, - ), - ], - ), - ), - ), - ); - } -} - -const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; - -String _speedLabel(double value) => '${value.toString()}x'; - -class _PlaybackSpeedSheet extends StatelessWidget { - const _PlaybackSpeedSheet({required this.selected}); - - final double selected; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return SafeArea( - child: Column( - key: const Key('player-speed-sheet'), - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.playbackSpeed, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - for (final value in _speedOptions) - ListTile( - key: Key('player-speed-${value.toString()}'), - dense: true, - tileColor: value == selected - ? AppColors.blue.withValues(alpha: 0.12) - : null, - title: Text( - _speedLabel(value), - style: TextStyle( - color: value == selected ? AppColors.blue : AppColors.cream, - fontSize: 13, - fontWeight: value == selected - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - trailing: value == selected - ? const Icon( - Icons.check_rounded, - color: AppColors.blue, - size: 18, - ) - : null, - onTap: () => Navigator.pop(context, value), - ), - ], - ), - ); - } -} - -class _TopAction extends StatelessWidget { - const _TopAction({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: AppColors.card.withValues(alpha: 0.94), - foregroundColor: AppColors.onSurface, - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _PlayerAction extends StatelessWidget { - const _PlayerAction({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.card.withValues(alpha: 0.94), - borderRadius: BorderRadius.circular(9), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: SizedBox( - height: 32, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 14, color: AppColors.secondary), - const SizedBox(width: 3), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - ); - } } diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart new file mode 100644 index 0000000..2149186 --- /dev/null +++ b/lib/pages/player/player_view_actions.dart @@ -0,0 +1,162 @@ +// Author: fruit.com.cn + +part of 'player_view.dart'; + +extension _PlayerViewActions on _PlayerPageState { + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed), + ); + if (selected == null || !mounted) return; + _updatePlaybackSpeed(selected); + await logic.setPlaybackSpeed(selected); + } + + Future _openQualitySheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => QualitySheet(qualities: logic.qualities), + ); + } + + Future _openMore( + BuildContext context, + Drama drama, + Episode episode, + ) async { + final l10n = AppLocalizations.of(context)!; + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + isScrollControlled: true, + builder: (_) => PlayerMoreSheet( + l10n: l10n, + state: PlayerMoreState( + speed: _playbackSpeed, + qualities: logic.qualities, + textScale: _textScale, + pipAvailable: logic.pictureInPicture.isPlatformSupported, + ), + actions: PlayerMoreActions( + onSpeed: () => unawaited(_openSpeedSheet(context)), + onQuality: () => unawaited(_openQualitySheet(context)), + onDownload: () => requestEpisodeDownload(context, episode.id), + onTextSize: () => unawaited(_openTextSizeSheet(context)), + onReport: () => unawaited(_reportDrama(context, drama, l10n)), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ), + ), + ); + } + + Future _openTextSizeSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => PlayerTextSizeSheet(selected: _textScale), + ); + if (selected == null || !mounted) return; + _updateTextScale(selected); + } + + Future _openUnlockSheet( + BuildContext context, + int index, + Episode episode, + ) async { + final wallet = logic.wallet; + if (wallet == null) return; + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + isScrollControlled: true, + builder: (_) => PlayerUnlockSheet( + l10n: AppLocalizations.of(context)!, + episodeNumber: episode.number, + coinCost: logic.blockedTicket?.unlockCoin ?? 0, + balance: wallet.snapshot == null ? null : wallet.balance, + onUnlock: () => _unlockEpisode(context, index), + ), + ); + } + + Future _unlockEpisode(BuildContext context, int index) async { + if (!await ensureSignedIn(context) || !context.mounted) return false; + final ok = await logic.unlockEpisode(index); + if (ok || !context.mounted) return ok; + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)), + ), + ); + return false; + } + + Future _reportDrama( + BuildContext context, + Drama drama, + AppLocalizations l10n, + ) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + final draft = await showReportDialog(context, title: l10n.reportDrama); + if (draft == null || !context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await context.read().reportDrama( + drama.id, + reason: draft.reason, + description: draft.description, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.isSuccess + ? l10n.reportSubmitted + : result.errorMessage ?? l10n.reportFailed, + ), + ), + ); + } + + Future _toggleLike( + BuildContext context, + InteractionController interactions, + Drama drama, + ) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (result.isSuccess || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + + Future _toggleSave(BuildContext context, Drama drama) async { + final favorites = context.read(); + if (!await ensureSignedIn(context) || !context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.favoriteToggleFailed), + ), + ); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index d183d1b..b23ee01 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -23,6 +23,7 @@ const double _minSheetHeight = 280; const double _maxSheetHeight = 520; const int _maxCommentLength = 300; const double _composerRadius = 22; +const double _sheetRadius = 20; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。 class DramaCommentsSheet extends StatefulWidget { @@ -160,15 +161,24 @@ class _DramaCommentsSheetState extends State { 24; return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: SizedBox( - height: available.clamp(_minSheetHeight, _maxSheetHeight), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => _content(context), + child: Container( + key: const Key('player-comments-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + ), + child: SizedBox( + height: available.clamp(_minSheetHeight, _maxSheetHeight), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _content(context), + ), ), ), ), @@ -182,13 +192,35 @@ class _DramaCommentsSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + Expanded( + child: Text( + count == null ? l10n.comments : l10n.commentsWithCount(count), + key: count == null ? null : const Key('comment-count-label'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-comments-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), Text( - count == null ? l10n.comments : '${l10n.comments} · $count', - key: count == null ? null : const Key('comment-count-label'), + widget.drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.cream, - fontSize: 18, - fontWeight: FontWeight.w700, + color: AppColors.muted, + fontSize: 12, + fontWeight: FontWeight.w500, ), ), const SizedBox(height: 12), @@ -312,7 +344,7 @@ class _DramaCommentsSheetState extends State { maxLength: _maxCommentLength, maxLines: 1, style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.gold, + cursorColor: AppColors.blue, decoration: _composerDecoration(l10n.writeComment), onSubmitted: (_) => _post(), ), diff --git a/lib/pages/player/widgets/episode_picker_cell.dart b/lib/pages/player/widgets/episode_picker_cell.dart index b5069b7..d282460 100644 --- a/lib/pages/player/widgets/episode_picker_cell.dart +++ b/lib/pages/player/widgets/episode_picker_cell.dart @@ -10,7 +10,7 @@ const double _numberSize = 15; const double _lockSize = 16; const double _lockGap = 4; const double _lockWeight = 300; -const double _coverAlpha = 0.45; +const double _coverAlpha = 0.55; /// 选集格子。需要解锁时铺一层透明底,锁在上,集数在锁下面。 class EpisodePickerCell extends StatelessWidget { @@ -38,12 +38,19 @@ class EpisodePickerCell extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - if (!episode.locked) Center(child: _EpisodeNumber(episode.number)), + if (!episode.locked) + Center( + child: _EpisodeNumber( + episode.number, + color: selected ? AppColors.onPrimary : AppColors.cream, + ), + ), if (episode.locked) _LockCover( episodeId: episode.id, number: episode.number, label: lockLabel, + selected: selected, ), ], ), @@ -53,16 +60,17 @@ class EpisodePickerCell extends StatelessWidget { } class _EpisodeNumber extends StatelessWidget { - const _EpisodeNumber(this.number); + const _EpisodeNumber(this.number, {required this.color}); final int number; + final Color color; @override Widget build(BuildContext context) { return Text( '$number', - style: const TextStyle( - color: AppColors.cream, + style: TextStyle( + color: color, fontSize: _numberSize, fontWeight: FontWeight.w600, height: 1, @@ -77,11 +85,13 @@ class _LockCover extends StatelessWidget { required this.episodeId, required this.number, required this.label, + required this.selected, }); final String episodeId; final int number; final String label; + final bool selected; @override Widget build(BuildContext context) { @@ -90,7 +100,7 @@ class _LockCover extends StatelessWidget { children: [ ColoredBox( key: ValueKey('episode-lock-cover-$episodeId'), - color: AppColors.ink.withValues(alpha: _coverAlpha), + color: AppColors.card.withValues(alpha: _coverAlpha), ), Center( child: Column( @@ -102,11 +112,14 @@ class _LockCover extends StatelessWidget { size: _lockSize, weight: _lockWeight, fill: 0, - color: AppColors.gold, + color: AppColors.secondary, semanticLabel: label, ), const SizedBox(height: _lockGap), - _EpisodeNumber(number), + _EpisodeNumber( + number, + color: selected ? AppColors.onPrimary : AppColors.cream, + ), ], ), ), diff --git a/lib/pages/player/widgets/episode_picker_sheet.dart b/lib/pages/player/widgets/episode_picker_sheet.dart new file mode 100644 index 0000000..5d05cf3 --- /dev/null +++ b/lib/pages/player/widgets/episode_picker_sheet.dart @@ -0,0 +1,131 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import 'episode_picker_cell.dart'; + +const int _columns = 6; +const double _sheetHeightFactor = 0.52; +const double _cellAspect = 1.05; +const double _sheetRadius = 20; + +/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。 +class EpisodePickerSheet extends StatelessWidget { + /// 创建选集面板。 + const EpisodePickerSheet({ + super.key, + required this.drama, + required this.currentNumber, + required this.onSelect, + }); + + /// 当前短剧。 + final Drama drama; + + /// 当前集号。 + final int currentNumber; + + /// 点击某集,参数为该集在原始列表中的索引。 + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final episodes = drama.episodes; + return Container( + key: const Key('player-episodes-sheet'), + height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + l10n.episodes, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-episodes-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + l10n.episodeCountLabel(episodes.length), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columns, + mainAxisSpacing: AppDimens.spaceSm, + crossAxisSpacing: AppDimens.spaceSm, + childAspectRatio: _cellAspect, + ), + itemCount: episodes.length, + itemBuilder: (context, index) { + final episode = episodes[index]; + return EpisodePickerCell( + key: Key('player-episode-${episode.number}'), + episode: episode, + selected: episode.number == currentNumber, + lockLabel: l10n.locked, + onTap: () { + Navigator.pop(context); + onSelect(index); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} 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 1f1381f..0000000 --- a/lib/pages/player/widgets/player_featured_comment.dart +++ /dev/null @@ -1,158 +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'; - -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)!; - final time = _time(l10n, comment.createdAt); - return InkWell( - key: const Key('player-featured-comment'), - onTap: widget.onViewAll, - borderRadius: BorderRadius.circular(10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(9), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.secondary.withValues(alpha: 0.2), - ), - child: Text( - _initial(comment.nickname), - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 8, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 6), - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - comment.nickname.isEmpty - ? l10n.localViewer - : comment.nickname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 10, - fontWeight: FontWeight.w700, - ), - ), - ), - if (time != null) ...[ - const SizedBox(width: 4), - Text( - '· $time', - style: const TextStyle( - color: AppColors.muted, - fontSize: 9, - ), - ), - ], - ], - ), - ), - ], - ), - const SizedBox(height: 5), - Text( - comment.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 10, - height: 1.5, - ), - ), - ], - ), - ), - ); - } - - String? _time(AppLocalizations l10n, DateTime? createdAt) { - if (createdAt == null) return null; - final diff = DateTime.now().difference(createdAt); - if (diff.inMinutes < 1) return null; - return l10n.minutesAgo(diff.inMinutes); - } - - String _initial(String nickname) { - final trimmed = nickname.trim(); - return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); - } -} diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart new file mode 100644 index 0000000..70140d6 --- /dev/null +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -0,0 +1,228 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import 'player_text_size_sheet.dart'; +import 'quality_sheet.dart'; + +const double _sheetRadius = 20; + +/// 更多面板运行状态。 +class PlayerMoreState { + /// 创建更多面板状态。 + const PlayerMoreState({ + required this.speed, + required this.qualities, + required this.textScale, + required this.pipAvailable, + }); + + /// 当前倍速。 + final double speed; + + /// 后端下发的清晰度档位。 + final List qualities; + + /// 当前信息文字缩放比例。 + final double textScale; + + /// 设备是否提供画中画入口。 + final bool pipAvailable; +} + +/// 更多面板动作集合。 +class PlayerMoreActions { + /// 创建动作集合。 + const PlayerMoreActions({ + required this.onSpeed, + required this.onQuality, + required this.onDownload, + required this.onTextSize, + required this.onReport, + this.onPip, + }); + + /// 打开倍速面板。 + final VoidCallback onSpeed; + + /// 打开清晰度面板。 + final VoidCallback onQuality; + + /// 下载当前集。 + final VoidCallback onDownload; + + /// 打开文字大小面板。 + final VoidCallback onTextSize; + + /// 举报当前剧。 + final VoidCallback onReport; + + /// 进入画中画。 + final VoidCallback? onPip; +} + +/// K28 播放页更多操作面板。 +class PlayerMoreSheet extends StatelessWidget { + /// 创建更多操作面板。 + const PlayerMoreSheet({ + super.key, + required this.l10n, + required this.state, + required this.actions, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 面板状态。 + final PlayerMoreState state; + + /// 面板动作。 + final PlayerMoreActions actions; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('player-more-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), + child: Text( + l10n.more, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + _MoreRow( + key: const Key('player-more-speed'), + icon: Icons.speed_rounded, + title: l10n.playbackSpeed, + value: '${state.speed}x', + action: actions.onSpeed, + ), + if (state.qualities.isNotEmpty) + _MoreRow( + key: const Key('player-more-quality'), + icon: Icons.high_quality_rounded, + title: l10n.playbackQuality, + value: state.qualities.contains(QualitySheet.autoQuality) + ? l10n.qualityAuto + : state.qualities.first, + action: actions.onQuality, + ), + _MoreRow( + key: const Key('player-more-download'), + icon: Icons.download_rounded, + title: l10n.downloads, + value: '', + action: actions.onDownload, + ), + _MoreRow( + key: const Key('player-more-text-size'), + icon: Icons.format_size_rounded, + title: l10n.textSize, + value: playerTextSizeLabel(l10n, state.textScale), + action: actions.onTextSize, + ), + if (state.pipAvailable && actions.onPip != null) + _MoreRow( + key: const Key('player-more-pip'), + icon: Icons.picture_in_picture_alt_rounded, + title: l10n.pictureInPicture, + value: '', + action: actions.onPip!, + ), + _MoreRow( + key: const Key('player-more-report'), + icon: Icons.flag_outlined, + title: l10n.reportDrama, + value: '', + action: actions.onReport, + ), + ], + ), + ), + ), + ); + } +} + +class _MoreRow extends StatelessWidget { + const _MoreRow({ + super.key, + required this.icon, + required this.title, + required this.value, + required this.action, + }); + + final IconData icon; + final String title; + final String value; + final VoidCallback action; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) => action()); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), + child: Row( + children: [ + Icon(icon, color: AppColors.muted, size: 21), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + if (value.isNotEmpty) + Text( + value, + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart new file mode 100644 index 0000000..c44d2e1 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay.dart @@ -0,0 +1,416 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import 'exit_clear_screen_button.dart'; +import 'player_center_toggle.dart'; +import 'player_overlay_models.dart'; +import 'seek_progress_bar.dart'; + +part 'player_overlay_parts.dart'; + +const double _topGradientHeight = 230; +const double _bottomGradientHeight = 390; +const double _topButtonSize = 36; +const double _titleSize = 24; +const double _dotSize = 7; +const double _railButtonSize = 36; +const double _railGap = 9; +const double _chipRadius = 999; +const double _dotRadius = 3; +const double _railTopFactor = 0.28; +const double _railTopCompactFactor = 0.18; + +/// K28 竖屏播放页浮层。 +class PlayerPlaybackOverlay extends StatelessWidget { + /// 创建播放浮层。 + const PlayerPlaybackOverlay({ + super.key, + required this.data, + required this.actions, + required this.controlsVisible, + }); + + /// 展示数据。 + final PlayerOverlayData data; + + /// 交互动作。 + final PlayerOverlayActions actions; + + /// 控制层是否可见。 + final bool controlsVisible; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: actions.onTogglePlay, + ), + ), + if (controlsVisible) ...[ + _top(context), + _bottom(context), + _rail(context), + ], + if (!controlsVisible) + Positioned( + top: AppDimens.spaceSm, + right: AppDimens.spaceMd, + child: ExitClearScreenButton( + key: const Key('player-exit-clear-screen'), + tooltip: data.l10n.exitClearScreen, + onPressed: actions.onExitClear, + ), + ), + if (!data.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: false, + onTap: actions.onTogglePlay, + ), + ), + ], + ), + ); + } + + Widget _top(BuildContext context) { + final title = data.episode.title.trim().isEmpty + ? data.l10n.episodeLabel(data.episode.number) + : data.episode.title; + final series = localizedDramaTitle(data.l10n, data.drama); + return Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + key: const Key('player-controls-top'), + height: _topGradientHeight, + padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xCC000000), Color(0x00000000)], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (actions.onBack != null) + _TopButton( + key: const Key('player-back'), + tooltip: data.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: actions.onBack!, + ) + else + const SizedBox(width: _topButtonSize), + const Spacer(), + _TopButton( + key: const Key('player-clear-screen'), + tooltip: data.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: actions.onClear, + ), + const SizedBox(width: AppDimens.spaceSm), + _TopButton( + key: const Key('player-more'), + tooltip: data.l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: actions.onMore, + ), + ], + ), + const SizedBox(height: 18), + Row( + children: [ + Flexible( + child: Text( + title, + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: _titleSize * data.textScale, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 7), + Container( + key: const Key('player-title-dot'), + width: _dotSize, + height: _dotSize, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '$series · ${data.l10n.episodeLabel(data.episode.number)}', + key: const Key('player-subtitle'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 14 * data.textScale, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + if (_genre.isNotEmpty) + _infoChip(const Key('player-genre-chip'), _genre), + const Spacer(), + _PageDots( + key: const Key('player-page-dots'), + current: data.currentIndex, + total: data.totalEpisodes, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _rail(BuildContext context) { + final likeCount = data.likeCount; + final compact = MediaQuery.sizeOf(context).height < 700; + return Positioned( + right: AppDimens.spaceMd, + top: + MediaQuery.sizeOf(context).height * + (compact ? _railTopCompactFactor : _railTopFactor), + child: _Rail( + l10n: data.l10n, + liked: data.liked, + saved: data.saved, + likeCount: likeCount, + onLike: actions.onLike, + onComments: actions.onComments, + onSave: actions.onSave, + onShare: actions.onShare, + compact: compact, + ), + ); + } + + Widget _bottom(BuildContext context) { + final series = localizedDramaTitle(data.l10n, data.drama); + final synopsis = localizedDramaSynopsis(data.l10n, data.drama); + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Stack( + children: [ + const Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x00000000), + Color(0xDD000000), + Color(0xF2000000), + ], + ), + ), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _bottomGradientHeight), + child: Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + series, + key: const Key('player-drama-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: 22 * data.textScale, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + Text( + data.l10n.episodeLabel(data.episode.number), + key: const Key('player-episode-label'), + style: TextStyle( + color: AppColors.blue, + fontSize: 12 * data.textScale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: _genre.isEmpty + ? const SizedBox.shrink() + : _softChip(_genre), + ), + ), + const SizedBox(width: 8), + SizedBox( + height: AppDimens.buttonSmall, + child: FilledButton( + key: const Key('player-follow'), + onPressed: actions.onSave, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: Text( + data.saved + ? data.l10n.followingTab + : data.l10n.followSeries, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + synopsis, + key: const Key('player-synopsis'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 12 * data.textScale, + height: 1.45, + ), + ), + const SizedBox(height: 4), + _TimeLabel( + key: const Key('player-time-label'), + controller: data.controller, + ), + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: data.controller, + semanticsLabel: data.l10n.seekVideo, + showTimes: true, + compact: true, + onScrubStart: actions.onScrubStart, + onSeek: actions.onSeek, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: _ActionButton( + key: const Key('player-episodes'), + icon: Icons.grid_view_rounded, + label: data.l10n.episodes, + onTap: actions.onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + key: const Key('player-next'), + icon: Icons.skip_next_rounded, + label: data.l10n.nextEpisode, + onTap: actions.onNext, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + key: const Key('player-bottom-more'), + icon: Icons.more_horiz_rounded, + label: data.l10n.more, + onTap: actions.onMore, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _infoChip(Key key, String label) => Container( + key: key, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(_chipRadius), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ); + + Widget _softChip(String label) => Container( + constraints: const BoxConstraints(maxWidth: 180), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(_chipRadius), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ); + + String get _genre => + data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category; +} diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart new file mode 100644 index 0000000..e5a1e15 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -0,0 +1,117 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 播放浮层展示数据。 +class PlayerOverlayData { + /// 创建播放浮层数据。 + const PlayerOverlayData({ + required this.l10n, + required this.drama, + required this.episode, + required this.controller, + required this.liked, + required this.likeCount, + required this.saved, + required this.playing, + required this.textScale, + required this.currentIndex, + required this.totalEpisodes, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前短剧。 + final Drama drama; + + /// 当前剧集。 + final Episode episode; + + /// 当前播放器。 + final VideoPlayerController? controller; + + /// 是否已点赞。 + final bool liked; + + /// 点赞数;未知时隐藏。 + final int? likeCount; + + /// 是否已收藏。 + final bool saved; + + /// 是否处于播放状态。 + final bool playing; + + /// 信息文字缩放比例。 + final double textScale; + + /// 当前集索引。 + final int currentIndex; + + /// 可见集数量。 + final int totalEpisodes; +} + +/// 播放浮层动作集合。 +class PlayerOverlayActions { + /// 创建动作集合。 + const PlayerOverlayActions({ + required this.onBack, + required this.onTogglePlay, + required this.onClear, + required this.onExitClear, + required this.onEpisodes, + required this.onComments, + required this.onLike, + required this.onSave, + required this.onShare, + required this.onMore, + required this.onScrubStart, + required this.onSeek, + this.onNext, + }); + + /// 返回上一页。 + final VoidCallback? onBack; + + /// 切换播放状态。 + final VoidCallback onTogglePlay; + + /// 进入清屏。 + final VoidCallback onClear; + + /// 退出清屏。 + final VoidCallback onExitClear; + + /// 打开选集。 + final VoidCallback onEpisodes; + + /// 打开评论。 + final VoidCallback onComments; + + /// 切换点赞。 + final VoidCallback onLike; + + /// 切换收藏。 + final VoidCallback onSave; + + /// 分享。 + final VoidCallback onShare; + + /// 打开更多面板。 + final VoidCallback onMore; + + /// 下一集。 + final VoidCallback? onNext; + + /// 开始拖动进度。 + final VoidCallback? onScrubStart; + + /// 提交进度跳转。 + final Future Function(Duration)? onSeek; +} diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart new file mode 100644 index 0000000..7a2ce76 --- /dev/null +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -0,0 +1,243 @@ +// Author: fruit.com.cn + +part of 'player_overlay.dart'; + +class _TopButton extends StatelessWidget { + const _TopButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: _topButtonSize, + child: IconButton( + tooltip: tooltip, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: AppColors.card, + foregroundColor: AppColors.onSurface, + ), + onPressed: onPressed, + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _Rail extends StatelessWidget { + const _Rail({ + required this.l10n, + required this.liked, + required this.saved, + required this.likeCount, + required this.onLike, + required this.onComments, + required this.onSave, + required this.onShare, + required this.compact, + }); + + final AppLocalizations l10n; + final bool liked; + final bool saved; + final int? likeCount; + final VoidCallback onLike; + final VoidCallback onComments; + final VoidCallback onSave; + final VoidCallback onShare; + final bool compact; + + @override + Widget build(BuildContext context) { + return Column( + key: const Key('player-interaction-rail'), + children: [ + _RailButton( + key: const Key('player-rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + color: liked ? AppColors.blueSoft : AppColors.onPrimary, + label: likeCount?.toString() ?? l10n.like, + onTap: onLike, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + onTap: onSave, + compact: compact, + ), + _RailButton( + key: const Key('player-rail-share'), + icon: Icons.ios_share_rounded, + label: l10n.share, + onTap: onShare, + compact: compact, + ), + ], + ); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + required this.compact, + this.color = AppColors.onPrimary, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool compact; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 2 : _railGap), + child: Column( + children: [ + SizedBox.square( + dimension: _railButtonSize, + child: IconButton( + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: AppColors.overlayDark, + foregroundColor: color, + ), + icon: Icon(icon, size: 18), + ), + ), + SizedBox(height: compact ? 1 : 3), + Text( + label, + maxLines: 1, + style: TextStyle( + color: AppColors.onPrimary, + fontSize: compact ? 8 : 9, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _PageDots extends StatelessWidget { + const _PageDots({super.key, required this.current, required this.total}); + + final int current; + final int total; + + @override + Widget build(BuildContext context) { + if (total <= 1) return const SizedBox.shrink(); + final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0); + final end = (start + 5).clamp(0, total); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = start; index < end; index++) + Container( + width: index == current ? 14 : _dotSize, + height: _dotSize, + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: index == current ? AppColors.blue : Colors.white54, + borderRadius: BorderRadius.circular(_dotRadius), + ), + ), + ], + ); + } +} + +class _TimeLabel extends StatelessWidget { + const _TimeLabel({super.key, required this.controller}); + + final VideoPlayerController? controller; + + @override + Widget build(BuildContext context) { + final source = controller; + if (source == null) return _label(Duration.zero, Duration.zero); + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) => _label(value.position, value.duration), + ); + } + + Widget _label(Duration position, Duration duration) => Text( + '${_format(position)} / ${_format(duration)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ); + + 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 _ActionButton extends StatelessWidget { + const _ActionButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: FilledButton.icon( + onPressed: onTap, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + backgroundColor: AppColors.card, + foregroundColor: AppColors.onSurface, + disabledBackgroundColor: Colors.black38, + disabledForegroundColor: Colors.white54, + ), + icon: Icon(icon, size: 16), + label: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_speed_sheet.dart b/lib/pages/player/widgets/player_speed_sheet.dart new file mode 100644 index 0000000..7a42449 --- /dev/null +++ b/lib/pages/player/widgets/player_speed_sheet.dart @@ -0,0 +1,75 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; +const double _sheetRadius = 20; + +/// 播放倍速选择弹窗。 +class PlayerSpeedSheet extends StatelessWidget { + /// 创建倍速选择弹窗。 + const PlayerSpeedSheet({super.key, required this.selected}); + + /// 当前倍速。 + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + key: const Key('player-speed-sheet'), + color: AppColors.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackSpeed, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + for (final value in _speedOptions) _option(context, value), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _option(BuildContext context, double value) { + final active = value == selected; + return ListTile( + key: Key('player-speed-${value.toString()}'), + dense: true, + title: Text( + '${value.toString()}x', + style: TextStyle( + color: active ? AppColors.blue : AppColors.onSurface, + fontSize: 14, + fontWeight: active ? FontWeight.w800 : FontWeight.w500, + ), + ), + trailing: active + ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 20) + : null, + onTap: () => Navigator.pop(context, value), + ); + } +} diff --git a/lib/pages/player/widgets/player_text_size_sheet.dart b/lib/pages/player/widgets/player_text_size_sheet.dart new file mode 100644 index 0000000..fa284f5 --- /dev/null +++ b/lib/pages/player/widgets/player_text_size_sheet.dart @@ -0,0 +1,110 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double playerTextSizeSmall = 0.9; +const double playerTextSizeStandard = 1.0; +const double playerTextSizeLarge = 1.15; +const double _sheetRadius = 20; + +/// 返回播放信息文字档位的本地化名称。 +String playerTextSizeLabel(AppLocalizations l10n, double scale) { + if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; + if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; + return l10n.textSizeStandard; +} + +/// 播放页文字大小选择弹窗。 +class PlayerTextSizeSheet extends StatelessWidget { + /// 创建文字大小选择弹窗。 + const PlayerTextSizeSheet({super.key, required this.selected}); + + /// 当前文字缩放比例。 + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final options = [ + (playerTextSizeSmall, 'small', l10n.textSizeSmall), + (playerTextSizeStandard, 'standard', l10n.textSizeStandard), + (playerTextSizeLarge, 'large', l10n.textSizeLarge), + ]; + return Container( + key: const Key('player-text-size-sheet'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.textSize, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 16), + for (final option in options) ...[ + _option(context, option.$1, option.$2, option.$3), + if (option != options.last) const SizedBox(height: 8), + ], + ], + ), + ), + ), + ); + } + + Widget _option( + BuildContext context, + double scale, + String keySuffix, + String label, + ) { + final active = (scale - selected).abs() < 0.01; + return Material( + key: Key('player-text-size-$keySuffix'), + color: active ? AppColors.quick : AppColors.raised, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => Navigator.pop(context, scale), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + color: active ? AppColors.blue : AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + if (active) + const Icon( + Icons.check_circle_rounded, + color: AppColors.blue, + size: 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/player_unlock_sheet.dart b/lib/pages/player/widgets/player_unlock_sheet.dart new file mode 100644 index 0000000..4cdedc4 --- /dev/null +++ b/lib/pages/player/widgets/player_unlock_sheet.dart @@ -0,0 +1,373 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/models/play_ticket.dart'; +import '../../../core/formatting/error_text.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const double _cardRadius = 20; +const double _optionRadius = 14; +const double _sheetWidth = 360; + +/// 视频区占位提示:由后端票据决定是解锁、无片源还是会员限制。 +class PlayerBlockedNotice extends StatelessWidget { + /// 创建不可播放提示。 + const PlayerBlockedNotice({ + super.key, + required this.l10n, + required this.episode, + required this.accessType, + required this.unlockCoin, + required this.unlocking, + required this.error, + required this.onOpenUnlock, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前剧集。 + final Episode episode; + + /// 后端下发的内容访问类型。 + final String? accessType; + + /// 金币解锁价格。 + final int unlockCoin; + + /// 是否正在解锁。 + final bool unlocking; + + /// 最近一次解锁失败信息。 + final String? error; + + /// 打开内容解锁面板。 + final VoidCallback? onOpenUnlock; + + bool get _needsUnlock => + accessType == PlayTicket.coinRequired || + accessType == PlayTicket.vipRequired; + + @override + Widget build(BuildContext context) { + final title = _needsUnlock + ? l10n.lockedTitle(episode.number) + : accessType == PlayTicket.noMedia || !episode.locked + ? l10n.noMediaNotice + : l10n.lockedTitle(episode.number); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _sheetWidth), + child: Container( + key: const Key('player-unlock-notice'), + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + boxShadow: const [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _needsUnlock ? Icons.lock_rounded : Icons.movie_filter_outlined, + color: _needsUnlock ? AppColors.blue : AppColors.muted, + size: 30, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + if (_needsUnlock && unlockCoin > 0) ...[ + const SizedBox(height: 10), + _coinLabel(l10n), + ], + if (error != null) ...[ + const SizedBox(height: 8), + Text( + errorText(l10n, error, l10n.unlockFailed), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.badgePinkText, + fontSize: 12, + ), + ), + ], + if (_needsUnlock) ...[ + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton( + key: const Key('player-open-unlock'), + onPressed: unlocking ? null : onOpenUnlock, + child: unlocking + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.unlockEpisode), + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _coinLabel(AppLocalizations l10n) => DecoratedBox( + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + l10n.walletBalanceShort(unlockCoin), + style: const TextStyle( + color: AppColors.onGold, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ); +} + +/// 内容解锁面板:金币可用,广告与会员在契约缺失时明确禁用。 +class PlayerUnlockSheet extends StatefulWidget { + /// 创建内容解锁面板。 + const PlayerUnlockSheet({ + super.key, + required this.l10n, + required this.episodeNumber, + required this.coinCost, + required this.balance, + required this.onUnlock, + }); + + /// 当前语言包。 + final AppLocalizations l10n; + + /// 当前集号。 + final int episodeNumber; + + /// 金币解锁价格。 + final int coinCost; + + /// 当前金币余额;未知时隐藏。 + final int? balance; + + /// 金币解锁动作,成功后返回 true。 + final Future Function()? onUnlock; + + @override + State createState() => _PlayerUnlockSheetState(); +} + +class _PlayerUnlockSheetState extends State { + bool _busy = false; + String? _error; + + Future _unlock() async { + final action = widget.onUnlock; + if (action == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + final success = await action(); + if (!mounted) return; + if (success) { + Navigator.of(context).pop(true); + return; + } + setState(() { + _busy = false; + _error = widget.l10n.unlockFailed; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = widget.l10n; + return Container( + key: const Key('player-unlock-panel'), + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.lockedTitle(widget.episodeNumber), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-unlock-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + const SizedBox(height: 8), + _UnlockOption( + optionKey: const Key('player-unlock-coins'), + icon: Icons.toll_rounded, + title: l10n.unlockWithCoins, + subtitle: widget.balance == null + ? l10n.walletBalanceShort(widget.coinCost) + : '${l10n.walletBalanceShort(widget.coinCost)} · ' + '${l10n.walletBalanceShort(widget.balance!)}', + enabled: widget.onUnlock != null && !_busy, + busy: _busy, + onTap: _unlock, + ), + const SizedBox(height: 10), + _UnlockOption( + optionKey: const Key('player-unlock-ad'), + icon: Icons.ondemand_video_rounded, + title: l10n.watchAdToUnlock, + subtitle: l10n.comingSoon, + enabled: false, + onTap: null, + ), + const SizedBox(height: 10), + _UnlockOption( + optionKey: const Key('player-unlock-vip'), + icon: Icons.workspace_premium_rounded, + title: l10n.unlockWithVip, + subtitle: l10n.comingSoon, + enabled: false, + onTap: null, + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + style: const TextStyle( + color: AppColors.badgePinkText, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 14), + Text( + l10n.unlockMethodsVary, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ], + ), + ), + ), + ); + } +} + +class _UnlockOption extends StatelessWidget { + const _UnlockOption({ + required this.optionKey, + required this.icon, + required this.title, + required this.subtitle, + required this.enabled, + this.busy = false, + this.onTap, + }); + + final Key optionKey; + final IconData icon; + final String title; + final String subtitle; + final bool enabled; + final bool busy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Material( + key: optionKey, + color: enabled ? AppColors.quick : AppColors.raised, + borderRadius: BorderRadius.circular(_optionRadius), + child: InkWell( + borderRadius: BorderRadius.circular(_optionRadius), + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Icon( + icon, + color: enabled ? AppColors.blue : AppColors.subtle, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: enabled ? AppColors.onSurface : AppColors.subtle, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ], + ), + ), + if (busy) + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (enabled) + const Icon(Icons.chevron_right_rounded, color: AppColors.blue), + ], + ), + ), + ), + ); + } +} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 413d19e..5712889 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -68,7 +68,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Second page'), findsOneWidget); - expect(find.text('Comments · 2'), findsOneWidget); + expect(find.text('Comments (2)'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 11b4daa..7464da8 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -6,6 +6,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.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'; @@ -287,29 +289,145 @@ void main() { ); expect(bar.showTimes, isTrue); }); + + testWidgets('should_render_k28_player_structure_and_episode_grid', ( + tester, + ) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + + _expectFullscreenOverlay(tester); + await tester.tap(find.byKey(const Key('player-episodes'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-episodes-close')), findsOneWidget); + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('player-episodes-sheet')), + matching: find.byType(GridView), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 6); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_locked_episode_in_episode_picker', (tester) async { + await _pumpApp( + tester, + catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), + ); + await _openSeriesPlayer( + tester, + cardKey: const Key('home-drama-card-locked-demo'), + ); + await tester.tap(find.byKey(const Key('player-episodes'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('episode-lock-locked-demo-1')), + findsOneWidget, + ); + expect(find.byKey(const Key('player-episode-2')), findsOneWidget); + }); + + testWidgets('should_open_more_actions_and_change_text_size', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-more-speed')), findsOneWidget); + expect(find.byKey(const Key('player-more-quality')), findsOneWidget); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-text-size')), findsOneWidget); + expect(find.byKey(const Key('player-more-report')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-more-text-size'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-text-size-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-text-size-large'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-text-size')), + matching: find.text('Large'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_open_unlock_panel_with_honest_method_states', ( + tester, + ) async { + await _pumpApp( + tester, + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + await _openSeriesPlayer(tester); + + expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-open-unlock'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-unlock-panel')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-coins')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-ad')), findsOneWidget); + expect(find.byKey(const Key('player-unlock-vip')), findsOneWidget); + expect( + find.text('Available unlock methods vary by title.'), + findsOneWidget, + ); + }); } void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-title-dot')), findsOneWidget); + expect(find.byKey(const Key('player-subtitle')), findsOneWidget); + expect(find.byKey(const Key('player-genre-chip')), findsOneWidget); + expect(find.byKey(const Key('player-page-dots')), findsOneWidget); + expect(find.byKey(const Key('player-drama-title')), findsOneWidget); + expect(find.byKey(const Key('player-episode-label')), findsOneWidget); + expect(find.byKey(const Key('player-follow')), findsOneWidget); + expect(find.byKey(const Key('player-synopsis')), findsOneWidget); + expect(find.byKey(const Key('player-time-label')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); - expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-next')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); - expect(find.byKey(const Key('rail-like')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); + expect(find.byKey(const Key('player-rail-like')), findsOneWidget); + expect(find.byKey(const Key('player-rail-save')), findsOneWidget); + expect(find.byKey(const Key('player-rail-share')), findsOneWidget); } -Future _openSeriesPlayer(WidgetTester tester) async { +Future _openSeriesPlayer( + WidgetTester tester, { + Key cardKey = const Key('home-drama-card-city-lights-demo'), +}) async { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-city-lights-demo')); + final card = find.byKey(cardKey); await tester.scrollUntilVisible( card, 220, @@ -329,20 +447,22 @@ Future _openSeriesPlayer(WidgetTester tester) async { Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), + FakeCatalogRepository? catalog, + FakePlaybackRepository? playback, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), + catalogRepository: repository, + categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), - playbackRepository: FakePlaybackRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), + playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), @@ -351,3 +471,29 @@ Future _pumpApp( ); await tester.pumpAndSettle(); } + +const _lockedDrama = Drama( + id: 'locked-demo', + title: 'Locked Demo', + synopsis: 'Locked demo synopsis', + posterUrl: '/files/locked-demo.png', + genre: 'Suspense', + detailLoaded: true, + episodes: [ + Episode( + id: 'locked-demo-1', + number: 1, + title: 'Locked Episode', + isFree: false, + locked: true, + videoUrl: 'https://example.test/locked-demo-1.mp4', + ), + Episode( + id: 'locked-demo-2', + number: 2, + title: 'Open Episode', + isFree: true, + videoUrl: 'https://example.test/locked-demo-2.mp4', + ), + ], +); diff --git a/test/widget_test.dart b/test/widget_test.dart index 4a12dd3..709f47c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -226,7 +226,7 @@ void main() { await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await tester.tap(find.byKey(const Key('player-comments'))); + await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.text('No comments yet.'), findsOneWidget); await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); @@ -236,11 +236,14 @@ void main() { await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - await _tapPlayerMore(tester, const Key('player-more-like')); + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); expect(interactions.likeTargets, contains('DRAMA:city-lights-demo')); - await _tapPlayerMore(tester, const Key('player-more-favorite')); - await _tapPlayerMore(tester, const Key('player-more-share')); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-rail-share'))); + await tester.pumpAndSettle(); expect(sharedText, contains('City Lights & Velvet Nights')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); @@ -256,7 +259,8 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await _tapPlayerMore(tester, const Key('player-more-like')); + await tester.tap(find.byKey(const Key('player-rail-like'))); + await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(interactions.likeTargets, isEmpty); @@ -271,7 +275,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - await _tapPlayerMore(tester, const Key('player-more-favorite')); + await tester.tap(find.byKey(const Key('player-rail-save'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); @@ -303,12 +308,3 @@ Future _openTheaterPlayer(WidgetTester tester) async { await tester.tap(card); await tester.pumpAndSettle(); } - -Future _tapPlayerMore(WidgetTester tester, Key action) async { - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(action)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(action)); - await tester.pumpAndSettle(); -}