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",
|
"clearScreen": "Clear screen",
|
||||||
"exitClearScreen": "Exit clear screen",
|
"exitClearScreen": "Exit clear screen",
|
||||||
"playbackSpeed": "Playback speed",
|
"playbackSpeed": "Playback speed",
|
||||||
|
"speedBoosting": "{speed}x speed",
|
||||||
|
"@speedBoosting": {
|
||||||
|
"placeholders": {
|
||||||
|
"speed": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"minutesAgo": "{count} min ago",
|
"minutesAgo": "{count} min ago",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"clearScreen": "Bersihkan layar",
|
"clearScreen": "Bersihkan layar",
|
||||||
"exitClearScreen": "Keluar dari layar bersih",
|
"exitClearScreen": "Keluar dari layar bersih",
|
||||||
"playbackSpeed": "Kecepatan putar",
|
"playbackSpeed": "Kecepatan putar",
|
||||||
|
"speedBoosting": "{speed}x kecepatan",
|
||||||
"minutesAgo": "{count} mnt lalu",
|
"minutesAgo": "{count} mnt lalu",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -382,6 +382,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Playback speed'**
|
/// **'Playback speed'**
|
||||||
String get playbackSpeed;
|
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.
|
/// No description provided for @minutesAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get playbackSpeed => 'Playback speed';
|
String get playbackSpeed => 'Playback speed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String speedBoosting(String speed) {
|
||||||
|
return '${speed}x speed';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count min ago';
|
return '$count min ago';
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get playbackSpeed => 'Kecepatan putar';
|
String get playbackSpeed => 'Kecepatan putar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String speedBoosting(String speed) {
|
||||||
|
return '${speed}x kecepatan';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count mnt lalu';
|
return '$count mnt lalu';
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get playbackSpeed => '倍速';
|
String get playbackSpeed => '倍速';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String speedBoosting(String speed) {
|
||||||
|
return '$speed 倍速快进中';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count 分钟前';
|
return '$count 分钟前';
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"clearScreen": "清屏",
|
"clearScreen": "清屏",
|
||||||
"exitClearScreen": "退出清屏",
|
"exitClearScreen": "退出清屏",
|
||||||
"playbackSpeed": "倍速",
|
"playbackSpeed": "倍速",
|
||||||
|
"speedBoosting": "{speed} 倍速快进中",
|
||||||
"minutesAgo": "{count} 分钟前",
|
"minutesAgo": "{count} 分钟前",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import '../../core/data/repositories/playback_repository.dart';
|
|||||||
|
|
||||||
part 'player_view_actions.dart';
|
part 'player_view_actions.dart';
|
||||||
|
|
||||||
|
const double _boostPlaybackSpeed = 2.0;
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
const PlayerPage({
|
const PlayerPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -82,6 +84,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
late final PlayerLogic logic;
|
late final PlayerLogic logic;
|
||||||
bool _chromeVisible = true;
|
bool _chromeVisible = true;
|
||||||
double _playbackSpeed = 1.0;
|
double _playbackSpeed = 1.0;
|
||||||
|
bool _boosting = false;
|
||||||
|
double? _speedBeforeBoost;
|
||||||
|
bool? _chromeBeforeBoost;
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
final Map<String, int> _favoriteDelta = {};
|
final Map<String, int> _favoriteDelta = {};
|
||||||
@@ -130,6 +135,34 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
setState(() => _chromeVisible = false);
|
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 跟随)。
|
/// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。
|
||||||
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
|
void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -397,6 +430,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
playing: logic.session.userWantsPlay,
|
playing: logic.session.userWantsPlay,
|
||||||
playbackSpeed: _playbackSpeed,
|
playbackSpeed: _playbackSpeed,
|
||||||
totalEpisodes: logic.visibleIndices.length,
|
totalEpisodes: logic.visibleIndices.length,
|
||||||
|
boosting: _boosting,
|
||||||
),
|
),
|
||||||
actions: PlayerOverlayActions(
|
actions: PlayerOverlayActions(
|
||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
@@ -419,6 +453,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: null,
|
||||||
|
onLongPressCenter: () => _openMore(context, episode),
|
||||||
|
onBoostStart: _startBoost,
|
||||||
|
onBoostEnd: _endBoost,
|
||||||
),
|
),
|
||||||
controlsVisible: _chromeVisible,
|
controlsVisible: _chromeVisible,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const double _topButtonSize = 36;
|
|||||||
const double _topBarHeight = 58;
|
const double _topBarHeight = 58;
|
||||||
const double _railButtonSize = 34;
|
const double _railButtonSize = 34;
|
||||||
const double _railGap = 18;
|
const double _railGap = 18;
|
||||||
|
const double _boostHintIconSize = 16;
|
||||||
|
const double _boostHintFontSize = 13;
|
||||||
const List<Shadow> _playerTextShadows = [
|
const List<Shadow> _playerTextShadows = [
|
||||||
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
|
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
|
||||||
];
|
];
|
||||||
@@ -45,10 +47,16 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: LayoutBuilder(
|
||||||
key: const Key('player-video-tap'),
|
builder: (context, constraints) => GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
key: const Key('player-video-tap'),
|
||||||
onTap: actions.onTogglePlay,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: actions.onTogglePlay,
|
||||||
|
onLongPressStart: (details) =>
|
||||||
|
_handleLongPressStart(details, constraints.maxWidth),
|
||||||
|
onLongPressEnd: (_) => actions.onBoostEnd(),
|
||||||
|
onLongPressCancel: actions.onBoostEnd,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_top(context),
|
_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) {
|
Widget _rail(BuildContext context) {
|
||||||
final compact = MediaQuery.sizeOf(context).height < 700;
|
final compact = MediaQuery.sizeOf(context).height < 700;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
@@ -149,6 +168,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (controlsVisible) _bottomInfo(context),
|
if (controlsVisible) _bottomInfo(context),
|
||||||
|
if (data.boosting) _boostHint(),
|
||||||
_bottomProgress(),
|
_bottomProgress(),
|
||||||
_bottomActions(),
|
_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) {
|
Widget _bottomInfo(BuildContext context) {
|
||||||
final series = localizedDramaTitle(data.l10n, data.drama);
|
final series = localizedDramaTitle(data.l10n, data.drama);
|
||||||
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
|
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PlayerOverlayData {
|
|||||||
required this.playing,
|
required this.playing,
|
||||||
required this.playbackSpeed,
|
required this.playbackSpeed,
|
||||||
required this.totalEpisodes,
|
required this.totalEpisodes,
|
||||||
|
required this.boosting,
|
||||||
this.authorNote = '',
|
this.authorNote = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ class PlayerOverlayData {
|
|||||||
/// 可见集数量。
|
/// 可见集数量。
|
||||||
final int totalEpisodes;
|
final int totalEpisodes;
|
||||||
|
|
||||||
|
/// 是否处于长按加速态。
|
||||||
|
final bool boosting;
|
||||||
|
|
||||||
/// 作者声明;没有后端字段时为空。
|
/// 作者声明;没有后端字段时为空。
|
||||||
final String authorNote;
|
final String authorNote;
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,9 @@ class PlayerOverlayActions {
|
|||||||
required this.onMore,
|
required this.onMore,
|
||||||
required this.onScrubStart,
|
required this.onScrubStart,
|
||||||
required this.onSeek,
|
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 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);
|
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 {
|
testWidgets('should_fit_player_header_on_320_width', (tester) async {
|
||||||
await _pumpApp(tester, size: const Size(320, 568));
|
await _pumpApp(tester, size: const Size(320, 568));
|
||||||
await _openSeriesPlayer(tester);
|
await _openSeriesPlayer(tester);
|
||||||
|
|||||||
Reference in new issue
Block a user