merge: 倍速改为底部弹窗选择
This commit is contained in:
commit
87628e7d10
10 files changed
+194
-6
No files matched your search
@@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"clearScreen": "Clear screen",
|
"clearScreen": "Clear screen",
|
||||||
"exitClearScreen": "Exit clear screen",
|
"exitClearScreen": "Exit clear screen",
|
||||||
|
"playbackSpeed": "Playback speed",
|
||||||
"minutesAgo": "{count} min ago",
|
"minutesAgo": "{count} min ago",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"clearScreen": "Bersihkan layar",
|
"clearScreen": "Bersihkan layar",
|
||||||
"exitClearScreen": "Keluar dari layar bersih",
|
"exitClearScreen": "Keluar dari layar bersih",
|
||||||
|
"playbackSpeed": "Kecepatan putar",
|
||||||
"minutesAgo": "{count} mnt lalu",
|
"minutesAgo": "{count} mnt lalu",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Exit clear screen'**
|
/// **'Exit clear screen'**
|
||||||
String get exitClearScreen;
|
String get exitClearScreen;
|
||||||
|
|
||||||
|
/// No description provided for @playbackSpeed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playback speed'**
|
||||||
|
String get playbackSpeed;
|
||||||
|
|
||||||
/// No description provided for @minutesAgo.
|
/// No description provided for @minutesAgo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get exitClearScreen => 'Exit clear screen';
|
String get exitClearScreen => 'Exit clear screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => 'Playback speed';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count min ago';
|
return '$count min ago';
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get exitClearScreen => 'Keluar dari layar bersih';
|
String get exitClearScreen => 'Keluar dari layar bersih';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => 'Kecepatan putar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count mnt lalu';
|
return '$count mnt lalu';
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get exitClearScreen => '退出清屏';
|
String get exitClearScreen => '退出清屏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get playbackSpeed => '倍速';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String minutesAgo(int count) {
|
String minutesAgo(int count) {
|
||||||
return '$count 分钟前';
|
return '$count 分钟前';
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"clearScreen": "清屏",
|
"clearScreen": "清屏",
|
||||||
"exitClearScreen": "退出清屏",
|
"exitClearScreen": "退出清屏",
|
||||||
|
"playbackSpeed": "倍速",
|
||||||
"minutesAgo": "{count} 分钟前",
|
"minutesAgo": "{count} 分钟前",
|
||||||
"@minutesAgo": {
|
"@minutesAgo": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
List<Episode> sessionEpisodes = const [];
|
List<Episode> sessionEpisodes = const [];
|
||||||
int pageRequest = 0;
|
int pageRequest = 0;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
|
double playbackSpeed = 1.0;
|
||||||
bool stoppedAtFeedEnd = false;
|
bool stoppedAtFeedEnd = false;
|
||||||
VideoPlayerPort? scrubbingPlayer;
|
VideoPlayerPort? scrubbingPlayer;
|
||||||
bool pip = false;
|
bool pip = false;
|
||||||
@@ -341,6 +342,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||||
if (player is! VideoPlayerPort) return;
|
if (player is! VideoPlayerPort) return;
|
||||||
try {
|
try {
|
||||||
|
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||||
await pendingWrite;
|
await pendingWrite;
|
||||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||||
if (resumePoint == null) await loadResumePoint();
|
if (resumePoint == null) await loadResumePoint();
|
||||||
@@ -360,6 +362,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final index = session.currentIndex;
|
final index = session.currentIndex;
|
||||||
final drama = queue.entryAt(index).drama;
|
final drama = queue.entryAt(index).drama;
|
||||||
await player.controller.setVolume(muted ? 0 : 1);
|
await player.controller.setVolume(muted ? 0 : 1);
|
||||||
|
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||||
if (session.active != player) return;
|
if (session.active != player) return;
|
||||||
trackedPlayer = player;
|
trackedPlayer = player;
|
||||||
trackedEpisode = episode;
|
trackedEpisode = episode;
|
||||||
@@ -787,6 +790,15 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setPlaybackSpeed(double value) async {
|
||||||
|
playbackSpeed = value;
|
||||||
|
final active = session.active;
|
||||||
|
if (active is VideoPlayerPort && active.controller.value.isInitialized) {
|
||||||
|
await active.controller.setPlaybackSpeed(value);
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> showComments(BuildContext context, Drama drama) async {
|
Future<void> showComments(BuildContext context, Drama drama) async {
|
||||||
session.setOverlayOpen(true);
|
session.setOverlayOpen(true);
|
||||||
await session.playbackSettled;
|
await session.playbackSettled;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
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;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -384,6 +385,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
? () => unawaited(logic.playNextEpisode())
|
? () => unawaited(logic.playNextEpisode())
|
||||||
: null,
|
: null,
|
||||||
playing: logic.session.userWantsPlay,
|
playing: logic.session.userWantsPlay,
|
||||||
|
playbackSpeed: _playbackSpeed,
|
||||||
|
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
||||||
controlsVisible: _chromeVisible,
|
controlsVisible: _chromeVisible,
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onExitClear: () => setState(() => _chromeVisible = true),
|
onExitClear: () => setState(() => _chromeVisible = true),
|
||||||
@@ -469,6 +472,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openSpeedSheet(BuildContext context) async {
|
||||||
|
final selected = await showModalBottomSheet<double>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
|
||||||
|
);
|
||||||
|
if (selected == null || !mounted) return;
|
||||||
|
setState(() => _playbackSpeed = selected);
|
||||||
|
await logic.setPlaybackSpeed(selected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
||||||
@@ -602,6 +617,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
required this.liked,
|
required this.liked,
|
||||||
required this.saved,
|
required this.saved,
|
||||||
required this.playing,
|
required this.playing,
|
||||||
|
required this.playbackSpeed,
|
||||||
|
required this.onSpeedTap,
|
||||||
required this.controlsVisible,
|
required this.controlsVisible,
|
||||||
required this.onTogglePlay,
|
required this.onTogglePlay,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
@@ -626,6 +643,8 @@ class _PlaybackOverlay extends StatefulWidget {
|
|||||||
final bool liked;
|
final bool liked;
|
||||||
final bool saved;
|
final bool saved;
|
||||||
final bool playing;
|
final bool playing;
|
||||||
|
final double playbackSpeed;
|
||||||
|
final VoidCallback onSpeedTap;
|
||||||
final bool controlsVisible;
|
final bool controlsVisible;
|
||||||
final VoidCallback onTogglePlay;
|
final VoidCallback onTogglePlay;
|
||||||
final VoidCallback onClear;
|
final VoidCallback onClear;
|
||||||
@@ -667,14 +686,28 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const Positioned(
|
Positioned(
|
||||||
right: 10,
|
right: 10,
|
||||||
bottom: 14,
|
bottom: 14,
|
||||||
child: Text(
|
child: Material(
|
||||||
'1.0x',
|
color: Colors.transparent,
|
||||||
style: TextStyle(
|
child: InkWell(
|
||||||
color: Color(0xFFDDDDDD),
|
key: const Key('player-speed'),
|
||||||
fontSize: 9,
|
onTap: widget.onSpeedTap,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_speedLabel(widget.playbackSpeed),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFDDDDDD),
|
||||||
|
fontSize: 9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -920,6 +953,69 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
|
||||||
|
String _speedLabel(double value) => '${value.toString()}x';
|
||||||
|
|
||||||
|
class _PlaybackSpeedSheet extends StatelessWidget {
|
||||||
|
const _PlaybackSpeedSheet({required this.selected});
|
||||||
|
|
||||||
|
final double selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
key: const Key('player-speed-sheet'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
l10n.playbackSpeed,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final value in _speedOptions)
|
||||||
|
ListTile(
|
||||||
|
key: Key('player-speed-${value.toString()}'),
|
||||||
|
dense: true,
|
||||||
|
tileColor: value == selected
|
||||||
|
? AppColors.blue.withValues(alpha: 0.12)
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
_speedLabel(value),
|
||||||
|
style: TextStyle(
|
||||||
|
color: value == selected ? AppColors.blue : AppColors.cream,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: value == selected
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: value == selected
|
||||||
|
? const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
color: AppColors.blue,
|
||||||
|
size: 18,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TopAction extends StatelessWidget {
|
class _TopAction extends StatelessWidget {
|
||||||
const _TopAction({
|
const _TopAction({
|
||||||
super.key,
|
super.key,
|
||||||
|
|||||||
@@ -247,6 +247,68 @@ void main() {
|
|||||||
expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing);
|
expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_speed_sheet_and_apply_selected_speed', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('1.0x'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed')));
|
||||||
|
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']) {
|
||||||
|
expect(find.byKey(Key('player-speed-$value')), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed-1.5')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('player-speed-sheet')), findsNothing);
|
||||||
|
expect(find.text('1.5x'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester);
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.binding.handlePopRoute();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('drawer-open-series')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('player-speed-1.5')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('1.5x'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-speed')), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('player-exit-clear-screen')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const Key('player-speed')), findsOneWidget);
|
||||||
|
expect(find.text('1.5x'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(0, -550));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('1.5x'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async {
|
testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async {
|
||||||
await _pumpApp(tester);
|
await _pumpApp(tester);
|
||||||
await tester.tap(find.byKey(const Key('tab-home')));
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
|||||||
Reference in new issue
Block a user