feat(flutter): 播放页长按手势与加速态
This commit is contained in:
1 parent
7bb2714941
commit
96074bc5e8
11 files changed
+231
-4
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 分钟前';
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"clearScreen": "清屏",
|
||||
"exitClearScreen": "退出清屏",
|
||||
"playbackSpeed": "倍速",
|
||||
"speedBoosting": "{speed} 倍速快进中",
|
||||
"minutesAgo": "{count} 分钟前",
|
||||
"@minutesAgo": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user