merge: 合并 K29 播放页与语言切换

This commit is contained in:
Zhou Weidong committed 2026-10-20 11:01:55 +08:00
commit c4ad5d72d7
34 files changed
+1331 -879

No files matched your search

+26 -17
View File
@@ -40,6 +40,7 @@ import 'core/data/services/category_controller.dart';
import 'core/data/services/download_controller.dart'; import 'core/data/services/download_controller.dart';
import 'core/data/services/favorite_controller.dart'; import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_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/playback_session.dart';
import 'core/data/services/video_player_port.dart'; import 'core/data/services/video_player_port.dart';
import 'core/data/services/wallet_controller.dart'; import 'core/data/services/wallet_controller.dart';
@@ -175,11 +176,14 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
late final InteractionController _interactionController = late final InteractionController _interactionController =
InteractionController(_interactionRepository); InteractionController(_interactionRepository);
late final LocaleController _localeController = LocaleController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load(); _auth.load();
unawaited(_localeController.load());
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged); _auth.addListener(_onAuthChanged);
if (_auth.isSignedIn) unawaited(_favoriteController.load()); if (_auth.isSignedIn) unawaited(_favoriteController.load());
@@ -210,6 +214,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
_downloadController.dispose(); _downloadController.dispose();
_walletController.dispose(); _walletController.dispose();
_interactionController.dispose(); _interactionController.dispose();
_localeController.dispose();
_apiClient.close(); _apiClient.close();
super.dispose(); super.dispose();
} }
@@ -242,27 +247,31 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _walletController),
ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _localeController),
ChangeNotifierProvider.value(value: _auth), ChangeNotifierProvider.value(value: _auth),
], ],
child: MaterialApp( child: Consumer<LocaleController>(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, builder: (context, localeController, _) => MaterialApp(
supportedLocales: AppLocalizations.supportedLocales, locale: localeController.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates, onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
localeResolutionCallback: (deviceLocale, _) => supportedLocales: AppLocalizations.supportedLocales,
resolveAppLocale(deviceLocale), localizationsDelegates: AppLocalizations.localizationsDelegates,
debugShowCheckedModeBanner: false, localeResolutionCallback: (deviceLocale, _) =>
themeMode: ThemeMode.light, resolveAppLocale(deviceLocale),
theme: AppTheme.light(), debugShowCheckedModeBanner: false,
darkTheme: AppTheme.light(), themeMode: ThemeMode.light,
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>( theme: AppTheme.light(),
value: SystemUiOverlayStyle.dark.copyWith( darkTheme: AppTheme.light(),
statusBarColor: Colors.transparent, builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
systemNavigationBarColor: AppColors.bar, value: SystemUiOverlayStyle.dark.copyWith(
systemNavigationBarIconBrightness: Brightness.dark, 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(),
), ),
); );
} }
@@ -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<String> supportedCodes = {'zh', 'en', 'id'};
final SharedPreferencesAsync _preferences;
Locale? _locale;
bool _disposed = false;
/// 用户选择的语言;为空时由 MaterialApp 跟随系统。
Locale? get locale => _locale;
/// 当前语言的展示代码。
String get languageCode => _locale?.languageCode ?? 'system';
/// 读取本地语言选择;未设置时保持跟随系统。
Future<void> load() async {
final code = await _preferences.getString(preferenceKey);
if (code == null || !supportedCodes.contains(code)) return;
_locale = Locale(code);
_notify();
}
/// 立即切换并持久化语言。
Future<void> 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();
}
}
+15 -2
View File
@@ -581,7 +581,7 @@
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "{count} episodes", "episodeCountLabel": "{count} episodes",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "Comments ({count})", "commentsWithCount": "{count} comments",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "Text size", "textSize": "Text size",
"textSizeSmall": "Small", "textSizeSmall": "Small",
@@ -590,5 +590,18 @@
"watchAdToUnlock": "Watch an ad to unlock", "watchAdToUnlock": "Watch an ad to unlock",
"unlockWithVip": "Unlock with VIP", "unlockWithVip": "Unlock with VIP",
"unlockMethodsVary": "Available unlock methods vary by title.", "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"
} }
+15 -2
View File
@@ -579,7 +579,7 @@
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "{count} episode", "episodeCountLabel": "{count} episode",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "Komentar ({count})", "commentsWithCount": "{count} komentar",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "Ukuran teks", "textSize": "Ukuran teks",
"textSizeSmall": "Kecil", "textSizeSmall": "Kecil",
@@ -588,5 +588,18 @@
"watchAdToUnlock": "Tonton iklan untuk membuka", "watchAdToUnlock": "Tonton iklan untuk membuka",
"unlockWithVip": "Buka dengan VIP", "unlockWithVip": "Buka dengan VIP",
"unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.", "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"
} }
+79 -1
View File
@@ -2581,7 +2581,7 @@ abstract class AppLocalizations {
/// No description provided for @commentsWithCount. /// No description provided for @commentsWithCount.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Comments ({count})'** /// **'{count} comments'**
String commentsWithCount(int count); String commentsWithCount(int count);
/// No description provided for @textSize. /// No description provided for @textSize.
@@ -2631,6 +2631,84 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlock this episode'** /// **'Unlock this episode'**
String get unlockEpisode; 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 class _AppLocalizationsDelegate
+40 -1
View File
@@ -1338,7 +1338,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String commentsWithCount(int count) { String commentsWithCount(int count) {
return 'Comments ($count)'; return '$count comments';
} }
@override @override
@@ -1364,4 +1364,43 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get unlockEpisode => 'Unlock this episode'; 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';
} }
+40 -1
View File
@@ -1337,7 +1337,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String commentsWithCount(int count) { String commentsWithCount(int count) {
return 'Komentar ($count)'; return '$count komentar';
} }
@override @override
@@ -1364,4 +1364,43 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get unlockEpisode => 'Buka episode ini'; 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';
} }
+40 -1
View File
@@ -1314,7 +1314,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String commentsWithCount(int count) { String commentsWithCount(int count) {
return '评论($count)'; return '$count 条评论';
} }
@override @override
@@ -1340,4 +1340,43 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get unlockEpisode => '解锁本集'; 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 => '定时关闭';
} }
+15 -2
View File
@@ -541,7 +541,7 @@
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }, "@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "共 {count} 集", "episodeCountLabel": "共 {count} 集",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } }, "@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "评论({count})", "commentsWithCount": "{count} 条评论",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } }, "@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "文字大小", "textSize": "文字大小",
"textSizeSmall": "小", "textSizeSmall": "小",
@@ -550,5 +550,18 @@
"watchAdToUnlock": "看广告解锁", "watchAdToUnlock": "看广告解锁",
"unlockWithVip": "会员解锁", "unlockWithVip": "会员解锁",
"unlockMethodsVary": "具体解锁方式以作品而定。", "unlockMethodsVary": "具体解锁方式以作品而定。",
"unlockEpisode": "解锁本集" "unlockEpisode": "解锁本集",
"language": "语言",
"fullScreenWatch": "全屏观看",
"freeWatch": "免费观看",
"showMore": "展开",
"qualityUnavailable": "暂不可用",
"notInterested": "不感兴趣",
"clearPlayback": "清屏播放",
"danmaku": "弹幕",
"danmakuUnavailable": "暂未开放",
"danmakuSettings": "弹幕设置",
"offlineCache": "离线缓存",
"screenCast": "投屏",
"sleepTimer": "定时关闭"
} }
-8
View File
@@ -28,7 +28,6 @@ class DiscoverLogic extends ChangeNotifier {
String _lastRequestedQuery = ''; String _lastRequestedQuery = '';
int _searchRequest = 0; int _searchRequest = 0;
bool _disposed = false; bool _disposed = false;
TheaterLanguage language = TheaterLanguage.all;
String? selectedCategoryId; String? selectedCategoryId;
bool get hasSearchQuery => search.text.trim().isNotEmpty; bool get hasSearchQuery => search.text.trim().isNotEmpty;
@@ -50,13 +49,6 @@ class DiscoverLogic extends ChangeNotifier {
void openSearch() => searchFocus.requestFocus(); void openSearch() => searchFocus.requestFocus();
/// 选择语言;当前仅改变视觉选中态,不参与数据过滤。
void selectLanguage(TheaterLanguage value) {
if (language == value) return;
language = value;
notify();
}
/// 选择分类;空值表示全部。 /// 选择分类;空值表示全部。
void selectCategory(String? categoryId) { void selectCategory(String? categoryId) {
if (selectedCategoryId == categoryId) return; if (selectedCategoryId == categoryId) return;
-3
View File
@@ -7,9 +7,6 @@ import '../../core/data/models/drama.dart';
/// 剧场页与根导航之间的动作。 /// 剧场页与根导航之间的动作。
enum DiscoverPageAction { home, create, my } enum DiscoverPageAction { home, create, my }
/// 剧场语言筛选的视觉选中态。
enum TheaterLanguage { all, zh, id, ms, en }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks { class DiscoverPageCallbacks {
const DiscoverPageCallbacks({ const DiscoverPageCallbacks({
-33
View File
@@ -175,7 +175,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
: categories.dramasOf(logic.selectedCategoryId!); : categories.dramasOf(logic.selectedCategoryId!);
return [ return [
_categoryChips(l10n, categories), _categoryChips(l10n, categories),
_languageChips(l10n),
if (dramas.isEmpty) if (dramas.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 40), padding: const EdgeInsets.symmetric(vertical: 40),
@@ -222,38 +221,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
} }
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({ Widget _filterChip({
required Key key, required Key key,
required String label, required String label,
+96 -1
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/locale_controller.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
@@ -122,7 +123,9 @@ class _MyPageState extends State<MyPage> {
Widget _body(BuildContext context, AppLocalizations l10n) { Widget _body(BuildContext context, AppLocalizations l10n) {
final auth = context.watch<AuthController>(); final auth = context.watch<AuthController>();
final wallet = context.watch<WalletController>(); final wallet = context.watch<WalletController>();
final locale = context.watch<LocaleController>();
return ListView( return ListView(
key: const Key('mine-page-scroll'),
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal, AppDimens.pageHorizontal,
0, 0,
@@ -142,7 +145,11 @@ class _MyPageState extends State<MyPage> {
const SizedBox(height: AppDimens.spaceMd), const SizedBox(height: AppDimens.spaceMd),
MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), MineVipCard(onOpen: () => _openDestination(MineDestination.vip)),
const SizedBox(height: AppDimens.spaceMd), 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<MyPage> {
_openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); _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<void> _openLanguage() async {
final selected = await showModalBottomSheet<Locale>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) =>
_LanguageSheet(selected: context.read<LocaleController>().locale),
);
if (selected == null || !mounted) return;
await context.read<LocaleController>().select(selected);
}
void _openRecharge() { void _openRecharge() {
final wallet = context.read<WalletController>(); final wallet = context.read<WalletController>();
_openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load())));
@@ -254,6 +282,73 @@ class _MyPageState extends State<MyPage> {
} }
} }
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 } enum MinePlaceholderKind { works, creator, help }
class _MinePlaceholderPage extends StatelessWidget { class _MinePlaceholderPage extends StatelessWidget {
+47 -1
View File
@@ -265,9 +265,16 @@ class MineVipCard extends StatelessWidget {
/// 我的页功能菜单。 /// 我的页功能菜单。
class MineMenuList 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<MineDestination> onOpen; final ValueChanged<MineDestination> onOpen;
final VoidCallback onLanguage;
final String languageLabel;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -313,6 +320,8 @@ class MineMenuList extends StatelessWidget {
destination: MineDestination.settings, destination: MineDestination.settings,
), ),
_divider(), _divider(),
_languageRow(context),
_divider(),
_row( _row(
key: const Key('mine-help'), key: const Key('mine-help'),
icon: Icons.help_outline_rounded, 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({ Widget _row({
required Key key, required Key key,
required IconData icon, required IconData icon,
+13 -7
View File
@@ -32,9 +32,7 @@ import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart'; import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart'; import 'widgets/player_overlay_models.dart';
import 'widgets/player_speed_sheet.dart'; import 'widgets/player_speed_sheet.dart';
import 'widgets/player_text_size_sheet.dart';
import 'widgets/player_unlock_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/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/playback_repository.dart';
@@ -86,7 +84,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic; late final PlayerLogic logic;
bool _chromeVisible = true; bool _chromeVisible = true;
double _playbackSpeed = 1.0; double _playbackSpeed = 1.0;
double _textScale = playerTextSizeStandard; double _textScale = 1.0;
@override @override
void initState() { void initState() {
@@ -132,6 +130,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
setState(() => _textScale = value); setState(() => _textScale = value);
} }
void _hideChrome() {
setState(() => _chromeVisible = false);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
@@ -403,10 +405,12 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
controller: ready ? videoPort!.controller : null, controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id), liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id), likeCount: interactions.likeCount(drama.id),
commentCount: null,
favoriteCount: null,
saved: context.watch<FavoriteController>().isSaved(drama.id), saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay, playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
textScale: _textScale, textScale: _textScale,
currentIndex: logic.session.currentIndex,
totalEpisodes: logic.visibleIndices.length, totalEpisodes: logic.visibleIndices.length,
), ),
actions: PlayerOverlayActions( actions: PlayerOverlayActions(
@@ -417,16 +421,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
logic.session.setUserWantsPlay(!logic.session.userWantsPlay), logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
onClear: () => setState(() => _chromeVisible = false), onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true), onExitClear: () => setState(() => _chromeVisible = true),
onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
onComments: () => logic.showComments(context, drama), onComments: () => logic.showComments(context, drama),
onLike: () => onLike: () =>
unawaited(_toggleLike(context, interactions, drama)), unawaited(_toggleLike(context, interactions, drama)),
onSave: () => unawaited(_toggleSave(context, drama)), onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama), onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, drama, episode), onMore: () => _openMore(context, drama, episode),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position) ? (position) => logic.seekTo(context, videoPort!, position)
+6 -27
View File
@@ -15,15 +15,6 @@ extension _PlayerViewActions on _PlayerPageState {
await logic.setPlaybackSpeed(selected); await logic.setPlaybackSpeed(selected);
} }
Future<void> _openQualitySheet(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (_) => QualitySheet(qualities: logic.qualities),
);
}
Future<void> _openMore( Future<void> _openMore(
BuildContext context, BuildContext context,
Drama drama, Drama drama,
@@ -41,33 +32,21 @@ extension _PlayerViewActions on _PlayerPageState {
speed: _playbackSpeed, speed: _playbackSpeed,
qualities: logic.qualities, qualities: logic.qualities,
textScale: _textScale, textScale: _textScale,
pipAvailable: logic.pictureInPicture.isPlatformSupported,
), ),
actions: PlayerMoreActions( actions: PlayerMoreActions(
onSpeed: () => unawaited(_openSpeedSheet(context)), onSpeedChanged: (value) {
onQuality: () => unawaited(_openQualitySheet(context)), _updatePlaybackSpeed(value);
unawaited(logic.setPlaybackSpeed(value));
},
onTextScaleChanged: _updateTextScale,
onClear: _hideChrome,
onDownload: () => requestEpisodeDownload(context, episode.id), onDownload: () => requestEpisodeDownload(context, episode.id),
onTextSize: () => unawaited(_openTextSizeSheet(context)),
onReport: () => unawaited(_reportDrama(context, drama, l10n)), onReport: () => unawaited(_reportDrama(context, drama, l10n)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
), ),
), ),
); );
} }
Future<void> _openTextSizeSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) => PlayerTextSizeSheet(selected: _textScale),
);
if (selected == null || !mounted) return;
_updateTextScale(selected);
}
Future<void> _openUnlockSheet( Future<void> _openUnlockSheet(
BuildContext context, BuildContext context,
int index, int index,
+34 -9
View File
@@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/comment.dart'; import '../../../core/data/models/comment.dart';
import '../../../core/data/services/comment_controller.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_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const String _deleteAction = 'delete'; const String _deleteAction = 'delete';
@@ -53,15 +55,7 @@ class CommentTile extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( _avatar(),
radius: isReply ? 12 : 16,
backgroundColor: AppColors.raised,
child: Icon(
Icons.person_rounded,
size: isReply ? 14 : 18,
color: AppColors.cream,
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( 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<String>( PopupMenuButton<String>(
key: Key('comment-menu-${comment.id}'), key: Key('comment-menu-${comment.id}'),
tooltip: l10n.more, 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),
),
);
}
} }
+264 -102
View File
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'player_text_size_sheet.dart'; import 'player_text_scale.dart';
import 'quality_sheet.dart';
const double _sheetRadius = 20; const double _sheetRadius = 20;
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
const List<String> _qualityOptions = ['AUTO', '720P', '1080P'];
const String _autoQuality = 'AUTO';
/// 更多面板运行状态。 /// 更多面板运行状态。
class PlayerMoreState { class PlayerMoreState {
@@ -16,7 +18,6 @@ class PlayerMoreState {
required this.speed, required this.speed,
required this.qualities, required this.qualities,
required this.textScale, required this.textScale,
required this.pipAvailable,
}); });
/// 当前倍速。 /// 当前倍速。
@@ -25,47 +26,40 @@ class PlayerMoreState {
/// 后端下发的清晰度档位。 /// 后端下发的清晰度档位。
final List<String> qualities; final List<String> qualities;
/// 当前信息文字缩放比例。 /// 当前文字缩放比例。
final double textScale; final double textScale;
/// 设备是否提供画中画入口。
final bool pipAvailable;
} }
/// 更多面板动作集合。 /// 更多面板动作集合。
class PlayerMoreActions { class PlayerMoreActions {
/// 创建动作集合。 /// 创建动作集合。
const PlayerMoreActions({ const PlayerMoreActions({
required this.onSpeed, required this.onSpeedChanged,
required this.onQuality, required this.onTextScaleChanged,
required this.onClear,
required this.onDownload, required this.onDownload,
required this.onTextSize,
required this.onReport, required this.onReport,
this.onPip,
}); });
/// 打开倍速面板。 /// 倍速变化。
final VoidCallback onSpeed; final ValueChanged<double> onSpeedChanged;
/// 打开清晰度面板。 /// 字体大小变化。
final VoidCallback onQuality; final ValueChanged<double> onTextScaleChanged;
/// 下载当前集。 /// 进入清屏播放。
final VoidCallback onClear;
/// 离线缓存当前集。
final VoidCallback onDownload; final VoidCallback onDownload;
/// 打开文字大小面板。 /// 投诉当前剧。
final VoidCallback onTextSize;
/// 举报当前剧。
final VoidCallback onReport; final VoidCallback onReport;
/// 进入画中画。
final VoidCallback? onPip;
} }
/// K28 播放页更多操作面板。 /// K29 播放页白色更多面板。
class PlayerMoreSheet extends StatelessWidget { class PlayerMoreSheet extends StatefulWidget {
/// 创建更多操作面板。 /// 创建更多面板。
const PlayerMoreSheet({ const PlayerMoreSheet({
super.key, super.key,
required this.l10n, required this.l10n,
@@ -82,78 +76,82 @@ class PlayerMoreSheet extends StatelessWidget {
/// 面板动作。 /// 面板动作。
final PlayerMoreActions actions; final PlayerMoreActions actions;
@override
State<PlayerMoreSheet> createState() => _PlayerMoreSheetState();
}
class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
late double _speed = widget.state.speed;
late double _textScale = widget.state.textScale;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = widget.l10n;
return Container( return Container(
key: const Key('player-more-sheet'), key: const Key('player-more-sheet'),
constraints: const BoxConstraints(maxHeight: 620),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: AppColors.card, color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( _header(l10n),
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), _sectionTitle(l10n.playbackSpeed),
child: Text( _speedPicker(),
l10n.more, const SizedBox(height: 16),
style: const TextStyle( _sectionTitle(l10n.playbackQuality),
color: AppColors.onSurface, _qualityPicker(l10n),
fontSize: 20, const SizedBox(height: 12),
fontWeight: FontWeight.w800, _disabledRow(
), key: const Key('player-more-not-interested'),
), icon: Icons.thumb_down_alt_outlined,
title: l10n.notInterested,
), ),
_MoreRow( _enabledRow(
key: const Key('player-more-speed'), key: const Key('player-more-clear'),
icon: Icons.speed_rounded, icon: Icons.fullscreen_rounded,
title: l10n.playbackSpeed, title: l10n.clearPlayback,
value: '${state.speed}x', onTap: _clear,
action: actions.onSpeed,
), ),
if (state.qualities.isNotEmpty) _disabledSwitchRow(
_MoreRow( key: const Key('player-more-danmaku'),
key: const Key('player-more-quality'), icon: Icons.subtitles_rounded,
icon: Icons.high_quality_rounded, title: l10n.danmaku,
title: l10n.playbackQuality, unavailable: l10n.danmakuUnavailable,
value: state.qualities.contains(QualitySheet.autoQuality) ),
? l10n.qualityAuto _disabledRow(
: state.qualities.first, key: const Key('player-more-danmaku-settings'),
action: actions.onQuality, icon: Icons.tune_rounded,
), title: l10n.danmakuSettings,
_MoreRow( ),
key: const Key('player-more-download'), _enabledRow(
key: const Key('player-more-offline'),
icon: Icons.download_rounded, icon: Icons.download_rounded,
title: l10n.downloads, title: l10n.offlineCache,
value: '', onTap: () => _closeThen(widget.actions.onDownload),
action: actions.onDownload,
), ),
_MoreRow( _textSizeRow(l10n),
key: const Key('player-more-text-size'), _disabledRow(
icon: Icons.format_size_rounded, key: const Key('player-more-cast'),
title: l10n.textSize, icon: Icons.cast_rounded,
value: playerTextSizeLabel(l10n, state.textScale), title: l10n.screenCast,
action: actions.onTextSize,
), ),
if (state.pipAvailable && actions.onPip != null) _disabledRow(
_MoreRow( key: const Key('player-more-timer'),
key: const Key('player-more-pip'), icon: Icons.timer_outlined,
icon: Icons.picture_in_picture_alt_rounded, title: l10n.sleepTimer,
title: l10n.pictureInPicture, ),
value: '', _enabledRow(
action: actions.onPip!,
),
_MoreRow(
key: const Key('player-more-report'), key: const Key('player-more-report'),
icon: Icons.flag_outlined, icon: Icons.flag_outlined,
title: l10n.reportDrama, title: l10n.reportDrama,
value: '', onTap: () => _closeThen(widget.actions.onReport),
action: 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<double>(
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 { class _MoreRow extends StatelessWidget {
@@ -168,57 +337,50 @@ class _MoreRow extends StatelessWidget {
super.key, super.key,
required this.icon, required this.icon,
required this.title, required this.title,
required this.value, required this.enabled,
required this.action, this.value = '',
this.trailing,
this.onTap,
}); });
final IconData icon; final IconData icon;
final String title; final String title;
final bool enabled;
final String value; final String value;
final VoidCallback action; final Widget? trailing;
final VoidCallback? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = enabled ? AppColors.onSurface : AppColors.subtle;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
onTap: () { onTap: enabled ? onTap : null,
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) => action());
},
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
child: Row( child: Row(
children: [ children: [
Icon(icon, color: AppColors.muted, size: 21), Icon(icon, color: color, size: 21),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
title, title,
style: const TextStyle( style: TextStyle(
color: AppColors.onSurface, color: color,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w600,
), ),
), ),
), ),
if (value.isNotEmpty) if (value.isNotEmpty)
Text( Text(
value, value,
style: const TextStyle( style: const TextStyle(color: AppColors.subtle, fontSize: 11),
color: AppColors.blue,
fontSize: 12,
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(width: 4), ?trailing,
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
),
], ],
), ),
), ),
+205 -269
View File
@@ -14,19 +14,14 @@ import 'seek_progress_bar.dart';
part 'player_overlay_parts.dart'; part 'player_overlay_parts.dart';
const double _topGradientHeight = 230; const double _topBarHeight = 58;
const double _bottomGradientHeight = 390;
const double _topButtonSize = 36; const double _topButtonSize = 36;
const double _titleSize = 24;
const double _dotSize = 7;
const double _railButtonSize = 36; const double _railButtonSize = 36;
const double _railGap = 9; const double _railGap = 9;
const double _chipRadius = 999; const double _fullScreenPillBottom = 246;
const double _dotRadius = 3; const double _panelRadius = 16;
const double _railTopFactor = 0.28;
const double _railTopCompactFactor = 0.18;
/// K28 竖屏播放页浮层。 /// K29 竖屏播放页浮层。
class PlayerPlaybackOverlay extends StatelessWidget { class PlayerPlaybackOverlay extends StatelessWidget {
/// 创建播放浮层。 /// 创建播放浮层。
const PlayerPlaybackOverlay({ const PlayerPlaybackOverlay({
@@ -61,6 +56,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
_top(context), _top(context),
_bottom(context), _bottom(context),
_rail(context), _rail(context),
_fullScreenPill(),
], ],
if (!controlsVisible) if (!controlsVisible)
Positioned( Positioned(
@@ -86,18 +82,14 @@ class PlayerPlaybackOverlay extends StatelessWidget {
} }
Widget _top(BuildContext context) { 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( return Positioned(
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: Container(
key: const Key('player-controls-top'), key: const Key('player-controls-top'),
height: _topGradientHeight, height: _topBarHeight,
padding: const EdgeInsets.fromLTRB(14, 4, 14, 20), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
@@ -105,89 +97,42 @@ class PlayerPlaybackOverlay extends StatelessWidget {
colors: [Color(0xCC000000), Color(0x00000000)], colors: [Color(0xCC000000), Color(0x00000000)],
), ),
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( if (actions.onBack != null)
children: [ _TopButton(
if (actions.onBack != null) key: const Key('player-back'),
_TopButton( tooltip: data.l10n.backToSeries,
key: const Key('player-back'), icon: Icons.chevron_left_rounded,
tooltip: data.l10n.backToSeries, onPressed: actions.onBack!,
icon: Icons.chevron_left_rounded, )
onPressed: actions.onBack!, else
) const SizedBox(width: _topButtonSize),
else const SizedBox(width: 8),
const SizedBox(width: _topButtonSize), Expanded(
const Spacer(), child: Text(
_TopButton( data.l10n.episodeLabel(data.episode.number),
key: const Key('player-clear-screen'), key: const Key('player-episode-heading'),
tooltip: data.l10n.clearScreen, maxLines: 1,
icon: Icons.fullscreen_rounded, overflow: TextOverflow.ellipsis,
onPressed: actions.onClear, 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), _SpeedEntry(
Row( key: const Key('player-speed-entry'),
children: [ speed: data.playbackSpeed,
if (_genre.isNotEmpty) onTap: actions.onSpeed,
_infoChip(const Key('player-genre-chip'), _genre), ),
const Spacer(), const SizedBox(width: 8),
_PageDots( _TopButton(
key: const Key('player-page-dots'), key: const Key('player-more'),
current: data.currentIndex, tooltip: data.l10n.more,
total: data.totalEpisodes, icon: Icons.more_vert_rounded,
), onPressed: actions.onMore,
],
), ),
], ],
), ),
@@ -196,18 +141,17 @@ class PlayerPlaybackOverlay extends StatelessWidget {
} }
Widget _rail(BuildContext context) { Widget _rail(BuildContext context) {
final likeCount = data.likeCount;
final compact = MediaQuery.sizeOf(context).height < 700; final compact = MediaQuery.sizeOf(context).height < 700;
return Positioned( return Positioned(
right: AppDimens.spaceMd, right: AppDimens.spaceMd,
top: top: _topBarHeight + (compact ? 18 : 54),
MediaQuery.sizeOf(context).height *
(compact ? _railTopCompactFactor : _railTopFactor),
child: _Rail( child: _Rail(
l10n: data.l10n, l10n: data.l10n,
liked: data.liked, liked: data.liked,
saved: data.saved, saved: data.saved,
likeCount: likeCount, likeCount: data.likeCount,
favoriteCount: data.favoriteCount,
commentCount: data.commentCount,
onLike: actions.onLike, onLike: actions.onLike,
onComments: actions.onComments, onComments: actions.onComments,
onSave: actions.onSave, onSave: actions.onSave,
@@ -224,193 +168,185 @@ class PlayerPlaybackOverlay extends StatelessWidget {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Stack( child: Container(
children: [ key: const Key('player-controls-bottom'),
const Positioned.fill( padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
child: IgnorePointer( decoration: const BoxDecoration(
child: DecoratedBox( color: Color(0xED000000),
decoration: BoxDecoration( borderRadius: BorderRadius.vertical(
gradient: LinearGradient( top: Radius.circular(_panelRadius),
begin: Alignment.topCenter, ),
end: Alignment.bottomCenter, ),
colors: [ child: Column(
Color(0x00000000), mainAxisSize: MainAxisSize.min,
Color(0xDD000000), crossAxisAlignment: CrossAxisAlignment.start,
Color(0xF2000000), 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( child: Padding(
key: const Key('player-controls-bottom'), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
padding: const EdgeInsets.fromLTRB(14, 48, 14, 10), child: Row(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Icon(
children: [ Icons.fullscreen_rounded,
Flexible( color: AppColors.onPrimary,
child: Text( size: 16,
series,
key: const Key('player-drama-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
fontFamily: 'Outfit',
fontSize: 22 * data.textScale,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 8),
Text(
data.l10n.episodeLabel(data.episode.number),
key: const Key('player-episode-label'),
style: TextStyle(
color: AppColors.blue,
fontSize: 12 * data.textScale,
fontWeight: FontWeight.w700,
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(width: 5),
Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _genre.isEmpty
? const SizedBox.shrink()
: _softChip(_genre),
),
),
const SizedBox(width: 8),
SizedBox(
height: AppDimens.buttonSmall,
child: FilledButton(
key: const Key('player-follow'),
onPressed: actions.onSave,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: Text(
data.saved
? data.l10n.followingTab
: data.l10n.followSeries,
),
),
),
],
),
const SizedBox(height: 8),
Text( Text(
synopsis, data.l10n.fullScreenWatch,
key: const Key('player-synopsis'), style: const TextStyle(
maxLines: 2, color: AppColors.onPrimary,
overflow: TextOverflow.ellipsis, fontSize: 12,
style: TextStyle( fontWeight: FontWeight.w700,
color: Colors.white70,
fontSize: 12 * data.textScale,
height: 1.45,
), ),
), ),
const SizedBox(height: 4),
_TimeLabel(
key: const Key('player-time-label'),
controller: data.controller,
),
SeekProgressBar(
key: const Key('player-progress'),
valueListenable: data.controller,
semanticsLabel: data.l10n.seekVideo,
showTimes: true,
compact: true,
onScrubStart: actions.onScrubStart,
onSeek: actions.onSeek,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: _ActionButton(
key: const Key('player-episodes'),
icon: Icons.grid_view_rounded,
label: data.l10n.episodes,
onTap: actions.onEpisodes,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
key: const Key('player-next'),
icon: Icons.skip_next_rounded,
label: data.l10n.nextEpisode,
onTap: actions.onNext,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
key: const Key('player-bottom-more'),
icon: Icons.more_horiz_rounded,
label: data.l10n.more,
onTap: actions.onMore,
),
),
],
),
], ],
), ),
), ),
), ),
], ),
), ),
); );
} }
Widget _infoChip(Key key, String label) => Container(
key: key,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(_chipRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
Widget _softChip(String label) => Container(
constraints: const BoxConstraints(maxWidth: 180),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(_chipRadius),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
);
String get _genre =>
data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category;
} }
@@ -16,11 +16,14 @@ class PlayerOverlayData {
required this.controller, required this.controller,
required this.liked, required this.liked,
required this.likeCount, required this.likeCount,
required this.commentCount,
required this.favoriteCount,
required this.saved, required this.saved,
required this.playing, required this.playing,
required this.playbackSpeed,
required this.textScale, required this.textScale,
required this.currentIndex,
required this.totalEpisodes, required this.totalEpisodes,
this.authorNote = '',
}); });
/// 当前语言包。 /// 当前语言包。
@@ -41,20 +44,29 @@ class PlayerOverlayData {
/// 点赞数;未知时隐藏。 /// 点赞数;未知时隐藏。
final int? likeCount; final int? likeCount;
/// 评论数;未知时隐藏。
final int? commentCount;
/// 收藏数;未知时隐藏。
final int? favoriteCount;
/// 是否已收藏。 /// 是否已收藏。
final bool saved; final bool saved;
/// 是否处于播放状态。 /// 是否处于播放状态。
final bool playing; final bool playing;
/// 当前倍速。
final double playbackSpeed;
/// 信息文字缩放比例。 /// 信息文字缩放比例。
final double textScale; final double textScale;
/// 当前集索引。
final int currentIndex;
/// 可见集数量。 /// 可见集数量。
final int totalEpisodes; final int totalEpisodes;
/// 作者声明;没有后端字段时为空。
final String authorNote;
} }
/// 播放浮层动作集合。 /// 播放浮层动作集合。
@@ -65,6 +77,8 @@ class PlayerOverlayActions {
required this.onTogglePlay, required this.onTogglePlay,
required this.onClear, required this.onClear,
required this.onExitClear, required this.onExitClear,
required this.onDetail,
required this.onSpeed,
required this.onEpisodes, required this.onEpisodes,
required this.onComments, required this.onComments,
required this.onLike, required this.onLike,
@@ -73,7 +87,7 @@ class PlayerOverlayActions {
required this.onMore, required this.onMore,
required this.onScrubStart, required this.onScrubStart,
required this.onSeek, required this.onSeek,
this.onNext, this.onPip,
}); });
/// 返回上一页。 /// 返回上一页。
@@ -88,6 +102,12 @@ class PlayerOverlayActions {
/// 退出清屏。 /// 退出清屏。
final VoidCallback onExitClear; final VoidCallback onExitClear;
/// 打开详情。
final VoidCallback onDetail;
/// 打开顶部倍速入口。
final VoidCallback onSpeed;
/// 打开选集。 /// 打开选集。
final VoidCallback onEpisodes; final VoidCallback onEpisodes;
@@ -106,12 +126,12 @@ class PlayerOverlayActions {
/// 打开更多面板。 /// 打开更多面板。
final VoidCallback onMore; final VoidCallback onMore;
/// 下一集。
final VoidCallback? onNext;
/// 开始拖动进度。 /// 开始拖动进度。
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
/// 提交进度跳转。 /// 提交进度跳转。
final Future<void> Function(Duration)? onSeek; final Future<void> Function(Duration)? onSeek;
/// 进入小窗播放。
final VoidCallback? onPip;
} }
@@ -22,8 +22,8 @@ class _TopButton extends StatelessWidget {
tooltip: tooltip, tooltip: tooltip,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.card, backgroundColor: const Color(0x80000000),
foregroundColor: AppColors.onSurface, foregroundColor: AppColors.onPrimary,
), ),
onPressed: onPressed, onPressed: onPressed,
icon: Icon(icon, size: 18), 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 { class _Rail extends StatelessWidget {
const _Rail({ const _Rail({
required this.l10n, required this.l10n,
required this.liked, required this.liked,
required this.saved, required this.saved,
required this.likeCount, required this.likeCount,
required this.favoriteCount,
required this.commentCount,
required this.onLike, required this.onLike,
required this.onComments, required this.onComments,
required this.onSave, required this.onSave,
@@ -49,6 +92,8 @@ class _Rail extends StatelessWidget {
final bool liked; final bool liked;
final bool saved; final bool saved;
final int? likeCount; final int? likeCount;
final int? favoriteCount;
final int? commentCount;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onComments; final VoidCallback onComments;
final VoidCallback onSave; final VoidCallback onSave;
@@ -61,25 +106,26 @@ class _Rail extends StatelessWidget {
key: const Key('player-interaction-rail'), key: const Key('player-interaction-rail'),
children: [ children: [
_RailButton( _RailButton(
key: const Key('player-rail-like'), key: const Key('player-rail-save'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
color: liked ? AppColors.blueSoft : AppColors.onPrimary, label: favoriteCount?.toString() ?? l10n.favorite,
label: likeCount?.toString() ?? l10n.like, active: saved,
onTap: onLike, onTap: onSave,
compact: compact, compact: compact,
), ),
_RailButton( _RailButton(
key: const Key('player-rail-comments'), key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded, icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments, label: commentCount?.toString() ?? l10n.comments,
onTap: onComments, onTap: onComments,
compact: compact, compact: compact,
), ),
_RailButton( _RailButton(
key: const Key('player-rail-save'), key: const Key('player-rail-like'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded, icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.favorite, label: likeCount?.toString() ?? l10n.like,
onTap: onSave, active: liked,
onTap: onLike,
compact: compact, compact: compact,
), ),
_RailButton( _RailButton(
@@ -101,19 +147,19 @@ class _RailButton extends StatelessWidget {
required this.label, required this.label,
required this.onTap, required this.onTap,
required this.compact, required this.compact,
this.color = AppColors.onPrimary, this.active = false,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;
final bool compact; final bool compact;
final Color color; final bool active;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : _railGap), padding: EdgeInsets.only(bottom: compact ? 3 : _railGap),
child: Column( child: Column(
children: [ children: [
SizedBox.square( SizedBox.square(
@@ -122,8 +168,8 @@ class _RailButton extends StatelessWidget {
onPressed: onTap, onPressed: onTap,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.overlayDark, backgroundColor: const Color(0x80000000),
foregroundColor: color, foregroundColor: active ? AppColors.blue : AppColors.onPrimary,
), ),
icon: Icon(icon, size: 18), icon: Icon(icon, size: 18),
), ),
@@ -144,28 +190,78 @@ class _RailButton extends StatelessWidget {
} }
} }
class _PageDots extends StatelessWidget { class _EpisodeSynopsis extends StatefulWidget {
const _PageDots({super.key, required this.current, required this.total}); const _EpisodeSynopsis({
super.key,
required this.l10n,
required this.episodeNumber,
required this.synopsis,
required this.textScale,
});
final int current; final AppLocalizations l10n;
final int total; final int episodeNumber;
final String synopsis;
final double textScale;
@override
State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState();
}
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (total <= 1) return const SizedBox.shrink(); final canExpand = widget.synopsis.trim().length > 28;
final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0); return Column(
final end = (start + 5).clamp(0, total); crossAxisAlignment: CrossAxisAlignment.start,
return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
for (var index = start; index < end; index++) Row(
Container( crossAxisAlignment: CrossAxisAlignment.start,
width: index == current ? 14 : _dotSize, children: [
height: _dotSize, Text(
margin: const EdgeInsets.only(left: 4), widget.l10n.episodeLabel(widget.episodeNumber),
decoration: BoxDecoration( key: const Key('player-episode-label'),
color: index == current ? AppColors.blue : Colors.white54, style: TextStyle(
borderRadius: BorderRadius.circular(_dotRadius), 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 { class _PlayerTimeLabel extends StatelessWidget {
const _TimeLabel({super.key, required this.controller}); const _PlayerTimeLabel({super.key, required this.controller});
final VideoPlayerController? controller; final VideoPlayerController? controller;
@@ -188,13 +284,15 @@ class _TimeLabel extends StatelessWidget {
); );
} }
Widget _label(Duration position, Duration duration) => Text( Widget _label(Duration position, Duration duration) => Align(
'${_format(position)} / ${_format(duration)}', alignment: Alignment.centerRight,
textAlign: TextAlign.center, child: Text(
style: const TextStyle( '${_format(position)} / ${_format(duration)}',
color: AppColors.onPrimary, style: const TextStyle(
fontSize: 10, color: Colors.white60,
fontWeight: FontWeight.w600, fontSize: 9,
fontWeight: FontWeight.w600,
),
), ),
); );
@@ -204,40 +302,3 @@ class _TimeLabel extends StatelessWidget {
return '$minutes:$seconds'; 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),
),
),
);
}
}
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
const double _sheetRadius = 20; const double _sheetRadius = 20;
/// 播放倍速选择弹窗。 /// 播放倍速选择弹窗。
@@ -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;
}
@@ -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,
),
],
),
),
),
);
}
}
@@ -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<String> 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;
}
+1 -1
View File
@@ -68,7 +68,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Second page'), findsOneWidget); expect(find.text('Second page'), findsOneWidget);
expect(find.text('Comments (2)'), findsOneWidget); expect(find.text('2 comments'), findsOneWidget);
expect(repository.pageCalls.last.cursor, '10'); expect(repository.pageCalls.last.cursor, '10');
}); });
+39
View File
@@ -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();
});
}
+1 -1
View File
@@ -32,7 +32,7 @@ void main() {
expect(find.text('Continue Watching'), findsNothing); expect(find.text('Continue Watching'), findsNothing);
expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('discover-search')), findsOneWidget);
expect(find.byKey(const Key('theater-category-chips')), 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('home-all-dramas-grid')), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
+6 -11
View File
@@ -86,20 +86,15 @@ void main() {
expect(field.focusNode?.hasFocus, isTrue); expect(field.focusNode?.hasFocus, isTrue);
}); });
testWidgets('should_select_theater_language_without_filtering_catalog', ( testWidgets('should_not_render_theater_language_filters', (tester) async {
tester,
) async {
await _pumpApp(tester); await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-theater'))); await tester.tap(find.byKey(const Key('tab-theater')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(_chip(tester, 'theater-language-all').selected, isTrue); expect(find.byKey(const Key('theater-language-chips')), findsNothing);
expect(_chip(tester, 'theater-language-id').selected, isFalse); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget);
await tester.tap(find.byKey(const Key('theater-language-id'))); expect(find.byKey(const Key('discover-search')), findsOneWidget);
await tester.pumpAndSettle(); expect(find.byKey(const Key('discover-refresh')), findsOneWidget);
expect(_chip(tester, 'theater-language-all').selected, isFalse);
expect(_chip(tester, 'theater-language-id').selected, isTrue);
expect( expect(
find.byKey(const Key('home-drama-card-the-signal-demo')), find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget, findsOneWidget,
@@ -202,7 +197,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-theater'))); await tester.tap(find.byKey(const Key('tab-theater')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('theater-category-chips')), 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(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
+2 -1
View File
@@ -60,11 +60,12 @@ void main() {
Key('mine-creator-center'), Key('mine-creator-center'),
Key('mine-notices'), Key('mine-notices'),
Key('mine-settings'), Key('mine-settings'),
Key('mine-language'),
Key('mine-help'), Key('mine-help'),
]) { ]) {
expect(find.byKey(key), findsOneWidget); 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-0')), findsNothing);
expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); expect(find.byKey(const Key('mine-library-tab-1')), findsNothing);
expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); expect(find.byKey(const Key('mine-library-tab-2')), findsNothing);
+75 -52
View File
@@ -40,14 +40,9 @@ void main() {
expect(find.byType(PageView), findsOneWidget); expect(find.byType(PageView), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more'))); await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-quality')), findsOneWidget); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget);
expect(find.byKey(const Key('player-more-download')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget);
expect(find.byKey(const Key('player-more-pip')), findsOneWidget); await tester.tap(find.byKey(const Key('player-more-close')));
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();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -111,7 +106,7 @@ void main() {
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
expect(find.byType(PlayerCenterToggle), 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(); await tester.pump();
expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-controls-top')), findsNothing);
@@ -184,7 +179,7 @@ void main() {
await _pumpApp(tester); await _pumpApp(tester);
await _openSeriesPlayer(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(); await tester.pump();
expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-controls-top')), findsNothing);
@@ -208,14 +203,11 @@ void main() {
await _pumpApp(tester); await _pumpApp(tester);
await _openSeriesPlayer(tester); await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-more'))); await tester.tap(find.byKey(const Key('player-speed-entry')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-speed')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more-speed')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); 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); expect(find.byKey(Key('player-speed-$value')), findsOneWidget);
} }
@@ -223,11 +215,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-sheet')), findsNothing); 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(); await tester.pumpAndSettle();
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('player-more-speed')), of: find.byKey(const Key('player-speed-entry')),
matching: find.text('1.5x'), matching: find.text('1.5x'),
), ),
findsOneWidget, findsOneWidget,
@@ -240,38 +232,22 @@ void main() {
await _pumpApp(tester); await _pumpApp(tester);
await _openSeriesPlayer(tester); await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-more'))); await tester.tap(find.byKey(const Key('player-speed-entry')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-more-speed')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.tap(find.byKey(const Key('player-speed-1.5')));
await tester.pumpAndSettle(); 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(); await tester.pump();
expect(find.byKey(const Key('player-more')), findsNothing); expect(find.byKey(const Key('player-more')), findsNothing);
await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
await tester.pump(); 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.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle();
expect( expect(
find.descendant( find.descendant(
of: find.byKey(const Key('player-more-speed')), of: find.byKey(const Key('player-speed-entry')),
matching: find.text('1.5x'), matching: find.text('1.5x'),
), ),
findsOneWidget, findsOneWidget,
@@ -341,25 +317,70 @@ void main() {
expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); 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-speed')), findsOneWidget);
expect(find.byKey(const Key('player-more-quality')), findsOneWidget); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget);
expect(find.byKey(const Key('player-more-download')), 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-text-size')), findsOneWidget);
expect(find.byKey(const Key('player-more-report')), 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(); 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.tap(find.byKey(const Key('player-text-size-large')));
await tester.pumpAndSettle(); 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.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.descendant( tester
of: find.byKey(const Key('player-more-text-size')), .widget<ChoiceChip>(find.byKey(const Key('player-more-quality-AUTO')))
matching: find.text('Large'), .onSelected,
), isNotNull,
findsOneWidget,
); );
expect(
tester
.widget<ChoiceChip>(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', ( testWidgets('should_open_unlock_panel_with_honest_method_states', (
@@ -395,14 +416,10 @@ void main() {
void _expectFullscreenOverlay(WidgetTester tester) { void _expectFullscreenOverlay(WidgetTester tester) {
expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('player-title')), findsOneWidget); expect(find.byKey(const Key('player-episode-heading')), findsOneWidget);
expect(find.byKey(const Key('player-title-dot')), findsOneWidget); expect(find.byKey(const Key('player-speed-entry')), findsOneWidget);
expect(find.byKey(const Key('player-subtitle')), findsOneWidget);
expect(find.byKey(const Key('player-genre-chip')), findsOneWidget);
expect(find.byKey(const Key('player-page-dots')), findsOneWidget);
expect(find.byKey(const Key('player-drama-title')), findsOneWidget); expect(find.byKey(const Key('player-drama-title')), findsOneWidget);
expect(find.byKey(const Key('player-episode-label')), 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-synopsis')), findsOneWidget);
expect(find.byKey(const Key('player-time-label')), findsOneWidget); expect(find.byKey(const Key('player-time-label')), findsOneWidget);
expect(find.byKey(const Key('player-more')), 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-play-toggle')), findsNothing);
expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-episodes')), findsOneWidget);
expect(find.byKey(const Key('player-rail-comments')), 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.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-interaction-rail')), findsOneWidget);
expect(find.byKey(const Key('player-rail-like')), findsOneWidget); expect(find.byKey(const Key('player-rail-like')), findsOneWidget);
-32
View File
@@ -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,
);
});
}
+1 -3
View File
@@ -42,9 +42,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-mine'))); await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520)); await tester.tap(find.byKey(const Key('mine-history')));
await tester.pumpAndSettle();
await tester.tap(find.text('Watch History'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
+29 -7
View File
@@ -72,7 +72,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data, tester.widget<Text>(find.byKey(const Key('player-episode-heading'))).data,
contains('Episode 2'), contains('Episode 2'),
); );
expect(created, hasLength(3)); expect(created, hasLength(3));
@@ -135,9 +135,7 @@ void main() {
); );
}); });
testWidgets('should_follow_system_locale_without_language_switch', ( testWidgets('should_show_language_entry_on_mine_page', (tester) async {
tester,
) async {
await tester.pumpWidget(appUnderTest()); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byTooltip('Captions'), findsNothing); expect(find.byTooltip('Captions'), findsNothing);
@@ -146,7 +144,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), 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); expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-theater'))); await tester.tap(find.byKey(const Key('tab-theater')));
@@ -154,6 +152,30 @@ void main() {
expect(find.text('Theater'), findsWidgets); 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', ( testWidgets('should_fit_compact_viewport_when_player_is_opened', (
tester, tester,
) async { ) async {
@@ -170,7 +192,7 @@ void main() {
await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data, tester.widget<Text>(find.byKey(const Key('player-episode-heading'))).data,
contains('Episode 2'), contains('Episode 2'),
); );
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
@@ -191,7 +213,7 @@ void main() {
expect(find.byKey(const Key('discover-search')), findsOneWidget); expect(find.byKey(const Key('discover-search')), findsOneWidget);
expect(find.byKey(const Key('theater-category-chips')), 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('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);