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