From 8c43e9e385ab8a873c798e6bd91154c7defab404 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 11:01:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E6=9E=84=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=A1=B5=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 43 +- lib/core/data/services/locale_controller.dart | 53 ++ lib/l10n/app_en.arb | 17 +- lib/l10n/app_id.arb | 17 +- lib/l10n/app_localizations.dart | 80 ++- lib/l10n/app_localizations_en.dart | 41 +- lib/l10n/app_localizations_id.dart | 41 +- lib/l10n/app_localizations_zh.dart | 41 +- lib/l10n/app_zh.arb | 17 +- lib/pages/discover/discover_logic.dart | 8 - lib/pages/discover/discover_types.dart | 3 - lib/pages/discover/discover_view.dart | 33 -- lib/pages/mine/mine_view.dart | 97 +++- lib/pages/mine/widgets/mine_menu.dart | 48 +- lib/pages/player/player_view.dart | 20 +- lib/pages/player/player_view_actions.dart | 33 +- lib/pages/player/widgets/comment_tile.dart | 43 +- .../player/widgets/player_more_sheet.dart | 366 ++++++++++---- lib/pages/player/widgets/player_overlay.dart | 474 ++++++++---------- .../player/widgets/player_overlay_models.dart | 36 +- .../player/widgets/player_overlay_parts.dart | 221 +++++--- .../player/widgets/player_speed_sheet.dart | 2 +- .../player/widgets/player_text_scale.dart | 19 + .../widgets/player_text_size_sheet.dart | 110 ---- lib/pages/player/widgets/quality_sheet.dart | 85 ---- test/comment/drama_comments_sheet_test.dart | 2 +- test/core/locale_controller_test.dart | 39 ++ test/discover/home_all_dramas_grid_test.dart | 2 +- test/home/k28_a_navigation_test.dart | 17 +- test/mine/mine_preview_layout_test.dart | 3 +- test/player/player_fullscreen_test.dart | 127 +++-- test/player/quality_sheet_test.dart | 32 -- test/watch/watch_history_provider_test.dart | 4 +- test/widget_test.dart | 36 +- 34 files changed, 1331 insertions(+), 879 deletions(-) create mode 100644 lib/core/data/services/locale_controller.dart create mode 100644 lib/pages/player/widgets/player_text_scale.dart delete mode 100644 lib/pages/player/widgets/player_text_size_sheet.dart delete mode 100644 lib/pages/player/widgets/quality_sheet.dart create mode 100644 test/core/locale_controller_test.dart delete mode 100644 test/player/quality_sheet_test.dart diff --git a/lib/app.dart b/lib/app.dart index e51d6ba..6ab3ac0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -40,6 +40,7 @@ import 'core/data/services/category_controller.dart'; import 'core/data/services/download_controller.dart'; import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; +import 'core/data/services/locale_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; @@ -175,11 +176,14 @@ class _LumaDramaAppState extends State { late final InteractionController _interactionController = InteractionController(_interactionRepository); + late final LocaleController _localeController = LocaleController(); + @override void initState() { super.initState(); // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 _auth.load(); + unawaited(_localeController.load()); // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); if (_auth.isSignedIn) unawaited(_favoriteController.load()); @@ -210,6 +214,7 @@ class _LumaDramaAppState extends State { _downloadController.dispose(); _walletController.dispose(); _interactionController.dispose(); + _localeController.dispose(); _apiClient.close(); super.dispose(); } @@ -242,27 +247,31 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), + ChangeNotifierProvider.value(value: _localeController), ChangeNotifierProvider.value(value: _auth), ], - child: MaterialApp( - onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - localeResolutionCallback: (deviceLocale, _) => - resolveAppLocale(deviceLocale), - debugShowCheckedModeBanner: false, - themeMode: ThemeMode.light, - theme: AppTheme.light(), - darkTheme: AppTheme.light(), - builder: (context, child) => AnnotatedRegion( - value: SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, - systemNavigationBarColor: AppColors.bar, - systemNavigationBarIconBrightness: Brightness.dark, + child: Consumer( + builder: (context, localeController, _) => MaterialApp( + locale: localeController.locale, + onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + localeResolutionCallback: (deviceLocale, _) => + resolveAppLocale(deviceLocale), + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.light, + theme: AppTheme.light(), + darkTheme: AppTheme.light(), + builder: (context, child) => AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + systemNavigationBarColor: AppColors.bar, + systemNavigationBarIconBrightness: Brightness.dark, + ), + child: child ?? const SizedBox.shrink(), ), - child: child ?? const SizedBox.shrink(), + home: const HomePage(), ), - home: const HomePage(), ), ); } diff --git a/lib/core/data/services/locale_controller.dart b/lib/core/data/services/locale_controller.dart new file mode 100644 index 0000000..0eeab22 --- /dev/null +++ b/lib/core/data/services/locale_controller.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 全局界面语言状态。 +/// +/// `null` 表示没有用户选择,继续跟随系统语言;一旦用户在“我的”里选择, +/// 选择结果会持久化并在下一次启动时恢复。 +class LocaleController extends ChangeNotifier { + /// 创建语言状态控制器。 + LocaleController({SharedPreferencesAsync? preferences}) + : _preferences = preferences ?? SharedPreferencesAsync(); + + static const String preferenceKey = 'app.locale'; + static const Set supportedCodes = {'zh', 'en', 'id'}; + + final SharedPreferencesAsync _preferences; + Locale? _locale; + bool _disposed = false; + + /// 用户选择的语言;为空时由 MaterialApp 跟随系统。 + Locale? get locale => _locale; + + /// 当前语言的展示代码。 + String get languageCode => _locale?.languageCode ?? 'system'; + + /// 读取本地语言选择;未设置时保持跟随系统。 + Future load() async { + final code = await _preferences.getString(preferenceKey); + if (code == null || !supportedCodes.contains(code)) return; + _locale = Locale(code); + _notify(); + } + + /// 立即切换并持久化语言。 + Future select(Locale locale) async { + if (!supportedCodes.contains(locale.languageCode)) return; + _locale = locale; + _notify(); + await _preferences.setString(preferenceKey, locale.languageCode); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6913b35..e377d84 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -581,7 +581,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "{count} episodes", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "Comments ({count})", + "commentsWithCount": "{count} comments", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "Text size", "textSizeSmall": "Small", @@ -590,5 +590,18 @@ "watchAdToUnlock": "Watch an ad to unlock", "unlockWithVip": "Unlock with VIP", "unlockMethodsVary": "Available unlock methods vary by title.", - "unlockEpisode": "Unlock this episode" + "unlockEpisode": "Unlock this episode", + "language": "Language", + "fullScreenWatch": "Full screen", + "freeWatch": "Free watch", + "showMore": "More", + "qualityUnavailable": "Unavailable", + "notInterested": "Not interested", + "clearPlayback": "Clear-screen playback", + "danmaku": "Danmaku", + "danmakuUnavailable": "Coming soon", + "danmakuSettings": "Danmaku settings", + "offlineCache": "Offline cache", + "screenCast": "Cast", + "sleepTimer": "Sleep timer" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 586de55..d80f862 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -579,7 +579,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "{count} episode", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "Komentar ({count})", + "commentsWithCount": "{count} komentar", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "Ukuran teks", "textSizeSmall": "Kecil", @@ -588,5 +588,18 @@ "watchAdToUnlock": "Tonton iklan untuk membuka", "unlockWithVip": "Buka dengan VIP", "unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", - "unlockEpisode": "Buka episode ini" + "unlockEpisode": "Buka episode ini", + "language": "Bahasa", + "fullScreenWatch": "Layar penuh", + "freeWatch": "Tonton gratis", + "showMore": "Selengkapnya", + "qualityUnavailable": "Tidak tersedia", + "notInterested": "Tidak tertarik", + "clearPlayback": "Putar tanpa kontrol", + "danmaku": "Komentar layar", + "danmakuUnavailable": "Belum tersedia", + "danmakuSettings": "Pengaturan komentar layar", + "offlineCache": "Cache offline", + "screenCast": "Cast", + "sleepTimer": "Timer tidur" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b861cd5..41f5f46 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2581,7 +2581,7 @@ abstract class AppLocalizations { /// No description provided for @commentsWithCount. /// /// In en, this message translates to: - /// **'Comments ({count})'** + /// **'{count} comments'** String commentsWithCount(int count); /// No description provided for @textSize. @@ -2631,6 +2631,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Unlock this episode'** String get unlockEpisode; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @fullScreenWatch. + /// + /// In en, this message translates to: + /// **'Full screen'** + String get fullScreenWatch; + + /// No description provided for @freeWatch. + /// + /// In en, this message translates to: + /// **'Free watch'** + String get freeWatch; + + /// No description provided for @showMore. + /// + /// In en, this message translates to: + /// **'More'** + String get showMore; + + /// No description provided for @qualityUnavailable. + /// + /// In en, this message translates to: + /// **'Unavailable'** + String get qualityUnavailable; + + /// No description provided for @notInterested. + /// + /// In en, this message translates to: + /// **'Not interested'** + String get notInterested; + + /// No description provided for @clearPlayback. + /// + /// In en, this message translates to: + /// **'Clear-screen playback'** + String get clearPlayback; + + /// No description provided for @danmaku. + /// + /// In en, this message translates to: + /// **'Danmaku'** + String get danmaku; + + /// No description provided for @danmakuUnavailable. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get danmakuUnavailable; + + /// No description provided for @danmakuSettings. + /// + /// In en, this message translates to: + /// **'Danmaku settings'** + String get danmakuSettings; + + /// No description provided for @offlineCache. + /// + /// In en, this message translates to: + /// **'Offline cache'** + String get offlineCache; + + /// No description provided for @screenCast. + /// + /// In en, this message translates to: + /// **'Cast'** + String get screenCast; + + /// No description provided for @sleepTimer. + /// + /// In en, this message translates to: + /// **'Sleep timer'** + String get sleepTimer; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a16aca9..9f8819a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1338,7 +1338,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String commentsWithCount(int count) { - return 'Comments ($count)'; + return '$count comments'; } @override @@ -1364,4 +1364,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unlockEpisode => 'Unlock this episode'; + + @override + String get language => 'Language'; + + @override + String get fullScreenWatch => 'Full screen'; + + @override + String get freeWatch => 'Free watch'; + + @override + String get showMore => 'More'; + + @override + String get qualityUnavailable => 'Unavailable'; + + @override + String get notInterested => 'Not interested'; + + @override + String get clearPlayback => 'Clear-screen playback'; + + @override + String get danmaku => 'Danmaku'; + + @override + String get danmakuUnavailable => 'Coming soon'; + + @override + String get danmakuSettings => 'Danmaku settings'; + + @override + String get offlineCache => 'Offline cache'; + + @override + String get screenCast => 'Cast'; + + @override + String get sleepTimer => 'Sleep timer'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 690217a..518dad1 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1337,7 +1337,7 @@ class AppLocalizationsId extends AppLocalizations { @override String commentsWithCount(int count) { - return 'Komentar ($count)'; + return '$count komentar'; } @override @@ -1364,4 +1364,43 @@ class AppLocalizationsId extends AppLocalizations { @override String get unlockEpisode => 'Buka episode ini'; + + @override + String get language => 'Bahasa'; + + @override + String get fullScreenWatch => 'Layar penuh'; + + @override + String get freeWatch => 'Tonton gratis'; + + @override + String get showMore => 'Selengkapnya'; + + @override + String get qualityUnavailable => 'Tidak tersedia'; + + @override + String get notInterested => 'Tidak tertarik'; + + @override + String get clearPlayback => 'Putar tanpa kontrol'; + + @override + String get danmaku => 'Komentar layar'; + + @override + String get danmakuUnavailable => 'Belum tersedia'; + + @override + String get danmakuSettings => 'Pengaturan komentar layar'; + + @override + String get offlineCache => 'Cache offline'; + + @override + String get screenCast => 'Cast'; + + @override + String get sleepTimer => 'Timer tidur'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a487f6d..7d344c5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1314,7 +1314,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String commentsWithCount(int count) { - return '评论($count)'; + return '$count 条评论'; } @override @@ -1340,4 +1340,43 @@ class AppLocalizationsZh extends AppLocalizations { @override String get unlockEpisode => '解锁本集'; + + @override + String get language => '语言'; + + @override + String get fullScreenWatch => '全屏观看'; + + @override + String get freeWatch => '免费观看'; + + @override + String get showMore => '展开'; + + @override + String get qualityUnavailable => '暂不可用'; + + @override + String get notInterested => '不感兴趣'; + + @override + String get clearPlayback => '清屏播放'; + + @override + String get danmaku => '弹幕'; + + @override + String get danmakuUnavailable => '暂未开放'; + + @override + String get danmakuSettings => '弹幕设置'; + + @override + String get offlineCache => '离线缓存'; + + @override + String get screenCast => '投屏'; + + @override + String get sleepTimer => '定时关闭'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f8be167..05aa597 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -541,7 +541,7 @@ "@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "episodeCountLabel": "共 {count} 集", "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, - "commentsWithCount": "评论({count})", + "commentsWithCount": "{count} 条评论", "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "textSize": "文字大小", "textSizeSmall": "小", @@ -550,5 +550,18 @@ "watchAdToUnlock": "看广告解锁", "unlockWithVip": "会员解锁", "unlockMethodsVary": "具体解锁方式以作品而定。", - "unlockEpisode": "解锁本集" + "unlockEpisode": "解锁本集", + "language": "语言", + "fullScreenWatch": "全屏观看", + "freeWatch": "免费观看", + "showMore": "展开", + "qualityUnavailable": "暂不可用", + "notInterested": "不感兴趣", + "clearPlayback": "清屏播放", + "danmaku": "弹幕", + "danmakuUnavailable": "暂未开放", + "danmakuSettings": "弹幕设置", + "offlineCache": "离线缓存", + "screenCast": "投屏", + "sleepTimer": "定时关闭" } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 9556f36..d500ed7 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -28,7 +28,6 @@ class DiscoverLogic extends ChangeNotifier { String _lastRequestedQuery = ''; int _searchRequest = 0; bool _disposed = false; - TheaterLanguage language = TheaterLanguage.all; String? selectedCategoryId; bool get hasSearchQuery => search.text.trim().isNotEmpty; @@ -50,13 +49,6 @@ class DiscoverLogic extends ChangeNotifier { void openSearch() => searchFocus.requestFocus(); - /// 选择语言;当前仅改变视觉选中态,不参与数据过滤。 - void selectLanguage(TheaterLanguage value) { - if (language == value) return; - language = value; - notify(); - } - /// 选择分类;空值表示全部。 void selectCategory(String? categoryId) { if (selectedCategoryId == categoryId) return; diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index 5899661..d369cc9 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -7,9 +7,6 @@ import '../../core/data/models/drama.dart'; /// 剧场页与根导航之间的动作。 enum DiscoverPageAction { home, create, my } -/// 剧场语言筛选的视觉选中态。 -enum TheaterLanguage { all, zh, id, ms, en } - /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { const DiscoverPageCallbacks({ diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 222f127..0970307 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -175,7 +175,6 @@ class _DiscoverPageState extends State { : categories.dramasOf(logic.selectedCategoryId!); return [ _categoryChips(l10n, categories), - _languageChips(l10n), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -222,38 +221,6 @@ class _DiscoverPageState extends State { ); } - Widget _languageChips(AppLocalizations l10n) { - final entries = [ - (TheaterLanguage.all, l10n.allLanguages), - (TheaterLanguage.zh, l10n.simplifiedChinese), - (TheaterLanguage.id, l10n.bahasaIndonesia), - (TheaterLanguage.ms, l10n.bahasaMelayu), - (TheaterLanguage.en, l10n.english), - ]; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceSm, - AppDimens.pageHorizontal, - 0, - ), - child: Wrap( - key: const Key('theater-language-chips'), - spacing: 10, - runSpacing: 10, - children: [ - for (final entry in entries) - _filterChip( - key: Key('theater-language-${entry.$1.name}'), - label: entry.$2, - selected: logic.language == entry.$1, - onSelected: () => logic.selectLanguage(entry.$1), - ), - ], - ), - ); - } - Widget _filterChip({ required Key key, required String label, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index b3af0f2..7b2c44d 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; @@ -122,7 +123,9 @@ class _MyPageState extends State { Widget _body(BuildContext context, AppLocalizations l10n) { final auth = context.watch(); final wallet = context.watch(); + final locale = context.watch(); return ListView( + key: const Key('mine-page-scroll'), padding: const EdgeInsets.fromLTRB( AppDimens.pageHorizontal, 0, @@ -142,7 +145,11 @@ class _MyPageState extends State { const SizedBox(height: AppDimens.spaceMd), MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), const SizedBox(height: AppDimens.spaceMd), - MineMenuList(onOpen: _openDestination), + MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageLabel: _languageLabel(l10n, locale.locale), + ), ], ); } @@ -203,6 +210,27 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } + String _languageLabel(AppLocalizations l10n, Locale? locale) { + return switch (locale?.languageCode) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + 'id' => l10n.bahasaIndonesia, + _ => l10n.language, + }; + } + + Future _openLanguage() async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => + _LanguageSheet(selected: context.read().locale), + ); + if (selected == null || !mounted) return; + await context.read().select(selected); + } + void _openRecharge() { final wallet = context.read(); _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); @@ -254,6 +282,73 @@ class _MyPageState extends State { } } +class _LanguageSheet extends StatelessWidget { + const _LanguageSheet({required this.selected}); + + final Locale? selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final options = [ + ( + const Locale('zh'), + const Key('mine-language-zh'), + l10n.simplifiedChinese, + ), + (const Locale('en'), const Key('mine-language-en'), l10n.english), + (const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia), + ]; + return Material( + key: const Key('mine-language-sheet'), + color: AppColors.card, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.language, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + for (final option in options) + ListTile( + key: option.$2, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + title: Text(option.$3), + trailing: selected?.languageCode == option.$1.languageCode + ? const Icon(Icons.check_rounded, color: AppColors.blue) + : null, + onTap: () => Navigator.pop(context, option.$1), + ), + ], + ), + ), + ), + ); + } +} + enum MinePlaceholderKind { works, creator, help } class _MinePlaceholderPage extends StatelessWidget { diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 3cda8dd..8fc6270 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -265,9 +265,16 @@ class MineVipCard extends StatelessWidget { /// 我的页功能菜单。 class MineMenuList extends StatelessWidget { - const MineMenuList({super.key, required this.onOpen}); + const MineMenuList({ + super.key, + required this.onOpen, + required this.onLanguage, + required this.languageLabel, + }); final ValueChanged onOpen; + final VoidCallback onLanguage; + final String languageLabel; @override Widget build(BuildContext context) { @@ -313,6 +320,8 @@ class MineMenuList extends StatelessWidget { destination: MineDestination.settings, ), _divider(), + _languageRow(context), + _divider(), _row( key: const Key('mine-help'), icon: Icons.help_outline_rounded, @@ -325,6 +334,43 @@ class MineMenuList extends StatelessWidget { ); } + Widget _languageRow(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return ListTile( + key: const Key('mine-language'), + minTileHeight: 48, + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: const Icon( + Icons.language_rounded, + color: AppColors.blue, + size: 20, + ), + title: Text( + l10n.language, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.body, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + languageLabel, + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.subtle, + size: 18, + ), + ], + ), + onTap: onLanguage, + ); + } + Widget _row({ required Key key, required IconData icon, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4ae4cc9..7d9da94 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -32,9 +32,7 @@ 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 '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -86,7 +84,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; double _playbackSpeed = 1.0; - double _textScale = playerTextSizeStandard; + double _textScale = 1.0; @override void initState() { @@ -132,6 +130,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { setState(() => _textScale = value); } + void _hideChrome() { + setState(() => _chromeVisible = false); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -403,10 +405,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id), + commentCount: null, + favoriteCount: null, saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, textScale: _textScale, - currentIndex: logic.session.currentIndex, totalEpisodes: logic.visibleIndices.length, ), actions: PlayerOverlayActions( @@ -417,16 +421,18 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.setUserWantsPlay(!logic.session.userWantsPlay), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), + onDetail: () => unawaited(logic.openDetail(context)), + onSpeed: () => unawaited(_openSpeedSheet(context)), 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), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 2149186..0733240 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -15,15 +15,6 @@ extension _PlayerViewActions on _PlayerPageState { 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, @@ -41,33 +32,21 @@ extension _PlayerViewActions on _PlayerPageState { speed: _playbackSpeed, qualities: logic.qualities, textScale: _textScale, - pipAvailable: logic.pictureInPicture.isPlatformSupported, ), actions: PlayerMoreActions( - onSpeed: () => unawaited(_openSpeedSheet(context)), - onQuality: () => unawaited(_openQualitySheet(context)), + onSpeedChanged: (value) { + _updatePlaybackSpeed(value); + unawaited(logic.setPlaybackSpeed(value)); + }, + onTextScaleChanged: _updateTextScale, + onClear: _hideChrome, 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, diff --git a/lib/pages/player/widgets/comment_tile.dart b/lib/pages/player/widgets/comment_tile.dart index 56c1c19..7214bbb 100644 --- a/lib/pages/player/widgets/comment_tile.dart +++ b/lib/pages/player/widgets/comment_tile.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/comment.dart'; import '../../../core/data/services/comment_controller.dart'; +import '../../../core/formatting/localized_date_time.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; const String _deleteAction = 'delete'; @@ -53,15 +55,7 @@ class CommentTile extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: isReply ? 12 : 16, - backgroundColor: AppColors.raised, - child: Icon( - Icons.person_rounded, - size: isReply ? 14 : 18, - color: AppColors.cream, - ), - ), + _avatar(), const SizedBox(width: 12), Expanded( child: Column( @@ -78,6 +72,15 @@ class CommentTile extends StatelessWidget { ), ), ), + if (comment.createdAt != null) + Text( + formatLocalizedDateTime(context, comment.createdAt!), + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + const SizedBox(width: 4), PopupMenuButton( key: Key('comment-menu-${comment.id}'), tooltip: l10n.more, @@ -148,4 +151,26 @@ class CommentTile extends StatelessWidget { ), ); } + + Widget _avatar() { + final size = isReply ? 24.0 : 32.0; + if (comment.avatarUrl.isEmpty) { + return CircleAvatar( + radius: size / 2, + backgroundColor: AppColors.raised, + child: Icon( + Icons.person_rounded, + size: isReply ? 14 : 18, + color: AppColors.cream, + ), + ); + } + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: RemoteImage(comment.avatarUrl, fit: BoxFit.cover), + ), + ); + } } diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 70140d6..525bc01 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -4,10 +4,12 @@ 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'; +import 'player_text_scale.dart'; const double _sheetRadius = 20; +const List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; +const List _qualityOptions = ['AUTO', '720P', '1080P']; +const String _autoQuality = 'AUTO'; /// 更多面板运行状态。 class PlayerMoreState { @@ -16,7 +18,6 @@ class PlayerMoreState { required this.speed, required this.qualities, required this.textScale, - required this.pipAvailable, }); /// 当前倍速。 @@ -25,47 +26,40 @@ class PlayerMoreState { /// 后端下发的清晰度档位。 final List qualities; - /// 当前信息文字缩放比例。 + /// 当前文字缩放比例。 final double textScale; - - /// 设备是否提供画中画入口。 - final bool pipAvailable; } /// 更多面板动作集合。 class PlayerMoreActions { /// 创建动作集合。 const PlayerMoreActions({ - required this.onSpeed, - required this.onQuality, + required this.onSpeedChanged, + required this.onTextScaleChanged, + required this.onClear, required this.onDownload, - required this.onTextSize, required this.onReport, - this.onPip, }); - /// 打开倍速面板。 - final VoidCallback onSpeed; + /// 倍速变化。 + final ValueChanged onSpeedChanged; - /// 打开清晰度面板。 - final VoidCallback onQuality; + /// 字体大小变化。 + final ValueChanged onTextScaleChanged; - /// 下载当前集。 + /// 进入清屏播放。 + final VoidCallback onClear; + + /// 离线缓存当前集。 final VoidCallback onDownload; - /// 打开文字大小面板。 - final VoidCallback onTextSize; - - /// 举报当前剧。 + /// 投诉当前剧。 final VoidCallback onReport; - - /// 进入画中画。 - final VoidCallback? onPip; } -/// K28 播放页更多操作面板。 -class PlayerMoreSheet extends StatelessWidget { - /// 创建更多操作面板。 +/// K29 播放页白色更多面板。 +class PlayerMoreSheet extends StatefulWidget { + /// 创建更多面板。 const PlayerMoreSheet({ super.key, required this.l10n, @@ -82,78 +76,82 @@ class PlayerMoreSheet extends StatelessWidget { /// 面板动作。 final PlayerMoreActions actions; + @override + State createState() => _PlayerMoreSheetState(); +} + +class _PlayerMoreSheetState extends State { + late double _speed = widget.state.speed; + late double _textScale = widget.state.textScale; + @override Widget build(BuildContext context) { + final l10n = widget.l10n; return Container( key: const Key('player-more-sheet'), + constraints: const BoxConstraints(maxHeight: 620), 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: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 18), 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, - ), - ), + _header(l10n), + _sectionTitle(l10n.playbackSpeed), + _speedPicker(), + const SizedBox(height: 16), + _sectionTitle(l10n.playbackQuality), + _qualityPicker(l10n), + const SizedBox(height: 12), + _disabledRow( + key: const Key('player-more-not-interested'), + icon: Icons.thumb_down_alt_outlined, + title: l10n.notInterested, ), - _MoreRow( - key: const Key('player-more-speed'), - icon: Icons.speed_rounded, - title: l10n.playbackSpeed, - value: '${state.speed}x', - action: actions.onSpeed, + _enabledRow( + key: const Key('player-more-clear'), + icon: Icons.fullscreen_rounded, + title: l10n.clearPlayback, + onTap: _clear, ), - 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'), + _disabledSwitchRow( + key: const Key('player-more-danmaku'), + icon: Icons.subtitles_rounded, + title: l10n.danmaku, + unavailable: l10n.danmakuUnavailable, + ), + _disabledRow( + key: const Key('player-more-danmaku-settings'), + icon: Icons.tune_rounded, + title: l10n.danmakuSettings, + ), + _enabledRow( + key: const Key('player-more-offline'), icon: Icons.download_rounded, - title: l10n.downloads, - value: '', - action: actions.onDownload, + title: l10n.offlineCache, + onTap: () => _closeThen(widget.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, + _textSizeRow(l10n), + _disabledRow( + key: const Key('player-more-cast'), + icon: Icons.cast_rounded, + title: l10n.screenCast, ), - 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( + _disabledRow( + key: const Key('player-more-timer'), + icon: Icons.timer_outlined, + title: l10n.sleepTimer, + ), + _enabledRow( key: const Key('player-more-report'), icon: Icons.flag_outlined, title: l10n.reportDrama, - value: '', - action: actions.onReport, + onTap: () => _closeThen(widget.actions.onReport), ), ], ), @@ -161,6 +159,177 @@ class PlayerMoreSheet extends StatelessWidget { ), ); } + + Widget _header(AppLocalizations l10n) => Row( + children: [ + Expanded( + child: Text( + l10n.more, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const Key('player-more-close'), + tooltip: l10n.close, + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ); + + Widget _sectionTitle(String title) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ); + + Widget _speedPicker() => Wrap( + key: const Key('player-more-speed'), + spacing: 8, + runSpacing: 8, + children: [ + for (final value in _speedOptions) + ChoiceChip( + key: Key('player-more-speed-$value'), + label: Text('${value}x'), + selected: (_speed - value).abs() < 0.001, + showCheckmark: false, + onSelected: (_) { + setState(() => _speed = value); + widget.actions.onSpeedChanged(value); + }, + ), + ], + ); + + Widget _qualityPicker(AppLocalizations l10n) => Wrap( + key: const Key('player-more-quality'), + spacing: 8, + runSpacing: 8, + children: [ + for (final quality in _qualityOptions) _qualityChip(l10n, quality), + ], + ); + + Widget _qualityChip(AppLocalizations l10n, String quality) { + final enabled = widget.state.qualities.contains(quality); + final selected = + quality == _autoQuality && enabled || + !widget.state.qualities.contains(_autoQuality) && + widget.state.qualities.isNotEmpty && + widget.state.qualities.first == quality; + return ChoiceChip( + key: Key('player-more-quality-$quality'), + label: Text(enabled ? quality : '$quality · ${l10n.qualityUnavailable}'), + selected: selected, + showCheckmark: false, + onSelected: enabled ? (_) {} : null, + ); + } + + Widget _textSizeRow(AppLocalizations l10n) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + key: const Key('player-more-text-size'), + children: [ + const Icon(Icons.format_size_rounded, color: AppColors.muted, size: 21), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.textSize, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + PopupMenuButton( + tooltip: l10n.textSize, + initialValue: _textScale, + onSelected: (value) { + setState(() => _textScale = value); + widget.actions.onTextScaleChanged(value); + }, + itemBuilder: (_) => [ + PopupMenuItem( + key: const Key('player-text-size-small'), + value: playerTextSizeSmall, + child: Text(l10n.textSizeSmall), + ), + PopupMenuItem( + key: const Key('player-text-size-standard'), + value: playerTextSizeStandard, + child: Text(l10n.textSizeStandard), + ), + PopupMenuItem( + key: const Key('player-text-size-large'), + value: playerTextSizeLarge, + child: Text(l10n.textSizeLarge), + ), + ], + child: Text( + playerTextSizeLabel(l10n, _textScale), + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + + Widget _enabledRow({ + required Key key, + required IconData icon, + required String title, + required VoidCallback onTap, + }) => + _MoreRow(key: key, icon: icon, title: title, enabled: true, onTap: onTap); + + Widget _disabledRow({ + required Key key, + required IconData icon, + required String title, + }) => _MoreRow(key: key, icon: icon, title: title, enabled: false); + + Widget _disabledSwitchRow({ + required Key key, + required IconData icon, + required String title, + required String unavailable, + }) => _MoreRow( + key: key, + icon: icon, + title: title, + enabled: false, + value: unavailable, + trailing: const Switch(value: false, onChanged: null), + ); + + void _clear() { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.actions.onClear(), + ); + } + + void _closeThen(VoidCallback action) { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) => action()); + } } class _MoreRow extends StatelessWidget { @@ -168,57 +337,50 @@ class _MoreRow extends StatelessWidget { super.key, required this.icon, required this.title, - required this.value, - required this.action, + required this.enabled, + this.value = '', + this.trailing, + this.onTap, }); final IconData icon; final String title; + final bool enabled; final String value; - final VoidCallback action; + final Widget? trailing; + final VoidCallback? onTap; @override Widget build(BuildContext context) { + final color = enabled ? AppColors.onSurface : AppColors.subtle; return Material( color: Colors.transparent, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - Navigator.pop(context); - WidgetsBinding.instance.addPostFrameCallback((_) => action()); - }, + borderRadius: BorderRadius.circular(10), + onTap: enabled ? onTap : null, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11), child: Row( children: [ - Icon(icon, color: AppColors.muted, size: 21), + Icon(icon, color: color, size: 21), const SizedBox(width: 12), Expanded( child: Text( title, - style: const TextStyle( - color: AppColors.onSurface, + style: TextStyle( + color: color, fontSize: 14, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, ), ), ), if (value.isNotEmpty) Text( value, - style: const TextStyle( - color: AppColors.blue, - fontSize: 12, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(color: AppColors.subtle, fontSize: 11), ), - const SizedBox(width: 4), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.subtle, - size: 20, - ), + ?trailing, ], ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index c44d2e1..ade1564 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -14,19 +14,14 @@ import 'seek_progress_bar.dart'; part 'player_overlay_parts.dart'; -const double _topGradientHeight = 230; -const double _bottomGradientHeight = 390; +const double _topBarHeight = 58; 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; +const double _fullScreenPillBottom = 246; +const double _panelRadius = 16; -/// K28 竖屏播放页浮层。 +/// K29 竖屏播放页浮层。 class PlayerPlaybackOverlay extends StatelessWidget { /// 创建播放浮层。 const PlayerPlaybackOverlay({ @@ -61,6 +56,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { _top(context), _bottom(context), _rail(context), + _fullScreenPill(), ], if (!controlsVisible) Positioned( @@ -86,18 +82,14 @@ class PlayerPlaybackOverlay extends StatelessWidget { } 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), + height: _topBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -105,89 +97,42 @@ class PlayerPlaybackOverlay extends StatelessWidget { colors: [Color(0xCC000000), Color(0x00000000)], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( 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, + 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 SizedBox(width: 8), + Expanded( + child: Text( + data.l10n.episodeLabel(data.episode.number), + key: const Key('player-episode-heading'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, ), - 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, - ), - ], + _SpeedEntry( + key: const Key('player-speed-entry'), + speed: data.playbackSpeed, + onTap: actions.onSpeed, + ), + const SizedBox(width: 8), + _TopButton( + key: const Key('player-more'), + tooltip: data.l10n.more, + icon: Icons.more_vert_rounded, + onPressed: actions.onMore, ), ], ), @@ -196,18 +141,17 @@ class PlayerPlaybackOverlay extends StatelessWidget { } 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), + top: _topBarHeight + (compact ? 18 : 54), child: _Rail( l10n: data.l10n, liked: data.liked, saved: data.saved, - likeCount: likeCount, + likeCount: data.likeCount, + favoriteCount: data.favoriteCount, + commentCount: data.commentCount, onLike: actions.onLike, onComments: actions.onComments, onSave: actions.onSave, @@ -224,193 +168,185 @@ class PlayerPlaybackOverlay extends StatelessWidget { 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), - ], + child: Container( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + decoration: const BoxDecoration( + color: Color(0xED000000), + borderRadius: BorderRadius.vertical( + top: Radius.circular(_panelRadius), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(series), + const SizedBox(height: 6), + _EpisodeSynopsis( + key: const Key('player-episode-synopsis'), + l10n: data.l10n, + episodeNumber: data.episode.number, + synopsis: synopsis, + textScale: data.textScale, + ), + if (data.authorNote.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + data.authorNote, + key: const Key('player-author-note'), + style: const TextStyle(color: Colors.white60, fontSize: 11), + ), + ], + const SizedBox(height: 6), + _PlayerTimeLabel( + 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, + ), + _bottomActions(), + ], + ), + ), + ); + } + + Widget _detailRow(String series) { + return Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-open-detail'), + onTap: actions.onDetail, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + series, + key: const Key('player-drama-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.onPrimary, + fontFamily: 'Outfit', + fontSize: 19 * data.textScale, + fontWeight: FontWeight.w800, ), ), ), + const SizedBox(width: 6), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white70, + size: 20, + ), + ], + ), + ), + ), + ); + } + + Widget _bottomActions() { + return Row( + children: [ + Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-episodes'), + onTap: actions.onEpisodes, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Flexible( + child: Text( + '${data.l10n.episodes} · ' + '${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' + '${data.l10n.freeWatch}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_up_rounded, + color: AppColors.onPrimary, + size: 18, + ), + ], + ), + ), ), ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: _bottomGradientHeight), + ), + if (actions.onPip != null) ...[ + const SizedBox(width: 8), + _TopButton( + key: const Key('player-pip'), + tooltip: data.l10n.pictureInPicture, + icon: Icons.picture_in_picture_alt_rounded, + onPressed: actions.onPip!, + ), + ], + ], + ); + } + + Widget _fullScreenPill() { + return Positioned( + left: 0, + right: 0, + bottom: _fullScreenPillBottom, + child: Center( + child: Material( + color: const Color(0x99000000), + borderRadius: BorderRadius.circular(999), + child: InkWell( + key: const Key('player-fullscreen-toggle'), + borderRadius: BorderRadius.circular(999), + onTap: actions.onClear, child: Padding( - key: const Key('player-controls-bottom'), - padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), - child: Column( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + child: Row( 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 Icon( + Icons.fullscreen_rounded, + color: AppColors.onPrimary, + size: 16, ), - 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), + const SizedBox(width: 5), Text( - synopsis, - key: const Key('player-synopsis'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white70, - fontSize: 12 * data.textScale, - height: 1.45, + data.l10n.fullScreenWatch, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, ), ), - 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 index e5a1e15..873753c 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -16,11 +16,14 @@ class PlayerOverlayData { required this.controller, required this.liked, required this.likeCount, + required this.commentCount, + required this.favoriteCount, required this.saved, required this.playing, + required this.playbackSpeed, required this.textScale, - required this.currentIndex, required this.totalEpisodes, + this.authorNote = '', }); /// 当前语言包。 @@ -41,20 +44,29 @@ class PlayerOverlayData { /// 点赞数;未知时隐藏。 final int? likeCount; + /// 评论数;未知时隐藏。 + final int? commentCount; + + /// 收藏数;未知时隐藏。 + final int? favoriteCount; + /// 是否已收藏。 final bool saved; /// 是否处于播放状态。 final bool playing; + /// 当前倍速。 + final double playbackSpeed; + /// 信息文字缩放比例。 final double textScale; - /// 当前集索引。 - final int currentIndex; - /// 可见集数量。 final int totalEpisodes; + + /// 作者声明;没有后端字段时为空。 + final String authorNote; } /// 播放浮层动作集合。 @@ -65,6 +77,8 @@ class PlayerOverlayActions { required this.onTogglePlay, required this.onClear, required this.onExitClear, + required this.onDetail, + required this.onSpeed, required this.onEpisodes, required this.onComments, required this.onLike, @@ -73,7 +87,7 @@ class PlayerOverlayActions { required this.onMore, required this.onScrubStart, required this.onSeek, - this.onNext, + this.onPip, }); /// 返回上一页。 @@ -88,6 +102,12 @@ class PlayerOverlayActions { /// 退出清屏。 final VoidCallback onExitClear; + /// 打开详情。 + final VoidCallback onDetail; + + /// 打开顶部倍速入口。 + final VoidCallback onSpeed; + /// 打开选集。 final VoidCallback onEpisodes; @@ -106,12 +126,12 @@ class PlayerOverlayActions { /// 打开更多面板。 final VoidCallback onMore; - /// 下一集。 - final VoidCallback? onNext; - /// 开始拖动进度。 final VoidCallback? onScrubStart; /// 提交进度跳转。 final Future Function(Duration)? onSeek; + + /// 进入小窗播放。 + final VoidCallback? onPip; } diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 7a2ce76..f472365 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -22,8 +22,8 @@ class _TopButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.card, - foregroundColor: AppColors.onSurface, + backgroundColor: const Color(0x80000000), + foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, icon: Icon(icon, size: 18), @@ -32,12 +32,55 @@ class _TopButton extends StatelessWidget { } } +class _SpeedEntry extends StatelessWidget { + const _SpeedEntry({super.key, required this.speed, required this.onTap}); + + final double speed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0x80000000), + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.speed_rounded, + color: AppColors.onPrimary, + size: 15, + ), + const SizedBox(width: 4), + Text( + '${speed}x', + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + class _Rail extends StatelessWidget { const _Rail({ required this.l10n, required this.liked, required this.saved, required this.likeCount, + required this.favoriteCount, + required this.commentCount, required this.onLike, required this.onComments, required this.onSave, @@ -49,6 +92,8 @@ class _Rail extends StatelessWidget { final bool liked; final bool saved; final int? likeCount; + final int? favoriteCount; + final int? commentCount; final VoidCallback onLike; final VoidCallback onComments; final VoidCallback onSave; @@ -61,25 +106,26 @@ class _Rail extends StatelessWidget { 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, + key: const Key('player-rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: favoriteCount?.toString() ?? l10n.favorite, + active: saved, + onTap: onSave, compact: compact, ), _RailButton( key: const Key('player-rail-comments'), icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, + label: commentCount?.toString() ?? 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, + key: const Key('player-rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: likeCount?.toString() ?? l10n.like, + active: liked, + onTap: onLike, compact: compact, ), _RailButton( @@ -101,19 +147,19 @@ class _RailButton extends StatelessWidget { required this.label, required this.onTap, required this.compact, - this.color = AppColors.onPrimary, + this.active = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool compact; - final Color color; + final bool active; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: compact ? 2 : _railGap), + padding: EdgeInsets.only(bottom: compact ? 3 : _railGap), child: Column( children: [ SizedBox.square( @@ -122,8 +168,8 @@ class _RailButton extends StatelessWidget { onPressed: onTap, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: AppColors.overlayDark, - foregroundColor: color, + backgroundColor: const Color(0x80000000), + foregroundColor: active ? AppColors.blue : AppColors.onPrimary, ), icon: Icon(icon, size: 18), ), @@ -144,28 +190,78 @@ class _RailButton extends StatelessWidget { } } -class _PageDots extends StatelessWidget { - const _PageDots({super.key, required this.current, required this.total}); +class _EpisodeSynopsis extends StatefulWidget { + const _EpisodeSynopsis({ + super.key, + required this.l10n, + required this.episodeNumber, + required this.synopsis, + required this.textScale, + }); - final int current; - final int total; + final AppLocalizations l10n; + final int episodeNumber; + final String synopsis; + final double textScale; + + @override + State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState(); +} + +class _EpisodeSynopsisState extends State<_EpisodeSynopsis> { + bool _expanded = false; @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, + final canExpand = widget.synopsis.trim().length > 28; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, 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), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.l10n.episodeLabel(widget.episodeNumber), + key: const Key('player-episode-label'), + style: TextStyle( + color: AppColors.blue, + fontSize: 11 * widget.textScale, + fontWeight: FontWeight.w700, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('|', style: TextStyle(color: Colors.white38)), + ), + Expanded( + child: Text( + widget.synopsis, + key: const Key('player-synopsis'), + maxLines: _expanded ? 4 : 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, + fontSize: 11 * widget.textScale, + height: 1.45, + ), + ), + ), + ], + ), + if (canExpand) + GestureDetector( + key: const Key('player-synopsis-toggle'), + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _expanded ? widget.l10n.showLess : widget.l10n.showMore, + style: const TextStyle( + color: Colors.white54, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), ), ), ], @@ -173,8 +269,8 @@ class _PageDots extends StatelessWidget { } } -class _TimeLabel extends StatelessWidget { - const _TimeLabel({super.key, required this.controller}); +class _PlayerTimeLabel extends StatelessWidget { + const _PlayerTimeLabel({super.key, required this.controller}); final VideoPlayerController? controller; @@ -188,13 +284,15 @@ class _TimeLabel extends StatelessWidget { ); } - 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, + Widget _label(Duration position, Duration duration) => Align( + alignment: Alignment.centerRight, + child: Text( + '${_format(position)} / ${_format(duration)}', + style: const TextStyle( + color: Colors.white60, + fontSize: 9, + fontWeight: FontWeight.w600, + ), ), ); @@ -204,40 +302,3 @@ class _TimeLabel extends StatelessWidget { 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 index 7a42449..475ac5c 100644 --- a/lib/pages/player/widgets/player_speed_sheet.dart +++ b/lib/pages/player/widgets/player_speed_sheet.dart @@ -5,7 +5,7 @@ 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 List _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const double _sheetRadius = 20; /// 播放倍速选择弹窗。 diff --git a/lib/pages/player/widgets/player_text_scale.dart b/lib/pages/player/widgets/player_text_scale.dart new file mode 100644 index 0000000..1259f53 --- /dev/null +++ b/lib/pages/player/widgets/player_text_scale.dart @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +import '../../../l10n/app_localizations.dart'; + +/// 播放信息小号文字比例。 +const double playerTextSizeSmall = 0.9; + +/// 播放信息标准文字比例。 +const double playerTextSizeStandard = 1.0; + +/// 播放信息大号文字比例。 +const double playerTextSizeLarge = 1.15; + +/// 返回播放信息文字档位的本地化名称。 +String playerTextSizeLabel(AppLocalizations l10n, double scale) { + if (scale <= playerTextSizeSmall) return l10n.textSizeSmall; + if (scale >= playerTextSizeLarge) return l10n.textSizeLarge; + return l10n.textSizeStandard; +} diff --git a/lib/pages/player/widgets/player_text_size_sheet.dart b/lib/pages/player/widgets/player_text_size_sheet.dart deleted file mode 100644 index fa284f5..0000000 --- a/lib/pages/player/widgets/player_text_size_sheet.dart +++ /dev/null @@ -1,110 +0,0 @@ -// 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/quality_sheet.dart b/lib/pages/player/widgets/quality_sheet.dart deleted file mode 100644 index fcb6c50..0000000 --- a/lib/pages/player/widgets/quality_sheet.dart +++ /dev/null @@ -1,85 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../l10n/app_localizations.dart'; - -/// 清晰度档位面板:展示后端下发档位;HLS 多码率由播放器自动切换。 -class QualitySheet extends StatelessWidget { - const QualitySheet({ - super.key, - required this.qualities, - this.selected = autoQuality, - }); - - static const String autoQuality = 'AUTO'; - - final List qualities; - final String selected; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return SafeArea( - child: Column( - key: const Key('player-quality-sheet'), - mainAxisSize: MainAxisSize.min, - children: [ - _header(l10n), - for (final quality in qualities) _qualityTile(l10n, quality), - _hint(l10n), - ], - ), - ); - } - - Widget _header(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.playbackQuality, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } - - Widget _qualityTile(AppLocalizations l10n, String quality) { - final active = quality == selected; - return ListTile( - key: Key('player-quality-$quality'), - dense: true, - title: Text( - _label(l10n, quality), - style: TextStyle( - color: active ? AppColors.blue : AppColors.cream, - fontSize: 13, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, - ), - ), - trailing: active - ? const Icon(Icons.check_rounded, color: AppColors.blue, size: 18) - : null, - ); - } - - Widget _hint(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 14), - child: Text( - l10n.qualityAutoHint, - style: const TextStyle(color: AppColors.muted, fontSize: 11), - ), - ); - } - - String _label(AppLocalizations l10n, String quality) => - quality == autoQuality ? l10n.qualityAuto : quality; -} diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 5712889..933e16e 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('2 comments'), findsOneWidget); expect(repository.pageCalls.last.cursor, '10'); }); diff --git a/test/core/locale_controller_test.dart b/test/core/locale_controller_test.dart new file mode 100644 index 0000000..9b3905b --- /dev/null +++ b/test/core/locale_controller_test.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/locale_controller.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + test('should_restore_saved_locale_when_controller_loads', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + LocaleController.preferenceKey: 'zh', + }); + final controller = LocaleController(); + + await controller.load(); + + expect(controller.locale?.languageCode, 'zh'); + controller.dispose(); + }); + + test('should_persist_selected_locale_for_next_launch', () async { + final controller = LocaleController(); + await controller.select(const Locale('id')); + + final restored = LocaleController(); + await restored.load(); + + expect(restored.locale?.languageCode, 'id'); + controller.dispose(); + restored.dispose(); + }); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 5c638c1..2080cb0 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -32,7 +32,7 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index ede4e89..9a17708 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -86,20 +86,15 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_select_theater_language_without_filtering_catalog', ( - tester, - ) async { + testWidgets('should_not_render_theater_language_filters', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); - expect(_chip(tester, 'theater-language-all').selected, isTrue); - expect(_chip(tester, 'theater-language-id').selected, isFalse); - await tester.tap(find.byKey(const Key('theater-language-id'))); - await tester.pumpAndSettle(); - - expect(_chip(tester, 'theater-language-all').selected, isFalse); - expect(_chip(tester, 'theater-language-id').selected, isTrue); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); + expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('discover-refresh')), findsOneWidget); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsOneWidget, @@ -202,7 +197,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 4e7047d..62eb05a 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -60,11 +60,12 @@ void main() { Key('mine-creator-center'), Key('mine-notices'), Key('mine-settings'), + Key('mine-language'), Key('mine-help'), ]) { expect(find.byKey(key), findsOneWidget); } - expect(find.byKey(const Key('mine-language')), findsNothing); + expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7464da8..8c5dd9d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -40,14 +40,9 @@ void main() { expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); - 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-pip')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-quality'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-quality-sheet')), findsOneWidget); - expect(find.text('720P'), findsOneWidget); - await tester.binding.handlePopRoute(); + expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-close'))); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); @@ -111,7 +106,7 @@ void main() { expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -184,7 +179,7 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -208,14 +203,11 @@ void main() { 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-speed')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); - for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) { + for (final value in ['0.75', '1.0', '1.25', '1.5', '2.0', '3.0']) { expect(find.byKey(Key('player-speed-$value')), findsOneWidget); } @@ -223,11 +215,11 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsNothing); - await tester.tap(find.byKey(const Key('player-more'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect( find.descendant( - of: find.byKey(const Key('player-more-speed')), + of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, @@ -240,38 +232,22 @@ void main() { await _pumpApp(tester); await _openSeriesPlayer(tester); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-speed'))); + await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.tap(find.byKey(const Key('player-fullscreen-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-more')), findsNothing); await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - expect( - find.descendant( - of: find.byKey(const Key('player-more-speed')), - matching: find.text('1.5x'), - ), - findsOneWidget, - ); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(0, -550)); 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-speed')), + of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, @@ -341,25 +317,70 @@ void main() { 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-quality-AUTO')), findsOneWidget); + expect(find.byKey(const Key('player-more-offline')), findsOneWidget); + expect(find.byKey(const Key('player-more-clear')), findsOneWidget); + expect(find.byKey(const Key('player-more-danmaku')), findsOneWidget); + expect( + find.byKey(const Key('player-more-danmaku-settings')), + findsOneWidget, + ); + expect(find.byKey(const Key('player-more-cast')), findsOneWidget); + expect(find.byKey(const Key('player-more-timer')), 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.tap(find.byTooltip('Text size')); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-text-size-sheet')), findsOneWidget); + expect(find.byKey(const Key('player-text-size-large')), findsOneWidget); await tester.tap(find.byKey(const Key('player-text-size-large'))); await tester.pumpAndSettle(); + expect(find.text('Large'), findsOneWidget); + }); + + testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { + await _pumpApp( + tester, + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/city-lights-demo-1.mp4', + qualities: ['AUTO'], + ), + ), + ); + await _openSeriesPlayer(tester); 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, + tester + .widget(find.byKey(const Key('player-more-quality-AUTO'))) + .onSelected, + isNotNull, ); + expect( + tester + .widget(find.byKey(const Key('player-more-quality-720P'))) + .onSelected, + isNull, + ); + expect(find.text('720P · Unavailable'), findsOneWidget); + }); + + testWidgets('should_enter_clear_screen_from_more_panel', (tester) async { + await _pumpApp(tester); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-clear'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); }); testWidgets('should_open_unlock_panel_with_honest_method_states', ( @@ -395,14 +416,10 @@ void main() { 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-episode-heading')), findsOneWidget); + expect(find.byKey(const Key('player-speed-entry')), 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); @@ -412,8 +429,14 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); - expect(find.byKey(const Key('player-next')), findsOneWidget); + expect(find.byKey(const Key('player-fullscreen-toggle')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsNothing); + expect(find.byKey(const Key('player-subtitle')), findsNothing); + expect(find.byKey(const Key('player-genre-chip')), findsNothing); + expect(find.byKey(const Key('player-page-dots')), findsNothing); + expect(find.byKey(const Key('player-clear-screen')), findsNothing); + expect(find.byKey(const Key('player-next')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-rail-like')), findsOneWidget); diff --git a/test/player/quality_sheet_test.dart b/test/player/quality_sheet_test.dart deleted file mode 100644 index 8ff9f6f..0000000 --- a/test/player/quality_sheet_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart'; - -void main() { - testWidgets('should_list_every_returned_quality_when_sheet_opens', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - body: QualitySheet(qualities: ['AUTO', '720P', '1080P']), - ), - ), - ); - - expect(find.text('Auto'), findsOneWidget); - expect(find.text('720P'), findsOneWidget); - expect(find.text('1080P'), findsOneWidget); - expect(find.byIcon(Icons.check_rounded), findsOneWidget); - expect( - find.text('HLS adjusts the bitrate automatically for your connection.'), - findsOneWidget, - ); - }); -} diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index aef7233..31a3fd7 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -42,9 +42,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -520)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Watch History')); + await tester.tap(find.byKey(const Key('mine-history'))); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); diff --git a/test/widget_test.dart b/test/widget_test.dart index 709f47c..40b6905 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -72,7 +72,7 @@ void main() { await tester.pumpAndSettle(); expect( - tester.widget(find.byKey(const Key('player-title'))).data, + tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); expect(created, hasLength(3)); @@ -135,9 +135,7 @@ void main() { ); }); - testWidgets('should_follow_system_locale_without_language_switch', ( - tester, - ) async { + testWidgets('should_show_language_entry_on_mine_page', (tester) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); expect(find.byTooltip('Captions'), findsNothing); @@ -146,7 +144,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); - expect(find.text('App Language'), findsNothing); + expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-theater'))); @@ -154,6 +152,30 @@ void main() { expect(find.text('Theater'), findsWidgets); }); + testWidgets('should_switch_app_language_from_mine_menu', (tester) async { + await tester.pumpWidget(appUnderTest()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('mine-page-scroll')), + const Offset(0, -320), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-language'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-language-sheet')), findsOneWidget); + await tester.tap(find.byKey(const Key('mine-language-zh'))); + await tester.pumpAndSettle(); + + expect(find.text('我的'), findsWidgets); + expect(find.text('语言'), findsOneWidget); + await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.pumpAndSettle(); + expect(find.text('剧场'), findsWidgets); + }); + testWidgets('should_fit_compact_viewport_when_player_is_opened', ( tester, ) async { @@ -170,7 +192,7 @@ void main() { await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); expect( - tester.widget(find.byKey(const Key('player-title'))).data, + tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); expect(tester.takeException(), isNull); @@ -191,7 +213,7 @@ void main() { expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsOneWidget); + expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(tester.takeException(), isNull);