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

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

No files matched your search

+26 -17
View File
@@ -40,6 +40,7 @@ import 'core/data/services/category_controller.dart';
import 'core/data/services/download_controller.dart';
import 'core/data/services/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
View File
@@ -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
View File
@@ -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"
}
+79 -1
View File
@@ -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
+40 -1
View File
@@ -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';
}
+40 -1
View File
@@ -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';
}
+40 -1
View File
@@ -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
View File
@@ -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": "定时关闭"
}
-8
View File
@@ -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;
-3
View File
@@ -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({
-33
View File
@@ -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,
+96 -1
View File
@@ -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 {
+47 -1
View File
@@ -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,
+13 -7
View File
@@ -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)
+6 -27
View File
@@ -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,
+34 -9
View File
@@ -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),
),
);
}
}
+264 -102
View File
@@ -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,
],
),
),
+205 -269
View File
@@ -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;
}
+1 -1
View File
@@ -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');
});
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/locale_controller.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('should_restore_saved_locale_when_controller_loads', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
LocaleController.preferenceKey: 'zh',
});
final controller = LocaleController();
await controller.load();
expect(controller.locale?.languageCode, 'zh');
controller.dispose();
});
test('should_persist_selected_locale_for_next_launch', () async {
final controller = LocaleController();
await controller.select(const Locale('id'));
final restored = LocaleController();
await restored.load();
expect(restored.locale?.languageCode, 'id');
controller.dispose();
restored.dispose();
});
}
+1 -1
View File
@@ -32,7 +32,7 @@ void main() {
expect(find.text('Continue Watching'), findsNothing);
expect(find.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);
});
+6 -11
View File
@@ -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);
});
}
+2 -1
View File
@@ -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);
+75 -52
View File
@@ -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);
-32
View File
@@ -1,32 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/quality_sheet.dart';
void main() {
testWidgets('should_list_every_returned_quality_when_sheet_opens', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(
body: QualitySheet(qualities: ['AUTO', '720P', '1080P']),
),
),
);
expect(find.text('Auto'), findsOneWidget);
expect(find.text('720P'), findsOneWidget);
expect(find.text('1080P'), findsOneWidget);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
expect(
find.text('HLS adjusts the bitrate automatically for your connection.'),
findsOneWidget,
);
});
}
+1 -3
View File
@@ -42,9 +42,7 @@ void main() {
await tester.pumpAndSettle();
await tester.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
View File
@@ -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);