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 缓存集
和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。
`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);
/// 将播放位置限制在当前媒体时长内。
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 {
const CachedPlaybackPosition({
@@ -37,13 +46,14 @@ class PlaybackPositionCache {
required Duration position,
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;
}
_positions[episodeId] = CachedPlaybackPosition(
episodeId: episodeId,
dramaId: dramaId,
position: position,
position: safePosition,
duration: duration,
updatedAt: _now(),
);
@@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort {
if (!_disposed && controller.value.isInitialized) await controller.pause();
}
/// 跳转到指定播放位置。
Future<void> seekTo(Duration position) => controller.seekTo(position);
@override
Future<void> dispose() async {
if (_disposed) return;
+99 -36
View File
@@ -34,6 +34,12 @@ import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart';
typedef _PlaybackSnapshot = ({
Duration position,
Duration duration,
bool completed,
});
class PlayerLogic extends ChangeNotifier {
PlayerLogic({
required this.history,
@@ -119,6 +125,7 @@ class PlayerLogic extends ChangeNotifier {
bool _deferredFeedNotifyScheduled = false;
int _feedJumpSerial = 0;
Future<void> _feedUpdateFuture = Future<void>.value();
Future<void>? _pendingSeek;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -693,29 +700,24 @@ class PlayerLogic extends ChangeNotifier {
var completionHandled = false;
var completionProgressSaved = false;
player.controller.addListener(() {
if (session.active != player || !player.controller.value.isInitialized) {
return;
}
positionCache.save(
episodeId: episode.id,
dramaId: drama.id,
position: player.controller.value.position,
duration: player.controller.value.duration,
);
if (player.controller.value.isCompleted && !completionProgressSaved) {
if (session.active != player) return;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return;
_cachePlaybackPosition(drama, episode, snapshot);
if (snapshot.completed && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
snapshot.duration,
duration: snapshot.duration,
finished: true,
),
);
}
if (!completionHandled &&
player.controller.value.isCompleted &&
snapshot.completed &&
session.canAutoAdvance &&
session.currentIndex == index) {
completionHandled = true;
@@ -723,7 +725,12 @@ class PlayerLogic extends ChangeNotifier {
}
if (!shouldReportProgress(DateTime.now())) return;
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;
}
Future<void> saveCurrentProgress() {
Future<void> saveCurrentProgress() async {
final pendingSeek = _pendingSeek;
if (pendingSeek != null) await pendingSeek;
await _saveCurrentProgressNow();
}
Future<void> _saveCurrentProgressNow() {
final player = trackedPlayer;
final episode = trackedEpisode;
final drama = trackedDrama;
if (player == null ||
episode == null ||
drama == null ||
!player.controller.value.isInitialized) {
return pendingWrite;
if (player == null || episode == null || drama == null) return pendingWrite;
final snapshot = _readPlaybackSnapshot(player);
if (snapshot == null) return pendingWrite;
return _persistPlaybackSnapshot(drama, episode, snapshot);
}
_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(
episodeId: episode.id,
dramaId: drama.id,
position: player.controller.value.position,
duration: player.controller.value.duration,
position: snapshot.position,
duration: snapshot.duration,
);
}
Future<void> _persistPlaybackSnapshot(
Drama drama,
Episode episode,
_PlaybackSnapshot snapshot, {
bool finished = false,
}) {
_cachePlaybackPosition(drama, episode, snapshot);
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
snapshot.position,
duration: snapshot.duration,
finished: finished,
);
}
@@ -856,23 +899,44 @@ class PlayerLogic extends ChangeNotifier {
BuildContext context,
VideoPlayerPort player,
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 {
if (scrubbingPlayer != player || session.active != player) return;
try {
await session.playbackSettled;
if (scrubbingPlayer != player || session.active != player) return;
await player.controller.seekTo(position);
if (session.active == player) {
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。
resumeAfterSeek();
await saveCurrentProgress();
}
final duration = player.controller.value.duration;
final target = clampPlaybackPosition(position, duration);
if (target <= Duration.zero) return;
await player.seekTo(target);
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 (_) {
if (context.mounted && session.active == player) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(l10n.seekFailed)));
}
} finally {
if (scrubbingPlayer == player) cancelScrub();
@@ -1013,9 +1077,8 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(
context,
).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
final action = await Navigator.of(context)
.push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
if (!context.mounted) return;
if (action == MyPageAction.discover) {
await showDiscover(context);
@@ -4,6 +4,42 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
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', () {
final now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now);
+81
View File
@@ -218,6 +218,75 @@ void main() {
logic.dispose();
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',
@@ -1257,6 +1326,18 @@ class _VideoRecordingPlayer extends VideoPlayerPort {
@override
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
Future<void> dispose() async {
if (disposed) return;
+68 -29
View File
@@ -48,7 +48,7 @@ void main() {
channel: const MethodChannel('test/player_open_detail/pip'),
),
);
final factory = _RecordingFactory();
final factory = _VideoRecordingFactory();
logic.attach(
PlayerPage(drama: drama, initialIndex: 0),
isMounted: () => true,
@@ -59,15 +59,8 @@ void main() {
final active = logic.session.active;
final index = logic.session.currentIndex;
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
..trackedPlayer = tracked
..trackedPlayer = player
..trackedEpisode = drama.episodes.first
..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));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await _pumpSession(tester);
await tester.runAsync(() => logic.session.playbackSettled);
expect(find.byType(DramaDetailPage), findsOneWidget);
expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue);
expect(player.pauses, 1);
expect(player.plays, playsBefore);
expect(tracked.controller.value.position, const Duration(seconds: 37));
expect(history.writes, ['signal-1|37000|false']);
expect(
player.controller.value.position,
const Duration(minutes: 1, seconds: 22),
);
expect(history.writes, ['signal-1|82000|false', 'signal-1|82000|false']);
expect(
positionCache.read('signal-1', dramaId: 'signal')?.position,
const Duration(seconds: 37),
const Duration(minutes: 1, seconds: 22),
);
await tester.pageBack();
@@ -123,11 +125,14 @@ void main() {
expect(logic.session.active, same(active));
expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue);
expect(player.plays, playsBefore + 1);
expect(tracked.controller.value.position, const Duration(seconds: 37));
expect(
player.controller.value.position,
const Duration(minutes: 1, seconds: 22),
);
expect(history.writes, [
'signal-1|37000|false',
'signal-1|37000|false',
'signal-1|82000|false',
'signal-1|82000|false',
'signal-1|82000|false',
]);
logic
@@ -136,7 +141,6 @@ void main() {
..trackedDrama = null
..dispose();
await _pumpSession(tester);
await tracked.dispose();
},
);
}
@@ -164,29 +168,64 @@ Future<void> _pumpSession(WidgetTester tester) async {
}
}
class _RecordingFactory {
final List<_RecordingPlayer> created = [];
class _VideoRecordingFactory {
final List<_VideoRecordingPlayer> created = [];
PlayerPort create(Episode episode) {
final player = _RecordingPlayer();
final player = _VideoRecordingPlayer(episode);
created.add(player);
return player;
}
}
class _RecordingPlayer implements PlayerPort {
int plays = 0;
int pauses = 0;
class _VideoRecordingPlayer extends VideoPlayerPort {
_VideoRecordingPlayer(super.episode);
@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
Future<void> play() async => plays++;
Future<void> play() async {
_setPlaying(true);
}
@override
Future<void> pause() async => pauses++;
Future<void> pause() async {
_setPlaying(false);
}
@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();
}
}