merge: 合并播放位置连续性修复
This commit is contained in:
commit
47ca21406a
16 files changed
+464
-5
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
# 播放位置连续性
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。
|
||||||
|
- 新增进程内最近播放位置缓存,优先于服务端旧记录。
|
||||||
|
- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。
|
||||||
|
2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。
|
||||||
|
3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。
|
||||||
|
4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。
|
||||||
|
5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。
|
||||||
|
6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。
|
||||||
|
7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
|
||||||
|
|
||||||
|
## 风险 / 待确认
|
||||||
|
|
||||||
|
- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。
|
||||||
|
- 片尾附近继续复用现有 `clampResumePosition` 完成保护。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。
|
||||||
|
- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。
|
||||||
|
- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。
|
||||||
|
- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。
|
||||||
|
- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。
|
||||||
|
- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。
|
||||||
|
- 未启停服务,未操作真机。
|
||||||
@@ -326,3 +326,36 @@ $ flutter test --no-pub
|
|||||||
解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停
|
解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停
|
||||||
均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原,
|
均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原,
|
||||||
未启停服务,未操作真机。
|
未启停服务,未操作真机。
|
||||||
|
|
||||||
|
## 2026-10-22 播放位置连续性
|
||||||
|
|
||||||
|
- 已从 `develop`(`fe49c6d`)创建 `feature/playback-position-continuity`。
|
||||||
|
- 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId、position、duration、updatedAt。
|
||||||
|
- App Provider 注入单一缓存实例,PlayerLogic 与详情页共享。
|
||||||
|
- 进度监听、保存进度与进入详情前更新缓存;loadResumePoint 优先缓存,未命中再回源。
|
||||||
|
- 详情页继续观看与 CTA 优先使用最近缓存集,返回首页保持当前播放器位置。
|
||||||
|
- 跨剧缓存不命中;片尾保护继续复用 `clampResumePosition`。
|
||||||
|
|
||||||
|
执行命令与原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ flutter test --no-pub test/playback/playback_position_cache_test.dart
|
||||||
|
00:00 +4: All tests passed!
|
||||||
|
|
||||||
|
$ flutter test --no-pub test/player/player_logic_test.dart
|
||||||
|
00:00 +38: 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:53 +589: All tests passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集
|
||||||
|
和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。
|
||||||
|
`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||||
@@ -42,6 +42,7 @@ import 'core/data/services/favorite_controller.dart';
|
|||||||
import 'core/data/services/interaction_controller.dart';
|
import 'core/data/services/interaction_controller.dart';
|
||||||
import 'core/data/services/locale_controller.dart';
|
import 'core/data/services/locale_controller.dart';
|
||||||
import 'core/data/services/event_tracker.dart';
|
import 'core/data/services/event_tracker.dart';
|
||||||
|
import 'core/data/services/playback_position_cache.dart';
|
||||||
import 'core/data/services/playback_session.dart';
|
import 'core/data/services/playback_session.dart';
|
||||||
import 'core/data/services/video_player_port.dart';
|
import 'core/data/services/video_player_port.dart';
|
||||||
import 'core/data/services/wallet_controller.dart';
|
import 'core/data/services/wallet_controller.dart';
|
||||||
@@ -72,6 +73,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.watchHistoryRepository,
|
this.watchHistoryRepository,
|
||||||
this.favoriteRepository,
|
this.favoriteRepository,
|
||||||
this.playbackRepository,
|
this.playbackRepository,
|
||||||
|
this.playbackPositionCache,
|
||||||
this.commentRepository,
|
this.commentRepository,
|
||||||
this.interactionRepository,
|
this.interactionRepository,
|
||||||
this.noticeRepository,
|
this.noticeRepository,
|
||||||
@@ -91,6 +93,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final WatchHistoryRepository? watchHistoryRepository;
|
final WatchHistoryRepository? watchHistoryRepository;
|
||||||
final FavoriteRepository? favoriteRepository;
|
final FavoriteRepository? favoriteRepository;
|
||||||
final PlaybackRepository? playbackRepository;
|
final PlaybackRepository? playbackRepository;
|
||||||
|
final PlaybackPositionCache? playbackPositionCache;
|
||||||
final CommentRepository? commentRepository;
|
final CommentRepository? commentRepository;
|
||||||
final InteractionRepository? interactionRepository;
|
final InteractionRepository? interactionRepository;
|
||||||
final NoticeRepository? noticeRepository;
|
final NoticeRepository? noticeRepository;
|
||||||
@@ -146,6 +149,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
widget.playbackRepository ??
|
widget.playbackRepository ??
|
||||||
HttpPlaybackRepository(PlaybackApi(_apiClient));
|
HttpPlaybackRepository(PlaybackApi(_apiClient));
|
||||||
|
|
||||||
|
late final PlaybackPositionCache _playbackPositionCache =
|
||||||
|
widget.playbackPositionCache ?? PlaybackPositionCache();
|
||||||
|
|
||||||
late final FavoriteController _favoriteController = FavoriteController(
|
late final FavoriteController _favoriteController = FavoriteController(
|
||||||
_favorites,
|
_favorites,
|
||||||
);
|
);
|
||||||
@@ -240,6 +246,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
Provider<PlayerFactory>(create: (_) => widget.playerFactory),
|
||||||
Provider<CatalogRepository>.value(value: _catalogRepository),
|
Provider<CatalogRepository>.value(value: _catalogRepository),
|
||||||
Provider<PlaybackRepository>.value(value: _playback),
|
Provider<PlaybackRepository>.value(value: _playback),
|
||||||
|
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
|
||||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||||
Provider<FavoriteRepository>.value(value: _favorites),
|
Provider<FavoriteRepository>.value(value: _favorites),
|
||||||
Provider<CommentRepository>.value(value: _commentRepository),
|
Provider<CommentRepository>.value(value: _commentRepository),
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
const Duration _defaultMaxAge = Duration(hours: 12);
|
||||||
|
|
||||||
|
/// 进程内最近播放位置。
|
||||||
|
class CachedPlaybackPosition {
|
||||||
|
const CachedPlaybackPosition({
|
||||||
|
required this.episodeId,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.position,
|
||||||
|
required this.duration,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String episodeId;
|
||||||
|
final String dramaId;
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按集缓存最近播放位置,仅当前进程有效。
|
||||||
|
class PlaybackPositionCache {
|
||||||
|
PlaybackPositionCache({
|
||||||
|
DateTime Function()? now,
|
||||||
|
this.maxAge = _defaultMaxAge,
|
||||||
|
}) : _now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
final Duration maxAge;
|
||||||
|
final Map<String, CachedPlaybackPosition> _positions = {};
|
||||||
|
|
||||||
|
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
||||||
|
void save({
|
||||||
|
required String episodeId,
|
||||||
|
required String dramaId,
|
||||||
|
required Duration position,
|
||||||
|
required Duration duration,
|
||||||
|
}) {
|
||||||
|
if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_positions[episodeId] = CachedPlaybackPosition(
|
||||||
|
episodeId: episodeId,
|
||||||
|
dramaId: dramaId,
|
||||||
|
position: position,
|
||||||
|
duration: duration,
|
||||||
|
updatedAt: _now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取同剧同集且未过期的最近位置。
|
||||||
|
CachedPlaybackPosition? read(String episodeId, {required String dramaId}) {
|
||||||
|
final entry = _positions[episodeId];
|
||||||
|
if (!_isValid(entry, dramaId)) return null;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取指定短剧最近更新的有效位置。
|
||||||
|
CachedPlaybackPosition? latestForDrama(String dramaId) {
|
||||||
|
CachedPlaybackPosition? latest;
|
||||||
|
for (final entry in _positions.values) {
|
||||||
|
if (!_isValid(entry, dramaId)) continue;
|
||||||
|
if (latest == null || !entry.updatedAt.isBefore(latest.updatedAt)) {
|
||||||
|
latest = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValid(CachedPlaybackPosition? entry, String dramaId) {
|
||||||
|
if (entry == null ||
|
||||||
|
entry.dramaId != dramaId ||
|
||||||
|
entry.position <= Duration.zero) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _now().difference(entry.updatedAt) <= maxAge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import '../../core/data/models/drama.dart';
|
|||||||
import '../../core/data/repositories/playback_repository.dart';
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import 'drama_detail_logic.dart';
|
import 'drama_detail_logic.dart';
|
||||||
|
|
||||||
class DramaDetailBinding {
|
class DramaDetailBinding {
|
||||||
@@ -12,10 +13,12 @@ class DramaDetailBinding {
|
|||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
required PlaybackRepository playback,
|
required PlaybackRepository playback,
|
||||||
required FavoriteController favorites,
|
required FavoriteController favorites,
|
||||||
|
required PlaybackPositionCache positionCache,
|
||||||
}) => DramaDetailLogic(
|
}) => DramaDetailLogic(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
history: history,
|
history: history,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
favorites: favorites,
|
favorites: favorites,
|
||||||
|
positionCache: positionCache,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
|
|||||||
import '../../core/data/repositories/playback_repository.dart';
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/data/services/download_controller.dart';
|
import '../../core/data/services/download_controller.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/wallet_controller.dart';
|
import '../../core/data/services/wallet_controller.dart';
|
||||||
import '../player/player_view.dart';
|
import '../player/player_view.dart';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class DramaDetailLogic extends ChangeNotifier {
|
|||||||
required this.history,
|
required this.history,
|
||||||
required this.playback,
|
required this.playback,
|
||||||
required this.favorites,
|
required this.favorites,
|
||||||
|
required this.positionCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||||
@@ -27,6 +29,7 @@ class DramaDetailLogic extends ChangeNotifier {
|
|||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
final PlaybackRepository playback;
|
final PlaybackRepository playback;
|
||||||
final FavoriteController favorites;
|
final FavoriteController favorites;
|
||||||
|
final PlaybackPositionCache positionCache;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
bool failed = false;
|
bool failed = false;
|
||||||
|
|
||||||
@@ -71,6 +74,23 @@ class DramaDetailLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadContinue() async {
|
Future<void> loadContinue() async {
|
||||||
|
final cached = positionCache.latestForDrama(drama.id);
|
||||||
|
if (cached != null) {
|
||||||
|
final index = _indexOf(cached.episodeId);
|
||||||
|
if (index != null) {
|
||||||
|
progress = WatchProgress(
|
||||||
|
dramaId: cached.dramaId,
|
||||||
|
episodeId: cached.episodeId,
|
||||||
|
position: cached.position,
|
||||||
|
duration: cached.duration,
|
||||||
|
updatedAt: cached.updatedAt,
|
||||||
|
);
|
||||||
|
continueIndex = index;
|
||||||
|
progressError = null;
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final record = await history.progressFor(drama.id);
|
final record = await history.progressFor(drama.id);
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
|
|||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/data/services/interaction_controller.dart';
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/platform_share_service.dart';
|
import '../../core/data/services/platform_share_service.dart';
|
||||||
import '../../core/formatting/error_text.dart';
|
import '../../core/formatting/error_text.dart';
|
||||||
import '../../core/formatting/localized_content.dart';
|
import '../../core/formatting/localized_content.dart';
|
||||||
@@ -64,6 +65,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
history: context.read<WatchHistoryRepository>(),
|
history: context.read<WatchHistoryRepository>(),
|
||||||
playback: context.read<PlaybackRepository>(),
|
playback: context.read<PlaybackRepository>(),
|
||||||
favorites: context.read<FavoriteController>(),
|
favorites: context.read<FavoriteController>(),
|
||||||
|
positionCache: context.read<PlaybackPositionCache>(),
|
||||||
);
|
);
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../core/data/repositories/watch_history_repository.dart';
|
|||||||
import '../../core/data/repositories/playback_repository.dart';
|
import '../../core/data/repositories/playback_repository.dart';
|
||||||
import '../../core/data/services/android_picture_in_picture.dart';
|
import '../../core/data/services/android_picture_in_picture.dart';
|
||||||
import '../../core/data/services/download_controller.dart';
|
import '../../core/data/services/download_controller.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/wallet_controller.dart';
|
import '../../core/data/services/wallet_controller.dart';
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ class PlayerBinding {
|
|||||||
PlayerLogic create({
|
PlayerLogic create({
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
required PlaybackRepository playback,
|
required PlaybackRepository playback,
|
||||||
|
required PlaybackPositionCache positionCache,
|
||||||
WalletController? wallet,
|
WalletController? wallet,
|
||||||
DownloadController? downloads,
|
DownloadController? downloads,
|
||||||
}) => PlayerLogic(
|
}) => PlayerLogic(
|
||||||
history: history,
|
history: history,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
|
positionCache: positionCache,
|
||||||
pictureInPicture: AndroidPictureInPicture(),
|
pictureInPicture: AndroidPictureInPicture(),
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../../core/data/services/android_picture_in_picture.dart';
|
|||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/data/services/download_controller.dart';
|
import '../../core/data/services/download_controller.dart';
|
||||||
import '../../core/data/services/platform_share_service.dart';
|
import '../../core/data/services/platform_share_service.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/playback_queue.dart';
|
import '../../core/data/services/playback_queue.dart';
|
||||||
import '../../core/data/services/playback_session.dart';
|
import '../../core/data/services/playback_session.dart';
|
||||||
import '../../core/data/services/video_player_port.dart';
|
import '../../core/data/services/video_player_port.dart';
|
||||||
@@ -40,8 +41,10 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
required this.pictureInPicture,
|
required this.pictureInPicture,
|
||||||
this.wallet,
|
this.wallet,
|
||||||
this.downloads,
|
this.downloads,
|
||||||
|
PlaybackPositionCache? positionCache,
|
||||||
Future<void> Function()? waitForFrame,
|
Future<void> Function()? waitForFrame,
|
||||||
}) : _waitForFrame =
|
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
||||||
|
_waitForFrame =
|
||||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
@@ -49,6 +52,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final AndroidPictureInPicture pictureInPicture;
|
final AndroidPictureInPicture pictureInPicture;
|
||||||
final WalletController? wallet;
|
final WalletController? wallet;
|
||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
|
final PlaybackPositionCache positionCache;
|
||||||
final Future<void> Function() _waitForFrame;
|
final Future<void> Function() _waitForFrame;
|
||||||
|
|
||||||
late PageController pages;
|
late PageController pages;
|
||||||
@@ -351,9 +355,22 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||||
Future<void> loadResumePoint() async {
|
Future<void> loadResumePoint() async {
|
||||||
final dramaId = queue.entryAt(initialIndex).drama.id;
|
final entry = queue.entryAt(initialIndex);
|
||||||
|
final cached = positionCache.read(
|
||||||
|
entry.episode.id,
|
||||||
|
dramaId: entry.drama.id,
|
||||||
|
);
|
||||||
|
if (cached != null) {
|
||||||
|
resumePoint = ResumePoint(
|
||||||
|
episodeId: cached.episodeId,
|
||||||
|
position: cached.position,
|
||||||
|
);
|
||||||
|
resumeLoaded = true;
|
||||||
|
if (isMounted()) notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
resumePoint = await history.resumePointFor(dramaId);
|
resumePoint = await history.resumePointFor(entry.drama.id);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 拿不到续看位置不影响播放:从当前集开头看
|
// 拿不到续看位置不影响播放:从当前集开头看
|
||||||
resumePoint = null;
|
resumePoint = null;
|
||||||
@@ -639,6 +656,18 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
await pendingWrite;
|
await pendingWrite;
|
||||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||||
if (resumePoint == null) await loadResumePoint();
|
if (resumePoint == null) await loadResumePoint();
|
||||||
|
if (resumePositionFor(episode.id) == null) {
|
||||||
|
final dramaId = _dramaIdForEpisode(episode.id);
|
||||||
|
final cached = dramaId == null
|
||||||
|
? null
|
||||||
|
: positionCache.read(episode.id, dramaId: dramaId);
|
||||||
|
if (cached != null) {
|
||||||
|
resumePoint = ResumePoint(
|
||||||
|
episodeId: cached.episodeId,
|
||||||
|
position: cached.position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
final saved = resumePositionFor(episode.id);
|
final saved = resumePositionFor(episode.id);
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
await player.controller.seekTo(
|
await player.controller.seekTo(
|
||||||
@@ -667,6 +696,12 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (session.active != player || !player.controller.value.isInitialized) {
|
if (session.active != player || !player.controller.value.isInitialized) {
|
||||||
return;
|
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 (player.controller.value.isCompleted && !completionProgressSaved) {
|
||||||
completionProgressSaved = true;
|
completionProgressSaved = true;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -750,6 +785,12 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
!player.controller.value.isInitialized) {
|
!player.controller.value.isInitialized) {
|
||||||
return pendingWrite;
|
return pendingWrite;
|
||||||
}
|
}
|
||||||
|
positionCache.save(
|
||||||
|
episodeId: episode.id,
|
||||||
|
dramaId: drama.id,
|
||||||
|
position: player.controller.value.position,
|
||||||
|
duration: player.controller.value.duration,
|
||||||
|
);
|
||||||
return queueProgress(
|
return queueProgress(
|
||||||
drama,
|
drama,
|
||||||
episode,
|
episode,
|
||||||
@@ -851,6 +892,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> openDetail(BuildContext context) async {
|
Future<void> openDetail(BuildContext context) async {
|
||||||
session.setOverlayOpen(true);
|
session.setOverlayOpen(true);
|
||||||
|
await saveCurrentProgress();
|
||||||
try {
|
try {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
@@ -865,6 +907,13 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
await saveCurrentProgress();
|
await saveCurrentProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _dramaIdForEpisode(String episodeId) {
|
||||||
|
for (final entry in queue.entries) {
|
||||||
|
if (entry.episode.id == episodeId) return entry.drama.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> openSeriesAt(
|
Future<void> openSeriesAt(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Drama drama,
|
Drama drama,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../core/data/repositories/comment_repository.dart';
|
|||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/data/services/download_controller.dart';
|
import '../../core/data/services/download_controller.dart';
|
||||||
import '../../core/data/services/interaction_controller.dart';
|
import '../../core/data/services/interaction_controller.dart';
|
||||||
|
import '../../core/data/services/playback_position_cache.dart';
|
||||||
import '../../core/data/services/playback_session.dart';
|
import '../../core/data/services/playback_session.dart';
|
||||||
import '../../core/data/services/video_player_port.dart';
|
import '../../core/data/services/video_player_port.dart';
|
||||||
import '../../core/data/services/wallet_controller.dart';
|
import '../../core/data/services/wallet_controller.dart';
|
||||||
@@ -119,6 +120,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
logic = PlayerBinding().create(
|
logic = PlayerBinding().create(
|
||||||
history: context.read<WatchHistoryRepository>(),
|
history: context.read<WatchHistoryRepository>(),
|
||||||
playback: context.read<PlaybackRepository>(),
|
playback: context.read<PlaybackRepository>(),
|
||||||
|
positionCache: context.read<PlaybackPositionCache>(),
|
||||||
wallet: widget.wallet,
|
wallet: widget.wallet,
|
||||||
downloads: widget.downloads,
|
downloads: widget.downloads,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
|||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
|
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ Future<void> _pumpDetail(
|
|||||||
value: InMemoryWatchHistoryRepository(),
|
value: InMemoryWatchHistoryRepository(),
|
||||||
),
|
),
|
||||||
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
||||||
|
Provider<PlaybackPositionCache>(create: (_) => PlaybackPositionCache()),
|
||||||
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
|
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(catalog)..load(),
|
create: (_) => CatalogController(catalog)..load(),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
|
import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
|
||||||
|
|
||||||
import '../support/fake_watch_history.dart';
|
import '../support/fake_watch_history.dart';
|
||||||
@@ -13,12 +15,14 @@ DramaDetailLogic _logic(
|
|||||||
Drama drama,
|
Drama drama,
|
||||||
FakeWatchHistory history, {
|
FakeWatchHistory history, {
|
||||||
FavoriteController? favorites,
|
FavoriteController? favorites,
|
||||||
|
PlaybackPositionCache? positionCache,
|
||||||
}) =>
|
}) =>
|
||||||
DramaDetailLogic(
|
DramaDetailLogic(
|
||||||
drama: drama,
|
drama: drama,
|
||||||
history: history,
|
history: history,
|
||||||
playback: FakePlaybackRepository(),
|
playback: FakePlaybackRepository(),
|
||||||
favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()),
|
favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()),
|
||||||
|
positionCache: positionCache ?? PlaybackPositionCache(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Drama _card(String id) => Drama(
|
Drama _card(String id) => Drama(
|
||||||
@@ -103,6 +107,31 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_prefer_recent_local_position_when_loading_continue', () async {
|
||||||
|
final cache = PlaybackPositionCache();
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'signal-3',
|
||||||
|
dramaId: 'signal',
|
||||||
|
position: const Duration(minutes: 1, seconds: 20),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final history = FakeWatchHistory()
|
||||||
|
..records['signal'] = const WatchProgress(
|
||||||
|
dramaId: 'signal',
|
||||||
|
episodeId: 'signal-1',
|
||||||
|
position: Duration(seconds: 5),
|
||||||
|
duration: Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final logic = _logic(_detail('signal'), history, positionCache: cache);
|
||||||
|
|
||||||
|
await logic.load(() async => _detail('signal'));
|
||||||
|
|
||||||
|
expect(logic.continueIndex, 2);
|
||||||
|
expect(logic.progress?.position, const Duration(seconds: 80));
|
||||||
|
expect(history.reads, isEmpty);
|
||||||
|
logic.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('should_adjust_favorite_count_only_after_successful_changes', () async {
|
test('should_adjust_favorite_count_only_after_successful_changes', () async {
|
||||||
final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]);
|
final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]);
|
||||||
final favorites = FavoriteController(repository);
|
final favorites = FavoriteController(repository);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
|||||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
|
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
|
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
|
||||||
@@ -259,6 +260,25 @@ void main() {
|
|||||||
expect(player.initialIndex, 79);
|
expect(player.initialIndex, 79);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_recent_cached_episode_when_detail_cta_is_tapped', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final cache = PlaybackPositionCache();
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'detail-k38-3',
|
||||||
|
dramaId: 'detail-k38',
|
||||||
|
position: const Duration(minutes: 1, seconds: 20),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
await _pumpDetail(tester, positionCache: cache);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('detail-cta')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
|
||||||
|
expect(player.initialIndex, 2);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
|
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -283,6 +303,7 @@ Future<void> _pumpDetail(
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
Drama? drama,
|
Drama? drama,
|
||||||
Size size = const Size(390, 844),
|
Size size = const Size(390, 844),
|
||||||
|
PlaybackPositionCache? positionCache,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
@@ -303,6 +324,9 @@ Future<void> _pumpDetail(
|
|||||||
value: InMemoryWatchHistoryRepository(),
|
value: InMemoryWatchHistoryRepository(),
|
||||||
),
|
),
|
||||||
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
||||||
|
Provider<PlaybackPositionCache>.value(
|
||||||
|
value: positionCache ?? PlaybackPositionCache(),
|
||||||
|
),
|
||||||
Provider<PlayerFactory>.value(value: createWidgetPlayer),
|
Provider<PlayerFactory>.value(value: createWidgetPlayer),
|
||||||
Provider(create: (_) => PlaybackCoordinator()),
|
Provider(create: (_) => PlaybackCoordinator()),
|
||||||
Provider<InteractionRepository>.value(value: interactions),
|
Provider<InteractionRepository>.value(value: interactions),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('should_store_and_read_recent_position_when_drama_matches', () {
|
||||||
|
final now = DateTime(2026, 10, 22, 12);
|
||||||
|
final cache = PlaybackPositionCache(now: () => now);
|
||||||
|
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'episode-3',
|
||||||
|
dramaId: 'drama-a',
|
||||||
|
position: const Duration(minutes: 1, seconds: 20),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
final entry = cache.read('episode-3', dramaId: 'drama-a');
|
||||||
|
expect(entry, isNotNull);
|
||||||
|
expect(entry!.position, const Duration(minutes: 1, seconds: 20));
|
||||||
|
expect(entry.duration, const Duration(minutes: 3));
|
||||||
|
expect(entry.updatedAt, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_reject_cached_position_when_drama_does_not_match', () {
|
||||||
|
final cache = PlaybackPositionCache();
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'episode-3',
|
||||||
|
dramaId: 'drama-a',
|
||||||
|
position: const Duration(seconds: 80),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cache.read('episode-3', dramaId: 'drama-b'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_use_latest_saved_episode_when_timestamps_match', () {
|
||||||
|
final now = DateTime(2026, 10, 22, 12);
|
||||||
|
final cache = PlaybackPositionCache(now: () => now);
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
dramaId: 'drama-a',
|
||||||
|
position: const Duration(seconds: 30),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'episode-2',
|
||||||
|
dramaId: 'drama-a',
|
||||||
|
position: const Duration(seconds: 45),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_reject_expired_cached_position', () {
|
||||||
|
var now = DateTime(2026, 10, 22, 12);
|
||||||
|
final cache = PlaybackPositionCache(now: () => now);
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'episode-3',
|
||||||
|
dramaId: 'drama-a',
|
||||||
|
position: const Duration(seconds: 80),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
now = now.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
expect(cache.read('episode-3', dramaId: 'drama-a'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart';
|
|||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/models/download_license.dart';
|
import 'package:fruitshorts/core/data/models/download_license.dart';
|
||||||
import 'package:fruitshorts/core/data/models/download_policy.dart';
|
import 'package:fruitshorts/core/data/models/download_policy.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
|
||||||
@@ -12,6 +13,7 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
|||||||
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/download_storage.dart';
|
import 'package:fruitshorts/core/data/services/download_storage.dart';
|
||||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
@@ -48,11 +50,13 @@ AndroidPictureInPicture _pip() =>
|
|||||||
PlayerLogic _logic(
|
PlayerLogic _logic(
|
||||||
FakeWatchHistory history, {
|
FakeWatchHistory history, {
|
||||||
PlaybackRepository? playback,
|
PlaybackRepository? playback,
|
||||||
|
PlaybackPositionCache? positionCache,
|
||||||
Future<void> Function()? waitForFrame,
|
Future<void> Function()? waitForFrame,
|
||||||
}) => PlayerLogic(
|
}) => PlayerLogic(
|
||||||
history: history,
|
history: history,
|
||||||
playback: playback ?? FakePlaybackRepository(),
|
playback: playback ?? FakePlaybackRepository(),
|
||||||
pictureInPicture: _pip(),
|
pictureInPicture: _pip(),
|
||||||
|
positionCache: positionCache,
|
||||||
waitForFrame: waitForFrame ?? () async {},
|
waitForFrame: waitForFrame ?? () async {},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,6 +130,96 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('playback position cache', () {
|
||||||
|
test('should_use_cached_resume_point_without_reading_history', () async {
|
||||||
|
final history = FakeWatchHistory()..saved = const Duration(seconds: 12);
|
||||||
|
final cache = PlaybackPositionCache();
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'signal-1',
|
||||||
|
dramaId: 'signal',
|
||||||
|
position: const Duration(minutes: 1, seconds: 20),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final logic = _logic(history, positionCache: cache);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: _RecordingFactory().create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(logic.resumePoint?.position, const Duration(seconds: 80));
|
||||||
|
expect(history.reads, isEmpty);
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_fall_back_to_server_when_cache_belongs_to_other_drama',
|
||||||
|
() async {
|
||||||
|
final history = FakeWatchHistory()
|
||||||
|
..records['signal'] = const WatchProgress(
|
||||||
|
dramaId: 'signal',
|
||||||
|
episodeId: 'signal-2',
|
||||||
|
position: Duration(seconds: 42),
|
||||||
|
duration: Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final cache = PlaybackPositionCache();
|
||||||
|
cache.save(
|
||||||
|
episodeId: 'signal-1',
|
||||||
|
dramaId: 'other-drama',
|
||||||
|
position: const Duration(seconds: 80),
|
||||||
|
duration: const Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final logic = _logic(history, positionCache: cache);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: _RecordingFactory().create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(logic.resumePoint?.episodeId, 'signal-2');
|
||||||
|
expect(logic.resumePoint?.position, const Duration(seconds: 42));
|
||||||
|
expect(history.reads, ['signal']);
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_fall_back_to_server_when_cache_is_missing', () async {
|
||||||
|
final history = FakeWatchHistory()
|
||||||
|
..records['signal'] = const WatchProgress(
|
||||||
|
dramaId: 'signal',
|
||||||
|
episodeId: 'signal-1',
|
||||||
|
position: Duration(seconds: 36),
|
||||||
|
duration: Duration(minutes: 3),
|
||||||
|
);
|
||||||
|
final logic = _logic(history);
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
logic.start(
|
||||||
|
factory: _RecordingFactory().create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(logic.resumePoint?.position, const Duration(seconds: 36));
|
||||||
|
expect(history.reads, ['signal']);
|
||||||
|
logic.dispose();
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should_play_local_file_without_ticket_when_episode_is_downloaded',
|
test('should_play_local_file_without_ticket_when_episode_is_downloaded',
|
||||||
() async {
|
() async {
|
||||||
final playback = FakePlaybackRepository();
|
final playback = FakePlaybackRepository();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart
|
|||||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
|
||||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||||
import 'package:fruitshorts/core/platform/haptics.dart';
|
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||||
@@ -35,12 +36,14 @@ void main() {
|
|||||||
final history = FakeWatchHistory();
|
final history = FakeWatchHistory();
|
||||||
final playback = FakePlaybackRepository();
|
final playback = FakePlaybackRepository();
|
||||||
final catalog = CatalogController(FakeCatalogRepository(dramas: [drama]));
|
final catalog = CatalogController(FakeCatalogRepository(dramas: [drama]));
|
||||||
|
final positionCache = PlaybackPositionCache();
|
||||||
final favorites = FavoriteController(
|
final favorites = FavoriteController(
|
||||||
InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]),
|
InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]),
|
||||||
);
|
);
|
||||||
final logic = PlayerLogic(
|
final logic = PlayerLogic(
|
||||||
history: history,
|
history: history,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
|
positionCache: positionCache,
|
||||||
pictureInPicture: AndroidPictureInPicture(
|
pictureInPicture: AndroidPictureInPicture(
|
||||||
channel: const MethodChannel('test/player_open_detail/pip'),
|
channel: const MethodChannel('test/player_open_detail/pip'),
|
||||||
),
|
),
|
||||||
@@ -74,6 +77,7 @@ void main() {
|
|||||||
providers: [
|
providers: [
|
||||||
Provider<WatchHistoryRepository>.value(value: history),
|
Provider<WatchHistoryRepository>.value(value: history),
|
||||||
Provider<PlaybackRepository>.value(value: playback),
|
Provider<PlaybackRepository>.value(value: playback),
|
||||||
|
Provider<PlaybackPositionCache>.value(value: positionCache),
|
||||||
ChangeNotifierProvider<CatalogController>.value(value: catalog),
|
ChangeNotifierProvider<CatalogController>.value(value: catalog),
|
||||||
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
|
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
|
||||||
Provider<Haptics>.value(value: FakeHaptics()),
|
Provider<Haptics>.value(value: FakeHaptics()),
|
||||||
@@ -104,7 +108,11 @@ void main() {
|
|||||||
expect(player.pauses, 1);
|
expect(player.pauses, 1);
|
||||||
expect(player.plays, playsBefore);
|
expect(player.plays, playsBefore);
|
||||||
expect(tracked.controller.value.position, const Duration(seconds: 37));
|
expect(tracked.controller.value.position, const Duration(seconds: 37));
|
||||||
expect(history.writes, isEmpty);
|
expect(history.writes, ['signal-1|37000|false']);
|
||||||
|
expect(
|
||||||
|
positionCache.read('signal-1', dramaId: 'signal')?.position,
|
||||||
|
const Duration(seconds: 37),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pageBack();
|
await tester.pageBack();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -117,7 +125,10 @@ void main() {
|
|||||||
expect(logic.session.userWantsPlay, isTrue);
|
expect(logic.session.userWantsPlay, isTrue);
|
||||||
expect(player.plays, playsBefore + 1);
|
expect(player.plays, playsBefore + 1);
|
||||||
expect(tracked.controller.value.position, const Duration(seconds: 37));
|
expect(tracked.controller.value.position, const Duration(seconds: 37));
|
||||||
expect(history.writes, ['signal-1|37000|false']);
|
expect(history.writes, [
|
||||||
|
'signal-1|37000|false',
|
||||||
|
'signal-1|37000|false',
|
||||||
|
]);
|
||||||
|
|
||||||
logic
|
logic
|
||||||
..trackedPlayer = null
|
..trackedPlayer = null
|
||||||
|
|||||||
Reference in new issue
Block a user