merge: 合并 K29.6 更多面板精简

This commit is contained in:
Zhou Weidong committed 2026-10-20 11:40:15 +08:00
commit 1e3e5b64dc
8 files changed
+175 -310

No files matched your search

+1 -8
View File
@@ -21,7 +21,6 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/home_feed_overlay.dart';
@@ -83,7 +82,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
double _textScale = 1.0;
@override
void initState() {
@@ -125,10 +123,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
setState(() => _playbackSpeed = value);
}
void _updateTextScale(double value) {
setState(() => _textScale = value);
}
void _hideChrome() {
setState(() => _chromeVisible = false);
}
@@ -389,7 +383,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
textScale: _textScale,
totalEpisodes: logic.visibleIndices.length,
),
actions: PlayerOverlayActions(
@@ -408,7 +401,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
unawaited(_toggleLike(context, interactions, drama)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, drama, episode),
onMore: () => _openMore(context, episode),
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
+1 -38
View File
@@ -15,11 +15,7 @@ extension _PlayerViewActions on _PlayerPageState {
await logic.setPlaybackSpeed(selected);
}
Future<void> _openMore(
BuildContext context,
Drama drama,
Episode episode,
) async {
Future<void> _openMore(BuildContext context, Episode episode) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
@@ -31,21 +27,14 @@ extension _PlayerViewActions on _PlayerPageState {
state: PlayerMoreState(
speed: _playbackSpeed,
qualities: logic.qualities,
textScale: _textScale,
pipAvailable: logic.pictureInPicture.isPlatformSupported,
),
actions: PlayerMoreActions(
onSpeedChanged: (value) {
_updatePlaybackSpeed(value);
unawaited(logic.setPlaybackSpeed(value));
},
onTextScaleChanged: _updateTextScale,
onClear: _hideChrome,
onDownload: () => requestEpisodeDownload(context, episode.id),
onReport: () => unawaited(_reportDrama(context, drama, l10n)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
),
),
);
@@ -86,32 +75,6 @@ extension _PlayerViewActions on _PlayerPageState {
return false;
}
Future<void> _reportDrama(
BuildContext context,
Drama drama,
AppLocalizations l10n,
) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final draft = await showReportDialog(context, title: l10n.reportDrama);
if (draft == null || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await context.read<InteractionController>().reportDrama(
drama.id,
reason: draft.reason,
description: draft.description,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
),
),
);
}
Future<void> _toggleLike(
BuildContext context,
InteractionController interactions,
+128 -216
View File
@@ -4,9 +4,14 @@ import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'player_text_scale.dart';
const double _sheetRadius = 20;
const double _rowHeight = 44;
const double _groupGap = 12;
const double _segmentHeight = 28;
const double _segmentRadius = 8;
const double _segmentPadding = 10;
const double _segmentGap = 6;
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';
@@ -14,24 +19,13 @@ const String _autoQuality = 'AUTO';
/// 更多面板运行状态。
class PlayerMoreState {
/// 创建更多面板状态。
const PlayerMoreState({
required this.speed,
required this.qualities,
required this.textScale,
required this.pipAvailable,
});
const PlayerMoreState({required this.speed, required this.qualities});
/// 当前倍速。
final double speed;
/// 后端下发的清晰度档位。
final List<String> qualities;
/// 当前文字缩放比例。
final double textScale;
/// 设备是否提供画中画入口。
final bool pipAvailable;
}
/// 更多面板动作集合。
@@ -39,33 +33,21 @@ class PlayerMoreActions {
/// 创建动作集合。
const PlayerMoreActions({
required this.onSpeedChanged,
required this.onTextScaleChanged,
required this.onClear,
required this.onDownload,
required this.onReport,
this.onPip,
});
/// 倍速变化。
final ValueChanged<double> onSpeedChanged;
/// 字体大小变化。
final ValueChanged<double> onTextScaleChanged;
/// 进入清屏播放。
final VoidCallback onClear;
/// 离线缓存当前集。
final VoidCallback onDownload;
/// 投诉当前剧。
final VoidCallback onReport;
/// 进入画中画;平台不支持时为空。
final VoidCallback? onPip;
}
/// K29 播放页白色更多面板。
/// K29.6 精简版播放页更多面板。
class PlayerMoreSheet extends StatefulWidget {
/// 创建更多面板。
const PlayerMoreSheet({
@@ -90,84 +72,55 @@ class PlayerMoreSheet extends StatefulWidget {
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(
return Material(
key: const Key('player-more-sheet'),
constraints: const BoxConstraints(maxHeight: 620),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
color: AppColors.card,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
key: const Key('player-more-drag-handle'),
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 8),
_header(l10n),
_sectionTitle(l10n.playbackSpeed),
_speedPicker(),
const SizedBox(height: 16),
const SizedBox(height: _groupGap),
_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,
),
_enabledRow(
const SizedBox(height: _groupGap),
_actionRow(
key: const Key('player-more-clear'),
icon: Icons.fullscreen_rounded,
title: l10n.clearPlayback,
onTap: _clear,
),
_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(
_actionRow(
key: const Key('player-more-offline'),
icon: Icons.download_rounded,
title: l10n.offlineCache,
onTap: () => _closeThen(widget.actions.onDownload),
),
if (widget.state.pipAvailable && widget.actions.onPip != null)
_enabledRow(
key: const Key('player-more-pip'),
icon: Icons.picture_in_picture_alt_rounded,
title: l10n.pictureInPicture,
onTap: () => _closeThen(widget.actions.onPip!),
),
_textSizeRow(l10n),
_disabledRow(
key: const Key('player-more-cast'),
icon: Icons.cast_rounded,
title: l10n.screenCast,
),
_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,
onTap: () => _closeThen(widget.actions.onReport),
),
],
),
),
@@ -182,44 +135,47 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
l10n.more,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
key: const Key('player-more-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
SizedBox.square(
dimension: 32,
child: IconButton(
key: const Key('player-more-close'),
tooltip: l10n.close,
padding: EdgeInsets.zero,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded, size: 18),
),
),
],
);
Widget _sectionTitle(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.only(bottom: 8, top: 2),
child: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
Widget _speedPicker() => Wrap(
key: const Key('player-more-speed'),
spacing: 8,
runSpacing: 8,
spacing: _segmentGap,
runSpacing: _segmentGap,
children: [
for (final value in _speedOptions)
ChoiceChip(
_SegmentButton(
key: Key('player-more-speed-$value'),
label: Text('${value}x'),
label: '${value}x',
selected: (_speed - value).abs() < 0.001,
showCheckmark: false,
onSelected: (_) {
onTap: () {
setState(() => _speed = value);
widget.actions.onSpeedChanged(value);
},
@@ -229,110 +185,69 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
Widget _qualityPicker(AppLocalizations l10n) => Wrap(
key: const Key('player-more-quality'),
spacing: 8,
runSpacing: 8,
spacing: _segmentGap,
runSpacing: _segmentGap,
children: [
for (final quality in _qualityOptions) _qualityChip(l10n, quality),
for (final quality in _qualityOptions) _qualitySegment(l10n, quality),
],
);
Widget _qualityChip(AppLocalizations l10n, String quality) {
Widget _qualitySegment(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(
return _SegmentButton(
key: Key('player-more-quality-$quality'),
label: Text(enabled ? quality : '$quality · ${l10n.qualityUnavailable}'),
label: enabled ? quality : '$quality · ${l10n.qualityUnavailable}',
selected: selected,
showCheckmark: false,
onSelected: enabled ? (_) {} : null,
enabled: enabled,
onTap: 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({
Widget _actionRow({
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),
);
}) {
return Material(
key: key,
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: SizedBox(
height: _rowHeight,
child: Row(
children: [
Icon(icon, color: AppColors.muted, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
],
),
),
),
);
}
void _clear() {
Navigator.pop(context);
@@ -347,56 +262,53 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
}
}
class _MoreRow extends StatelessWidget {
const _MoreRow({
class _SegmentButton extends StatelessWidget {
const _SegmentButton({
super.key,
required this.icon,
required this.title,
required this.enabled,
this.value = '',
this.trailing,
required this.label,
required this.selected,
this.enabled = true,
this.onTap,
});
final IconData icon;
final String title;
final String label;
final bool selected;
final bool enabled;
final String value;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final color = enabled ? AppColors.onSurface : AppColors.subtle;
final background = selected
? AppColors.blue
: enabled
? AppColors.raised
: AppColors.cardAlt;
final foreground = selected
? AppColors.onPrimary
: enabled
? AppColors.onSurface
: AppColors.subtle;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
color: background,
borderRadius: BorderRadius.circular(_segmentRadius),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
child: Row(
children: [
Icon(icon, color: color, size: 21),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
color: color,
fontSize: 14,
fontWeight: FontWeight.w600,
),
onTap: onTap,
borderRadius: BorderRadius.circular(_segmentRadius),
child: SizedBox(
height: _segmentHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
child: Center(
child: Text(
label,
maxLines: 1,
style: TextStyle(
color: foreground,
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
if (value.isNotEmpty)
Text(
value,
style: const TextStyle(color: AppColors.subtle, fontSize: 11),
),
?trailing,
],
),
),
),
),
+1 -2
View File
@@ -173,7 +173,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
episodeNumber: data.episode.number,
synopsis: synopsis,
authorNote: data.authorNote,
textScale: data.textScale,
),
],
),
@@ -216,7 +215,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
style: TextStyle(
color: AppColors.onPrimary,
fontFamily: 'Outfit',
fontSize: 18 * data.textScale,
fontSize: 18,
fontWeight: FontWeight.w800,
shadows: _playerTextShadows,
),
@@ -21,7 +21,6 @@ class PlayerOverlayData {
required this.saved,
required this.playing,
required this.playbackSpeed,
required this.textScale,
required this.totalEpisodes,
this.authorNote = '',
});
@@ -59,9 +58,6 @@ class PlayerOverlayData {
/// 当前倍速。
final double playbackSpeed;
/// 信息文字缩放比例。
final double textScale;
/// 可见集数量。
final int totalEpisodes;
@@ -221,14 +221,12 @@ class _EpisodeSynopsis extends StatefulWidget {
required this.episodeNumber,
required this.synopsis,
required this.authorNote,
required this.textScale,
});
final AppLocalizations l10n;
final int episodeNumber;
final String synopsis;
final String authorNote;
final double textScale;
@override
State<_EpisodeSynopsis> createState() => _EpisodeSynopsisState();
@@ -255,7 +253,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
key: const Key('player-episode-label'),
style: TextStyle(
color: AppColors.onPrimary,
fontSize: 11 * widget.textScale,
fontSize: 11,
fontWeight: FontWeight.w700,
shadows: _playerTextShadows,
),
@@ -278,7 +276,7 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.playerMuted,
fontSize: 11 * widget.textScale,
fontSize: 11,
height: 1.35,
shadows: _playerTextShadows,
),
@@ -1,19 +0,0 @@
// 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;
}
+42 -19
View File
@@ -370,7 +370,7 @@ void main() {
expect(find.byKey(const Key('player-episode-2')), findsOneWidget);
});
testWidgets('should_open_more_actions_and_change_text_size', (tester) async {
testWidgets('should_open_compact_more_actions', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-more')));
@@ -381,22 +381,35 @@ void main() {
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-drag-handle')), findsOneWidget);
for (final removed in const [
Key('player-more-not-interested'),
Key('player-more-danmaku'),
Key('player-more-danmaku-settings'),
Key('player-more-text-size'),
Key('player-more-cast'),
Key('player-more-timer'),
Key('player-more-pip'),
Key('player-more-report'),
]) {
expect(find.byKey(removed), findsNothing);
}
expect(
find.byKey(const Key('player-more-danmaku-settings')),
findsOneWidget,
tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height,
28,
);
expect(
tester
.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-speed-1.0')),
matching: find.text('1.0x'),
),
)
.style
?.fontSize,
12,
);
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.byTooltip('Text size'));
await tester.pumpAndSettle();
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 {
@@ -418,14 +431,24 @@ void main() {
expect(
tester
.widget<ChoiceChip>(find.byKey(const Key('player-more-quality-AUTO')))
.onSelected,
.widget<InkWell>(
find.descendant(
of: find.byKey(const Key('player-more-quality-AUTO')),
matching: find.byType(InkWell),
),
)
.onTap,
isNotNull,
);
expect(
tester
.widget<ChoiceChip>(find.byKey(const Key('player-more-quality-720P')))
.onSelected,
.widget<InkWell>(
find.descendant(
of: find.byKey(const Key('player-more-quality-720P')),
matching: find.byType(InkWell),
),
)
.onTap,
isNull,
);
expect(find.text('720P · Unavailable'), findsOneWidget);