merge: 合并 Seek 播放位置修复

This commit is contained in:
Zhou Weidong committed 2026-10-22 10:41:12 +08:00
commit f302b396b7
8 files changed
+352 -67

No files matched your search

@@ -0,0 +1,21 @@
# Seek 后播放位置保持
## 目标
- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。
- 拖动后立即进详情/离开也不会回退到旧进度。
- 越界位置 clamp,锁定/票据失败不写无效缓存。
## 实施步骤
1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。
2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。
3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。
4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。
5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 片尾与完成逻辑继续沿用现有 controller/播放器行为。
- 缓存仅在进程内,不改变服务端观看记录契约。
+32
View File
@@ -359,3 +359,35 @@ $ flutter test --no-pub
解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集 解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集
和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。 和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。
`pubspec.lock` 已还原,未启停服务,未操作真机。 `pubspec.lock` 已还原,未启停服务,未操作真机。
## 2026-10-22 Seek 后播放位置保持
- 已从 `develop`(`47ca214`)创建 `feature/playback-seek-position`。
- 新增播放位置 clamp;缓存、上报与详情返回统一使用 seek 完成后的 controller 位置。
- PlayerLogic 增加 pending seek 屏障,saveCurrentProgress 与离开路径会等待 seek 和写入完成。
- 监听器统一读取一次当前播放快照,避免旧位置覆盖新位置。
- 已补 seek 目标缓存、越界 clamp、seek 后立即进详情并返回的位置测试。
执行命令与原始输出:
```text
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
00:00 +5: All tests passed!
$ flutter test --no-pub test/player/player_logic_test.dart --plain-name should_cache_actual_position_after_seek_completes
00:00 +1: All tests passed!
$ flutter test --no-pub test/player/player_open_detail_test.dart
00:00 +1: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:49 +591: All tests passed!
```
解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即
进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。
`pubspec.lock` 已还原,未启停服务,未操作真机。
@@ -2,6 +2,15 @@
const Duration _defaultMaxAge = Duration(hours: 12); const Duration _defaultMaxAge = Duration(hours: 12);
/// 将播放位置限制在当前媒体时长内。
Duration clampPlaybackPosition(Duration position, Duration duration) {
if (position <= Duration.zero || duration <= Duration.zero) {
return Duration.zero;
}
if (position >= duration) return duration;
return position;
}
/// 进程内最近播放位置。 /// 进程内最近播放位置。
class CachedPlaybackPosition { class CachedPlaybackPosition {
const CachedPlaybackPosition({ const CachedPlaybackPosition({
@@ -37,13 +46,14 @@ class PlaybackPositionCache {
required Duration position, required Duration position,
required Duration duration, required Duration duration,
}) { }) {
if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) { final safePosition = clampPlaybackPosition(position, duration);
if (episodeId.isEmpty || dramaId.isEmpty || safePosition <= Duration.zero) {
return; return;
} }
_positions[episodeId] = CachedPlaybackPosition( _positions[episodeId] = CachedPlaybackPosition(
episodeId: episodeId, episodeId: episodeId,
dramaId: dramaId, dramaId: dramaId,
position: position, position: safePosition,
duration: duration, duration: duration,
updatedAt: _now(), updatedAt: _now(),
); );
@@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort {
if (!_disposed && controller.value.isInitialized) await controller.pause(); if (!_disposed && controller.value.isInitialized) await controller.pause();
} }
/// 跳转到指定播放位置。
Future<void> seekTo(Duration position) => controller.seekTo(position);
@override @override
Future<void> dispose() async { Future<void> dispose() async {
if (_disposed) return; if (_disposed) return;
+99 -36
View File
@@ -34,6 +34,12 @@ import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart'; import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart'; import 'widgets/episode_picker_sheet.dart';
typedef _PlaybackSnapshot = ({
Duration position,
Duration duration,
bool completed,
});
class PlayerLogic extends ChangeNotifier { class PlayerLogic extends ChangeNotifier {
PlayerLogic({ PlayerLogic({
required this.history, required this.history,
@@ -119,6 +125,7 @@ class PlayerLogic extends ChangeNotifier {
bool _deferredFeedNotifyScheduled = false; bool _deferredFeedNotifyScheduled = false;
int _feedJumpSerial = 0; int _feedJumpSerial = 0;
Future<void> _feedUpdateFuture = Future<void>.value(); Future<void> _feedUpdateFuture = Future<void>.value();
Future<void>? _pendingSeek;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -693,29 +700,24 @@ class PlayerLogic extends ChangeNotifier {
var completionHandled = false; var completionHandled = false;
var completionProgressSaved = false; var completionProgressSaved = false;
player.controller.addListener(() { player.controller.addListener(() {
if (session.active != player || !player.controller.value.isInitialized) { if (session.active != player) return;
return; final snapshot = _readPlaybackSnapshot(player);
} if (snapshot == null) return;
positionCache.save( _cachePlaybackPosition(drama, episode, snapshot);
episodeId: episode.id, if (snapshot.completed && !completionProgressSaved) {
dramaId: drama.id,
position: player.controller.value.position,
duration: player.controller.value.duration,
);
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true; completionProgressSaved = true;
unawaited( unawaited(
queueProgress( queueProgress(
drama, drama,
episode, episode,
player.controller.value.duration, snapshot.duration,
duration: player.controller.value.duration, duration: snapshot.duration,
finished: true, finished: true,
), ),
); );
} }
if (!completionHandled && if (!completionHandled &&
player.controller.value.isCompleted && snapshot.completed &&
session.canAutoAdvance && session.canAutoAdvance &&
session.currentIndex == index) { session.currentIndex == index) {
completionHandled = true; completionHandled = true;
@@ -723,7 +725,12 @@ class PlayerLogic extends ChangeNotifier {
} }
if (!shouldReportProgress(DateTime.now())) return; if (!shouldReportProgress(DateTime.now())) return;
unawaited( unawaited(
queueProgress(drama, episode, player.controller.value.position), queueProgress(
drama,
episode,
snapshot.position,
duration: snapshot.duration,
),
); );
}); });
} }
@@ -775,27 +782,63 @@ class PlayerLogic extends ChangeNotifier {
return pendingWrite; return pendingWrite;
} }
Future<void> saveCurrentProgress() { Future<void> saveCurrentProgress() async {
final pendingSeek = _pendingSeek;
if (pendingSeek != null) await pendingSeek;
await _saveCurrentProgressNow();
}
Future<void> _saveCurrentProgressNow() {
final player = trackedPlayer; final player = trackedPlayer;
final episode = trackedEpisode; final episode = trackedEpisode;
final drama = trackedDrama; final drama = trackedDrama;
if (player == null || if (player == null || episode == null || drama == null) return pendingWrite;
episode == null || final snapshot = _readPlaybackSnapshot(player);
drama == null || if (snapshot == null) return pendingWrite;
!player.controller.value.isInitialized) { return _persistPlaybackSnapshot(drama, episode, snapshot);
return pendingWrite; }
_PlaybackSnapshot? _readPlaybackSnapshot(VideoPlayerPort player) {
final value = player.controller.value;
if (!value.isInitialized || value.duration <= Duration.zero) return null;
return (
position: clampPlaybackPosition(value.position, value.duration),
duration: value.duration,
completed: value.isCompleted,
);
}
void _cachePlaybackPosition(
Drama drama,
Episode episode,
_PlaybackSnapshot snapshot,
) {
if (session.isLocked ||
ticketError != null ||
blockedEpisodeId == episode.id) {
return;
} }
positionCache.save( positionCache.save(
episodeId: episode.id, episodeId: episode.id,
dramaId: drama.id, dramaId: drama.id,
position: player.controller.value.position, position: snapshot.position,
duration: player.controller.value.duration, duration: snapshot.duration,
); );
}
Future<void> _persistPlaybackSnapshot(
Drama drama,
Episode episode,
_PlaybackSnapshot snapshot, {
bool finished = false,
}) {
_cachePlaybackPosition(drama, episode, snapshot);
return queueProgress( return queueProgress(
drama, drama,
episode, episode,
player.controller.value.position, snapshot.position,
duration: player.controller.value.duration, duration: snapshot.duration,
finished: finished,
); );
} }
@@ -856,23 +899,44 @@ class PlayerLogic extends ChangeNotifier {
BuildContext context, BuildContext context,
VideoPlayerPort player, VideoPlayerPort player,
Duration position, Duration position,
) {
final operation = _performSeek(context, player, position);
_pendingSeek = operation;
return operation.whenComplete(() {
if (identical(_pendingSeek, operation)) _pendingSeek = null;
});
}
Future<void> _performSeek(
BuildContext context,
VideoPlayerPort player,
Duration position,
) async { ) async {
if (scrubbingPlayer != player || session.active != player) return; if (scrubbingPlayer != player || session.active != player) return;
try { try {
await session.playbackSettled; await session.playbackSettled;
if (scrubbingPlayer != player || session.active != player) return; if (scrubbingPlayer != player || session.active != player) return;
await player.controller.seekTo(position); final duration = player.controller.value.duration;
if (session.active == player) { final target = clampPlaybackPosition(position, duration);
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 if (target <= Duration.zero) return;
resumeAfterSeek(); await player.seekTo(target);
await saveCurrentProgress(); if (session.active != player) return;
} final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
final entry = queue.entryAt(session.currentIndex);
final save = _persistPlaybackSnapshot(
entry.drama,
entry.episode,
snapshot,
);
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。
resumeAfterSeek();
await save;
} catch (_) { } catch (_) {
if (context.mounted && session.active == player) { if (context.mounted && session.active == player) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of( ScaffoldMessenger.of(context)
context, .showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
} }
} finally { } finally {
if (scrubbingPlayer == player) cancelScrub(); if (scrubbingPlayer == player) cancelScrub();
@@ -1013,9 +1077,8 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled; await session.playbackSettled;
await saveCurrentProgress(); await saveCurrentProgress();
if (!context.mounted) return; if (!context.mounted) return;
final action = await Navigator.of( final action = await Navigator.of(context)
context, .push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return; if (!context.mounted) return;
if (action == MyPageAction.discover) { if (action == MyPageAction.discover) {
await showDiscover(context); await showDiscover(context);
@@ -4,6 +4,42 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
void main() { void main() {
test('should_clamp_invalid_or_out_of_range_position_when_caching', () {
expect(
clampPlaybackPosition(
const Duration(seconds: -1),
const Duration(seconds: 10),
),
Duration.zero,
);
expect(
clampPlaybackPosition(
const Duration(seconds: 12),
const Duration(seconds: 10),
),
const Duration(seconds: 10),
);
expect(
clampPlaybackPosition(
const Duration(seconds: 5),
const Duration(seconds: 10),
),
const Duration(seconds: 5),
);
final cache = PlaybackPositionCache();
cache.save(
episodeId: 'episode-3',
dramaId: 'drama-a',
position: const Duration(minutes: 4),
duration: const Duration(minutes: 3),
);
expect(
cache.read('episode-3', dramaId: 'drama-a')?.position,
const Duration(minutes: 3),
);
});
test('should_store_and_read_recent_position_when_drama_matches', () { test('should_store_and_read_recent_position_when_drama_matches', () {
final now = DateTime(2026, 10, 22, 12); final now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now); final cache = PlaybackPositionCache(now: () => now);
+81
View File
@@ -218,6 +218,75 @@ void main() {
logic.dispose(); logic.dispose();
await _drain(logic); await _drain(logic);
}); });
testWidgets(
'should_cache_actual_position_after_seek_completes',
(tester) async {
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (pageContext) {
context = pageContext;
return const Scaffold(body: SizedBox.expand());
},
),
),
);
final cache = PlaybackPositionCache();
final history = FakeWatchHistory();
final factory = _VideoRecordingFactory();
late PlayerLogic logic;
await tester.runAsync(() async {
logic = _logic(history, positionCache: cache);
logic.attach(
PlayerPage(drama: _drama('signal'), initialIndex: 0),
isMounted: () => true,
);
logic.start(
factory: factory.create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
});
final player = logic.session.active! as _VideoRecordingPlayer;
logic.beginScrub(player);
await tester.runAsync(
() => logic.seekTo(
context,
player,
const Duration(seconds: 42),
),
);
await tester.runAsync(() => logic.pendingWrite);
expect(
cache.read('signal-1', dramaId: 'signal')?.position,
const Duration(seconds: 42),
);
expect(history.writes.last, 'signal-1|42000|false');
logic.beginScrub(player);
await tester.runAsync(
() => logic.seekTo(
context,
player,
const Duration(minutes: 2),
),
);
await tester.runAsync(() => logic.pendingWrite);
expect(player.controller.value.position, const Duration(minutes: 1));
expect(
cache.read('signal-1', dramaId: 'signal')?.position,
const Duration(minutes: 1),
);
expect(history.writes.last, 'signal-1|60000|false');
logic.dispose();
await tester.pump();
},
);
}); });
test('should_play_local_file_without_ticket_when_episode_is_downloaded', test('should_play_local_file_without_ticket_when_episode_is_downloaded',
@@ -1257,6 +1326,18 @@ class _VideoRecordingPlayer extends VideoPlayerPort {
@override @override
Future<void> pause() async {} Future<void> pause() async {}
@override
Future<void> seekTo(Duration position) async {
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: position,
isInitialized: current.isInitialized,
isPlaying: current.isPlaying,
);
}
@override @override
Future<void> dispose() async { Future<void> dispose() async {
if (disposed) return; if (disposed) return;
+68 -29
View File
@@ -48,7 +48,7 @@ void main() {
channel: const MethodChannel('test/player_open_detail/pip'), channel: const MethodChannel('test/player_open_detail/pip'),
), ),
); );
final factory = _RecordingFactory(); final factory = _VideoRecordingFactory();
logic.attach( logic.attach(
PlayerPage(drama: drama, initialIndex: 0), PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true, isMounted: () => true,
@@ -59,15 +59,8 @@ void main() {
final active = logic.session.active; final active = logic.session.active;
final index = logic.session.currentIndex; final index = logic.session.currentIndex;
final player = factory.created.first; final player = factory.created.first;
final playsBefore = player.plays;
final tracked = VideoPlayerPort(drama.episodes.first);
tracked.controller.value = const VideoPlayerValue(
duration: Duration(minutes: 5),
position: Duration(seconds: 37),
isInitialized: true,
);
logic logic
..trackedPlayer = tracked ..trackedPlayer = player
..trackedEpisode = drama.episodes.first ..trackedEpisode = drama.episodes.first
..trackedDrama = drama; ..trackedDrama = drama;
@@ -96,22 +89,31 @@ void main() {
), ),
); );
logic.beginScrub(player);
await logic.seekTo(
pageContext,
player,
const Duration(minutes: 1, seconds: 22),
);
unawaited(logic.openDetail(pageContext)); unawaited(logic.openDetail(pageContext));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
await _pumpSession(tester); await _pumpSession(tester);
await tester.runAsync(() => logic.session.playbackSettled);
expect(find.byType(DramaDetailPage), findsOneWidget); expect(find.byType(DramaDetailPage), findsOneWidget);
expect(logic.session.active, same(active)); expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index); expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue); expect(logic.session.userWantsPlay, isTrue);
expect(player.pauses, 1); expect(
expect(player.plays, playsBefore); player.controller.value.position,
expect(tracked.controller.value.position, const Duration(seconds: 37)); const Duration(minutes: 1, seconds: 22),
expect(history.writes, ['signal-1|37000|false']); );
expect(history.writes, ['signal-1|82000|false', 'signal-1|82000|false']);
expect( expect(
positionCache.read('signal-1', dramaId: 'signal')?.position, positionCache.read('signal-1', dramaId: 'signal')?.position,
const Duration(seconds: 37), const Duration(minutes: 1, seconds: 22),
); );
await tester.pageBack(); await tester.pageBack();
@@ -123,11 +125,14 @@ void main() {
expect(logic.session.active, same(active)); expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index); expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue); expect(logic.session.userWantsPlay, isTrue);
expect(player.plays, playsBefore + 1); expect(
expect(tracked.controller.value.position, const Duration(seconds: 37)); player.controller.value.position,
const Duration(minutes: 1, seconds: 22),
);
expect(history.writes, [ expect(history.writes, [
'signal-1|37000|false', 'signal-1|82000|false',
'signal-1|37000|false', 'signal-1|82000|false',
'signal-1|82000|false',
]); ]);
logic logic
@@ -136,7 +141,6 @@ void main() {
..trackedDrama = null ..trackedDrama = null
..dispose(); ..dispose();
await _pumpSession(tester); await _pumpSession(tester);
await tracked.dispose();
}, },
); );
} }
@@ -164,29 +168,64 @@ Future<void> _pumpSession(WidgetTester tester) async {
} }
} }
class _RecordingFactory { class _VideoRecordingFactory {
final List<_RecordingPlayer> created = []; final List<_VideoRecordingPlayer> created = [];
PlayerPort create(Episode episode) { PlayerPort create(Episode episode) {
final player = _RecordingPlayer(); final player = _VideoRecordingPlayer(episode);
created.add(player); created.add(player);
return player; return player;
} }
} }
class _RecordingPlayer implements PlayerPort { class _VideoRecordingPlayer extends VideoPlayerPort {
int plays = 0; _VideoRecordingPlayer(super.episode);
int pauses = 0;
@override @override
Future<void> initialize() async {} Future<void> initialize() async {
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 5),
size: Size(1280, 720),
position: Duration(seconds: 1),
isInitialized: true,
);
}
@override @override
Future<void> play() async => plays++; Future<void> play() async {
_setPlaying(true);
}
@override @override
Future<void> pause() async => pauses++; Future<void> pause() async {
_setPlaying(false);
}
@override @override
Future<void> dispose() async {} Future<void> seekTo(Duration position) async {
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: position,
isInitialized: current.isInitialized,
isPlaying: current.isPlaying,
);
}
void _setPlaying(bool playing) {
final current = controller.value;
controller.value = VideoPlayerValue(
duration: current.duration,
size: current.size,
position: current.position,
isInitialized: current.isInitialized,
isPlaying: playing,
);
}
@override
Future<void> dispose() async {
await controller.dispose();
}
} }