feat(flutter): 播放页长按手势与加速态

This commit is contained in:
Zhou Weidong committed 2026-10-20 12:36:35 +08:00
1 parent 7bb2714941
commit 96074bc5e8
11 files changed
+231 -4

No files matched your search

+6
View File
@@ -53,6 +53,12 @@
"clearScreen": "Clear screen",
"exitClearScreen": "Exit clear screen",
"playbackSpeed": "Playback speed",
"speedBoosting": "{speed}x speed",
"@speedBoosting": {
"placeholders": {
"speed": {"type": "String"}
}
},
"minutesAgo": "{count} min ago",
"@minutesAgo": {
"placeholders": {
+1
View File
@@ -53,6 +53,7 @@
"clearScreen": "Bersihkan layar",
"exitClearScreen": "Keluar dari layar bersih",
"playbackSpeed": "Kecepatan putar",
"speedBoosting": "{speed}x kecepatan",
"minutesAgo": "{count} mnt lalu",
"@minutesAgo": {
"placeholders": {
+6
View File
@@ -382,6 +382,12 @@ abstract class AppLocalizations {
/// **'Playback speed'**
String get playbackSpeed;
/// No description provided for @speedBoosting.
///
/// In en, this message translates to:
/// **'{speed}x speed'**
String speedBoosting(String speed);
/// No description provided for @minutesAgo.
///
/// In en, this message translates to:
+5
View File
@@ -154,6 +154,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get playbackSpeed => 'Playback speed';
@override
String speedBoosting(String speed) {
return '${speed}x speed';
}
@override
String minutesAgo(int count) {
return '$count min ago';
+5
View File
@@ -154,6 +154,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get playbackSpeed => 'Kecepatan putar';
@override
String speedBoosting(String speed) {
return '${speed}x kecepatan';
}
@override
String minutesAgo(int count) {
return '$count mnt lalu';
+5
View File
@@ -151,6 +151,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get playbackSpeed => '倍速';
@override
String speedBoosting(String speed) {
return '$speed 倍速快进中';
}
@override
String minutesAgo(int count) {
return '$count 分钟前';
+1
View File
@@ -53,6 +53,7 @@
"clearScreen": "清屏",
"exitClearScreen": "退出清屏",
"playbackSpeed": "倍速",
"speedBoosting": "{speed} 倍速快进中",
"minutesAgo": "{count} 分钟前",
"@minutesAgo": {
"placeholders": {
+37
View File
@@ -36,6 +36,8 @@ import '../../core/data/repositories/playback_repository.dart';
part 'player_view_actions.dart';
const double _boostPlaybackSpeed = 2.0;
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
@@ -82,6 +84,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
bool _boosting = false;
double? _speedBeforeBoost;
bool? _chromeBeforeBoost;
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
@@ -130,6 +135,34 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
setState(() => _chromeVisible = false);
}
/// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。
void _startBoost() {
if (_boosting) return;
setState(() {
_boosting = true;
_speedBeforeBoost = _playbackSpeed;
_chromeBeforeBoost = _chromeVisible;
_playbackSpeed = _boostPlaybackSpeed;
_chromeVisible = false;
});
unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed));
}
/// 松手或手势取消:恢复长按前的倍速与清屏状态。
void _endBoost() {
if (!_boosting) return;
final speed = _speedBeforeBoost ?? _playbackSpeed;
final chrome = _chromeBeforeBoost ?? true;
setState(() {
_boosting = false;
_speedBeforeBoost = null;
_chromeBeforeBoost = null;
_playbackSpeed = speed;
_chromeVisible = chrome;
});
unawaited(logic.setPlaybackSpeed(speed));
}
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
setState(() {
@@ -397,6 +430,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
totalEpisodes: logic.visibleIndices.length,
boosting: _boosting,
),
actions: PlayerOverlayActions(
onBack: widget.embedded
@@ -419,6 +453,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
onLongPressCenter: () => _openMore(context, episode),
onBoostStart: _startBoost,
onBoostEnd: _endBoost,
),
controlsVisible: _chromeVisible,
),
+53 -4
View File
@@ -16,6 +16,8 @@ const double _topButtonSize = 36;
const double _topBarHeight = 58;
const double _railButtonSize = 34;
const double _railGap = 18;
const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13;
const List<Shadow> _playerTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
];
@@ -45,10 +47,16 @@ class PlayerPlaybackOverlay extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
key: const Key('player-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: actions.onTogglePlay,
child: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
key: const Key('player-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: actions.onTogglePlay,
onLongPressStart: (details) =>
_handleLongPressStart(details, constraints.maxWidth),
onLongPressEnd: (_) => actions.onBoostEnd(),
onLongPressCancel: actions.onBoostEnd,
),
),
),
_top(context),
@@ -120,6 +128,17 @@ class PlayerPlaybackOverlay extends StatelessWidget {
);
}
/// 长按分区:中间 1/3 弹更多,左右各 1/3 开始加速。
void _handleLongPressStart(LongPressStartDetails details, double width) {
final third = width / 3;
final dx = details.localPosition.dx;
if (dx < third || dx > width - third) {
actions.onBoostStart();
return;
}
actions.onLongPressCenter();
}
Widget _rail(BuildContext context) {
final compact = MediaQuery.sizeOf(context).height < 700;
return Positioned(
@@ -149,6 +168,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (controlsVisible) _bottomInfo(context),
if (data.boosting) _boostHint(),
_bottomProgress(),
_bottomActions(),
],
@@ -156,6 +176,35 @@ class PlayerPlaybackOverlay extends StatelessWidget {
);
}
/// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。
Widget _boostHint() {
return Padding(
key: const Key('player-speed-boost-hint'),
padding: const EdgeInsets.only(bottom: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.play_arrow_rounded,
color: AppColors.blue,
size: _boostHintIconSize,
shadows: _playerTextShadows,
),
const SizedBox(width: 4),
Text(
data.l10n.speedBoosting(data.playbackSpeed.toStringAsFixed(1)),
style: const TextStyle(
color: AppColors.blue,
fontSize: _boostHintFontSize,
fontWeight: FontWeight.w700,
shadows: _playerTextShadows,
),
),
],
),
);
}
Widget _bottomInfo(BuildContext context) {
final series = localizedDramaTitle(data.l10n, data.drama);
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
@@ -22,6 +22,7 @@ class PlayerOverlayData {
required this.playing,
required this.playbackSpeed,
required this.totalEpisodes,
required this.boosting,
this.authorNote = '',
});
@@ -61,6 +62,9 @@ class PlayerOverlayData {
/// 可见集数量。
final int totalEpisodes;
/// 是否处于长按加速态。
final bool boosting;
/// 作者声明;没有后端字段时为空。
final String authorNote;
}
@@ -83,6 +87,9 @@ class PlayerOverlayActions {
required this.onMore,
required this.onScrubStart,
required this.onSeek,
required this.onLongPressCenter,
required this.onBoostStart,
required this.onBoostEnd,
});
/// 返回上一页。
@@ -126,4 +133,13 @@ class PlayerOverlayActions {
/// 提交进度跳转。
final Future<void> Function(Duration)? onSeek;
/// 长按画面中间:弹出更多面板。
final VoidCallback onLongPressCenter;
/// 长按画面两侧:开始加速。
final VoidCallback onBoostStart;
/// 长按结束或取消:结束加速。
final VoidCallback onBoostEnd;
}
+96
View File
@@ -152,6 +152,102 @@ void main() {
expect(find.byTooltip('Clear screen'), findsOneWidget);
});
testWidgets('should_open_more_sheet_on_center_long_press', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
final gesture = await tester.startGesture(const Offset(195, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-sheet')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more-close')));
await tester.pumpAndSettle();
// 长按弹更多不改播放状态,仍处于播放中。
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('should_boost_speed_on_side_long_press_and_restore', (
tester,
) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
final gesture = await tester.startGesture(const Offset(60, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget);
expect(find.byKey(const Key('player-progress')), findsOneWidget);
expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
expect(find.byKey(const Key('player-controls-bottom')), findsNothing);
expect(
find.descendant(
of: find.byKey(const Key('player-speed-entry')),
matching: find.text('2.0x'),
),
findsOneWidget,
);
// 加速与清屏都不改变播放状态。
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing);
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('player-speed-entry')),
matching: find.text('1.0x'),
),
findsOneWidget,
);
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
// 右侧 1/3 同样触发加速。
final rightSide = await tester.startGesture(const Offset(330, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget);
await rightSide.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing);
});
testWidgets('should_keep_previous_clear_state_after_boost', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-bottom-clear-screen')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
final gesture = await tester.startGesture(const Offset(60, 300));
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
// 本来已清屏,松手后仍保持清屏,只恢复倍速与提示。
expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing);
expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('player-speed-entry')),
matching: find.text('1.0x'),
),
findsOneWidget,
);
});
testWidgets('should_fit_player_header_on_320_width', (tester) async {
await _pumpApp(tester, size: const Size(320, 568));
await _openSeriesPlayer(tester);