merge: 合并 Seek 播放位置修复
This commit is contained in:
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/播放器行为。
|
||||||
|
- 缓存仅在进程内,不改变服务端观看记录契约。
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user