diff --git a/lib/app.dart b/lib/app.dart index ff4f0cc..f03f479 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -5,12 +7,19 @@ import 'core/config/locale_controller.dart'; import 'core/data/remote/api_client.dart'; import 'core/data/remote/auth_api.dart'; import 'core/data/remote/catalog_api.dart'; +import 'core/data/remote/library_api.dart'; +import 'core/data/remote/playback_api.dart'; +import 'core/data/remote/watch_api.dart'; import 'core/data/repositories/catalog_repository.dart'; import 'core/data/repositories/engagement_repository.dart'; +import 'core/data/repositories/favorite_repository.dart'; +import 'core/data/repositories/playback_repository.dart'; +import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; import 'core/data/services/auth_session_store.dart'; import 'core/data/services/catalog_controller.dart'; import 'core/data/services/engagement_controller.dart'; +import 'core/data/services/favorite_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'l10n/app_localizations.dart'; @@ -21,12 +30,18 @@ class LumaDramaApp extends StatefulWidget { super.key, this.playerFactory = VideoPlayerPort.new, this.catalogRepository, + this.watchHistoryRepository, + this.favoriteRepository, + this.playbackRepository, }); final PlayerFactory playerFactory; /// 测试注入用;生产走 HTTP 仓库。 final CatalogRepository? catalogRepository; + final WatchHistoryRepository? watchHistoryRepository; + final FavoriteRepository? favoriteRepository; + final PlaybackRepository? playbackRepository; @override State createState() => _LumaDramaAppState(); @@ -46,11 +61,53 @@ class _LumaDramaAppState extends State { late final AuthController _auth = AuthController(AuthApi(_apiClient), _sessionStore); + late final WatchHistoryRepository _watchHistory = + widget.watchHistoryRepository ?? + HttpWatchHistoryRepository( + WatchApi(_apiClient), + isSignedIn: () => _auth.isSignedIn, + ); + + late final FavoriteRepository _favorites = + widget.favoriteRepository ?? + HttpFavoriteRepository( + LibraryApi(_apiClient), + isSignedIn: () => _auth.isSignedIn, + ); + + late final PlaybackRepository _playback = + widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient)); + + late final FavoriteController _favoriteController = FavoriteController(_favorites); + @override void initState() { super.initState(); // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 _auth.load(); + // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 + _auth.addListener(_onAuthChanged); + if (_auth.isSignedIn) unawaited(_favoriteController.load()); + } + + bool _wasSignedIn = false; + + void _onAuthChanged() { + if (_auth.isSignedIn == _wasSignedIn) return; + _wasSignedIn = _auth.isSignedIn; + if (_auth.isSignedIn) { + unawaited(_favoriteController.load()); + } else { + _favoriteController.clear(); + } + } + + @override + void dispose() { + _auth.removeListener(_onAuthChanged); + _favoriteController.dispose(); + _apiClient.close(); + super.dispose(); } @override @@ -59,12 +116,16 @@ class _LumaDramaAppState extends State { providers: [ Provider(create: (_) => PlaybackCoordinator()), Provider(create: (_) => widget.playerFactory), + Provider.value(value: _playback), + Provider.value(value: _watchHistory), + Provider.value(value: _favorites), ChangeNotifierProvider( create: (_) => CatalogController( widget.catalogRepository ?? HttpCatalogRepository(CatalogApi(_apiClient)), )..load(), ), ChangeNotifierProvider(create: (_) => LocaleController()..load()), + ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider( create: (_) => EngagementController(SharedPreferencesEngagementRepository()) diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index b09aab7..bb4a899 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -1,12 +1,18 @@ +import 'json_numbers.dart'; + /// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。 class PlayTicket { const PlayTicket({ required this.episodeId, required this.accessible, required this.accessType, + this.dramaId = '', + this.number = 0, + this.title = '', this.playUrl = '', this.durationSec = 0, this.unlockCoin = 0, + this.previewSeconds = 0, }); static const String free = 'FREE'; @@ -15,23 +21,35 @@ class PlayTicket { static const String noMedia = 'NO_MEDIA'; final String episodeId; + final String dramaId; + final int number; + final String title; final bool accessible; final String accessType; + + /// 签名播放地址:**原样使用**(含 `?e=&s=`),客户端不拼接、不改写。 final String playUrl; final int durationSec; final int unlockCoin; + /// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。 + final int previewSeconds; + bool get needsUnlock => accessType == coinRequired || accessType == vipRequired; factory PlayTicket.fromJson(Map json) { return PlayTicket( episodeId: (json['episodeId'] ?? '').toString(), + dramaId: (json['dramaId'] ?? '').toString(), + number: jsonToInt(json['number']), + title: json['title'] as String? ?? '', accessible: json['accessible'] == true, accessType: json['accessType'] as String? ?? noMedia, playUrl: json['playUrl'] as String? ?? '', - durationSec: (json['durationSec'] as num?)?.toInt() ?? 0, - unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0, + durationSec: jsonToInt(json['durationSec']), + unlockCoin: jsonToInt(json['unlockCoin']), + previewSeconds: jsonToInt(json['previewSeconds']), ); } } diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart new file mode 100644 index 0000000..1f2d392 --- /dev/null +++ b/lib/core/data/models/watch_progress.dart @@ -0,0 +1,61 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 服务端续看记录:`GET /app/watch/progress?dramaId=` 与 `/app/watch/recent` 的下发结构。 +/// +/// 服务端按 `(user, drama)` 只留一条,所以它是"这部剧看到哪一集的哪一秒", +/// 不是逐集进度。ID 与毫秒都是字符串(见 api-phase1.md §0),统一走 jsonToInt 解析。 +class WatchProgress { + const WatchProgress({ + required this.dramaId, + required this.episodeId, + required this.position, + required this.duration, + this.finished = false, + this.playCount = 0, + }); + + final String dramaId; + final String episodeId; + final Duration position; + final Duration duration; + final bool finished; + final int playCount; + + bool get hasPosition => position > Duration.zero; + + /// 进度比例 0~1,时长未知时为 0,避免 UI 出现 NaN。 + double get fraction { + if (duration <= Duration.zero) return 0; + return (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0); + } + + factory WatchProgress.fromJson(Map json) { + return WatchProgress( + dramaId: (json['dramaId'] ?? '').toString(), + episodeId: (json['episodeId'] ?? '').toString(), + position: Duration(milliseconds: jsonToInt(json['positionMs'])), + duration: Duration(milliseconds: jsonToInt(json['durationMs'])), + finished: json['finished'] == true, + playCount: jsonToInt(json['playCount']), + ); + } + + Map toJson() => { + 'dramaId': dramaId, + 'episodeId': episodeId, + 'positionMs': position.inMilliseconds, + 'durationMs': duration.inMilliseconds, + 'finished': finished, + 'playCount': playCount, + }; +} + +/// 续看点:从哪一集的哪个位置继续看。 +class ResumePoint { + const ResumePoint({required this.episodeId, required this.position}); + + final String episodeId; + final Duration position; +} diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index e9fb88b..15acde2 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -79,6 +79,14 @@ class ApiClient { ); } + Future delete( + String path, { + Object? body, + Map? query, + }) { + return _request('DELETE', path, body: body, query: query); + } + Future _request( String method, String path, { diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 54352b4..4346460 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -1,6 +1,5 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; -import '../models/play_ticket.dart'; import 'api_client.dart'; /// 客户端目录接口,对应后端 `/app/**`。 @@ -23,12 +22,6 @@ class CatalogApi { ); } - Future playTicket(String episodeId) async { - return PlayTicket.fromJson( - _asMap(await _client.get('/app/episodes/$episodeId/play')), - ); - } - Map _asMap(Object? data) { if (data is Map) return data; throw const ApiException(-1, '返回数据格式异常'); diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart new file mode 100644 index 0000000..14c09b7 --- /dev/null +++ b/lib/core/data/remote/library_api.dart @@ -0,0 +1,35 @@ +// Author: fruit.com.cn + +import '../models/drama.dart'; +import 'api_client.dart'; + +/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。 +/// +/// 列表返回的是与首页一致的卡片结构,所以直接用 `Drama.fromCardJson` 解析。 +class LibraryApi { + const LibraryApi(this._client); + + final ApiClient _client; + + /// 切换收藏,返回切换后的状态(true = 已收藏),客户端以它为唯一真相。 + Future toggleFavorite(String dramaId) async { + final data = await _client.post( + '/app/favorites/toggle', + body: {'dramaId': dramaId}, + ); + return data == true; + } + + Future> favorites() async { + final data = await _client.get('/app/favorites'); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) Drama.fromCardJson(item), + ]; + } + + Future removeFavorite(String dramaId) async { + await _client.delete('/app/favorites/$dramaId'); + } +} diff --git a/lib/core/data/remote/playback_api.dart b/lib/core/data/remote/playback_api.dart new file mode 100644 index 0000000..0122d95 --- /dev/null +++ b/lib/core/data/remote/playback_api.dart @@ -0,0 +1,20 @@ +// Author: fruit.com.cn + +import '../models/play_ticket.dart'; +import 'api_client.dart'; + +/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。 +/// +/// **签名地址原样使用**:后端下发的 `playUrl` 带 `?e=过期时间&s=签名`, +/// 客户端只做「相对路径补 host」,不拼接、不改写、不丢 query —— 否则签名校验直接失败。 +class PlaybackApi { + const PlaybackApi(this._client); + + final ApiClient _client; + + Future ticket(String episodeId) async { + final data = await _client.get('/app/episodes/$episodeId/play'); + if (data is Map) return PlayTicket.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart new file mode 100644 index 0000000..f09ea90 --- /dev/null +++ b/lib/core/data/remote/watch_api.dart @@ -0,0 +1,59 @@ +// Author: fruit.com.cn + +import '../models/watch_progress.dart'; +import 'api_client.dart'; + +/// 观看进度接口:`/app/watch/*`(契约 §1.5)。全部需要登录态。 +class WatchApi { + const WatchApi(this._client); + + final ApiClient _client; + + /// 上报进度。服务端从 `episodeId` 推出所属剧集,并保证「同集只前进不后退」。 + Future saveProgress({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + String source = 'app', + }) async { + final data = await _client.post( + '/app/watch/progress', + body: { + 'episodeId': episodeId, + 'positionMs': position.inMilliseconds, + if (duration != null) 'durationMs': duration.inMilliseconds, + 'finished': finished, + 'source': source, + }, + ); + return data is Map ? WatchProgress.fromJson(data) : null; + } + + /// 某部剧的续看位置;没有记录时后端返回空 data。 + Future progressOfDrama(String dramaId) async { + final data = await _client.get( + '/app/watch/progress', + query: {'dramaId': dramaId}, + ); + return data is Map ? WatchProgress.fromJson(data) : null; + } + + /// 最近观看(服务端按下标返回,已按更新时间倒序)。 + Future> recent({int limit = 20}) async { + final data = await _client.get( + '/app/watch/recent', + query: {'limit': '$limit'}, + ); + if (data is! List) return const []; + return [ + for (final item in data) + if (item is Map) WatchProgress.fromJson(item), + ]; + } + + /// 删除某部剧的观看记录(记录页清单条用)。 + Future removeProgress(String dramaId) async { + await _client.delete('/app/watch/progress/$dramaId'); + } +} diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart index 8762895..f8a176b 100644 --- a/lib/core/data/repositories/catalog_repository.dart +++ b/lib/core/data/repositories/catalog_repository.dart @@ -1,19 +1,17 @@ import '../models/drama.dart'; import '../models/home_feed.dart'; -import '../models/play_ticket.dart'; import '../remote/catalog_api.dart'; -/// 目录数据来源:首页/发现页聚合、详情、播放票据。 +/// 目录数据来源:首页/发现页聚合、详情。 /// /// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。 +/// **播放票据不在这里**:它属于 [PlaybackRepository](票据有有效期,需要单独缓存策略)。 abstract interface class CatalogRepository { Future fetchHomeFeed(); Future fetchDiscoverFeed(); Future fetchDrama(String dramaId); - - Future resolvePlay(String episodeId); } class HttpCatalogRepository implements CatalogRepository { @@ -30,7 +28,4 @@ class HttpCatalogRepository implements CatalogRepository { @override Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); - @override - Future resolvePlay(String episodeId) => - _api.playTicket(episodeId); } diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart new file mode 100644 index 0000000..1123505 --- /dev/null +++ b/lib/core/data/repositories/favorite_repository.dart @@ -0,0 +1,68 @@ +// Author: fruit.com.cn + +import '../models/drama.dart'; +import '../remote/library_api.dart'; + +/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。 +abstract interface class FavoriteRepository { + /// 切换收藏,返回切换后的状态(true = 已收藏)。 + Future toggle(String dramaId); + + /// 收藏列表(卡片结构与首页一致)。 + Future> list(); + + Future remove(String dramaId); +} + +class HttpFavoriteRepository implements FavoriteRepository { + const HttpFavoriteRepository(this._api, {this.isSignedIn}); + + final LibraryApi _api; + + /// 未登录时不请求:收藏属于账号,匿名浏览时列表按空处理(点收藏按钮会先引导登录)。 + final bool Function()? isSignedIn; + + bool get _signedIn => isSignedIn?.call() ?? true; + + @override + Future toggle(String dramaId) => _api.toggleFavorite(dramaId); + + @override + Future> list() async { + if (!_signedIn) return const []; + return _api.favorites(); + } + + @override + Future remove(String dramaId) => _api.removeFavorite(dramaId); +} + +/// 内存实现:单测与 widget 测试注入用。 +class InMemoryFavoriteRepository implements FavoriteRepository { + InMemoryFavoriteRepository({Set? saved, List? cards}) + : _saved = {...?saved}, + _cards = [...?cards]; + + final Set _saved; + final List _cards; + bool failToggle = false; + bool failList = false; + int toggleCalls = 0; + + @override + Future toggle(String dramaId) async { + toggleCalls += 1; + if (failToggle) throw StateError('favorite unavailable'); + if (!_saved.add(dramaId)) _saved.remove(dramaId); + return _saved.contains(dramaId); + } + + @override + Future> list() async { + if (failList) throw StateError('favorite unavailable'); + return _cards.where((card) => _saved.contains(card.id)).toList(); + } + + @override + Future remove(String dramaId) async => _saved.remove(dramaId); +} diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart new file mode 100644 index 0000000..24bc443 --- /dev/null +++ b/lib/core/data/repositories/playback_repository.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import '../models/play_ticket.dart'; +import '../remote/playback_api.dart'; + +/// 播放票据来源。 +/// +/// 票据里的签名地址有有效期(契约 §1.4:签名默认 15 分钟),所以**不能一直缓存**: +/// [ticketFor] 命中未过期的缓存就用缓存,过期或 [forceRefresh] 时重新向后端要一张。 +abstract interface class PlaybackRepository { + Future ticketFor(String episodeId, {bool forceRefresh = false}); + + /// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。 + bool isFresh(String episodeId); +} + +class HttpPlaybackRepository implements PlaybackRepository { + HttpPlaybackRepository( + this._api, { + this.ttl = const Duration(minutes: 10), + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final PlaybackApi _api; + + /// 缓存时长:比后端 15 分钟签名短 5 分钟,避免"地址刚好在播放中途失效"。 + final Duration ttl; + final DateTime Function() _clock; + final Map _cache = {}; + + @override + Future ticketFor( + String episodeId, { + bool forceRefresh = false, + }) async { + final cached = _cache[episodeId]; + if (!forceRefresh && cached != null && !cached.isExpired(_clock(), ttl)) { + return cached.ticket; + } + final ticket = await _api.ticket(episodeId); + _cache[episodeId] = _CachedTicket(ticket, _clock()); + return ticket; + } + + @override + bool isFresh(String episodeId) { + final cached = _cache[episodeId]; + if (cached == null) return false; + return !cached.isExpired(_clock(), ttl); + } + + /// 供测试与"退出播放页"清理用。 + void clear() => _cache.clear(); +} + +class _CachedTicket { + const _CachedTicket(this.ticket, this.fetchedAt); + + final PlayTicket ticket; + final DateTime fetchedAt; + + bool isExpired(DateTime now, Duration ttl) => now.difference(fetchedAt) >= ttl; +} + +/// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 +class FakePlaybackRepository implements PlaybackRepository { + FakePlaybackRepository({this.ticket, this.error, this.fresh = true}); + + PlayTicket? ticket; + Object? error; + final List requests = []; + final List forced = []; + + /// 模拟"票据是否还有效":置 false 可验证回前台会重新取。 + bool fresh; + + @override + bool isFresh(String episodeId) => fresh; + + @override + Future ticketFor( + String episodeId, { + bool forceRefresh = false, + }) async { + requests.add(episodeId); + forced.add(forceRefresh); + if (error != null) throw error!; + return ticket ?? + PlayTicket( + episodeId: episodeId, + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/fake-$episodeId.mp4?e=1&s=abc', + ); + } +} diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index da15c63..7b5e79b 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -1,78 +1,142 @@ -import 'package:shared_preferences/shared_preferences.dart'; +// Author: fruit.com.cn -import '../models/drama.dart'; - -const demoProfileId = 'demo-local-v1'; +import '../models/watch_progress.dart'; +import '../remote/watch_api.dart'; +/// 观看进度来源。 +/// +/// **服务端按 `(user, drama)` 只留一条续看记录**(契约 §1.5),所以这里不再是 +/// "本地按 profile+剧+集存位置",而是"这部剧看到哪一集、哪一秒"。 +/// 本地实现已删除:进度以服务端为准,换设备也能续看。 abstract interface class WatchHistoryRepository { - Future positionFor( - String profileId, - String dramaId, - String episodeId, - ); - Future lastEpisodeId(String profileId, String dramaId); - Future save( - String profileId, - String dramaId, - String episodeId, - Duration position, - ); + /// 某部剧的续看记录;没有记录返回 null(不是异常)。 + Future progressFor(String dramaId); + + /// 上报进度(客户端每 10 秒一次,切集/退出/切后台各补一次)。 + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }); + + /// 最近观看(默认 20 条)。 + Future> recent({int limit = 20}); + + /// 清理某部剧的记录(K2 的记录页会用,这里先备好)。 + Future remove(String dramaId); } -class SharedPreferencesWatchHistoryRepository - implements WatchHistoryRepository { - SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences}) - : _preferences = preferences ?? SharedPreferencesAsync(); +class HttpWatchHistoryRepository implements WatchHistoryRepository { + const HttpWatchHistoryRepository(this._api, {this.isSignedIn}); - final SharedPreferencesAsync _preferences; + final WatchApi _api; - String _positionKey(String profile, String drama, String episode) => - 'watch.v1.$profile.$drama.$episode.positionMs'; - String _lastKey(String profile, String drama) => - 'watch.v1.$profile.$drama.lastEpisode'; + /// 未登录时这些接口必然 401:直接短路成"没有记录",让浏览/试看保持匿名可用。 + final bool Function()? isSignedIn; + + bool get _signedIn => isSignedIn?.call() ?? true; @override - Future positionFor( - String profileId, - String dramaId, - String episodeId, - ) async { - final milliseconds = await _preferences.getInt( - _positionKey(profileId, dramaId, episodeId), + Future progressFor(String dramaId) async { + if (!_signedIn) return null; + return _api.progressOfDrama(dramaId); + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { + if (!_signedIn) return null; + return _api.saveProgress( + episodeId: episodeId, + position: position, + duration: duration, + finished: finished, ); - return milliseconds == null ? null : Duration(milliseconds: milliseconds); } @override - Future lastEpisodeId(String profileId, String dramaId) => - _preferences.getString(_lastKey(profileId, dramaId)); + Future> recent({int limit = 20}) async { + if (!_signedIn) return const []; + return _api.recent(limit: limit); + } @override - Future save( - String profileId, - String dramaId, - String episodeId, - Duration position, - ) async { - await _preferences.setInt( - _positionKey(profileId, dramaId, episodeId), - position.inMilliseconds, + Future remove(String dramaId) async { + if (!_signedIn) return; + await _api.removeProgress(dramaId); + } +} + +/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。 +class InMemoryWatchHistoryRepository implements WatchHistoryRepository { + InMemoryWatchHistoryRepository({List? seed}) + : _records = {for (final item in seed ?? const []) item.dramaId: item}; + + final Map _records; + int failReads = 0; + bool failWrites = false; + final List writes = []; + + @override + Future progressFor(String dramaId) async { + if (failReads > 0) { + failReads -= 1; + throw StateError('progress unavailable'); + } + return _records[dramaId]; + } + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { + if (failWrites) throw StateError('progress unavailable'); + writes.add('$episodeId|${position.inMilliseconds}|$finished'); + final existing = _records[episodeId]; + final record = WatchProgress( + dramaId: existing?.dramaId ?? '', + episodeId: episodeId, + position: position, + duration: duration ?? Duration.zero, + finished: finished, ); - await _preferences.setString(_lastKey(profileId, dramaId), episodeId); + return record; } + + @override + Future> recent({int limit = 20}) async { + if (failReads > 0) { + failReads -= 1; + throw StateError('progress unavailable'); + } + return _records.values.take(limit).toList(); + } + + @override + Future remove(String dramaId) async => _records.remove(dramaId); } -/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。 -extension WatchHistoryResume on WatchHistoryRepository { - Future resumePositionFor(String profileId, Drama drama) async { - final episodeId = drama.episodes.isNotEmpty - ? drama.episodes.first.id - : await lastEpisodeId(profileId, drama.id); - if (episodeId == null) return null; - return positionFor(profileId, drama.id, episodeId); +/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。 +extension WatchProgressResume on WatchHistoryRepository { + Future resumePointFor(String dramaId) async { + final progress = await progressFor(dramaId); + if (progress == null || progress.episodeId.isEmpty) return null; + return ResumePoint( + episodeId: progress.episodeId, + position: progress.position, + ); } } +/// 续看位置钳位:片尾附近与异常值一律从 0 开始,避免"一进来就播完"。 Duration clampResumePosition(Duration saved, Duration duration) { if (duration <= const Duration(milliseconds: 500)) return Duration.zero; if (saved <= Duration.zero) return Duration.zero; diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index 00e446c..f8daacd 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -46,13 +46,6 @@ class CatalogController extends ChangeNotifier { return detail; } - /// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。 - Future resolvePlay(Episode episode) async { - if (episode.videoUrl.isNotEmpty) return episode; - final ticket = await repository.resolvePlay(episode.id); - return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode; - } - void _replace(Drama detail) { final sections = feed.sections .map( diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart new file mode 100644 index 0000000..64c1ab1 --- /dev/null +++ b/lib/core/data/services/favorite_controller.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/drama.dart'; +import '../repositories/favorite_repository.dart'; + +/// 收藏状态:**以服务端为准**。 +/// +/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于 +/// "点了没反应"或"看起来收藏了其实没有"。 +class FavoriteController extends ChangeNotifier { + FavoriteController(this._repository); + + final FavoriteRepository _repository; + final Set _saved = {}; + List cards = const []; + bool loading = false; + String? error; + bool _disposed = false; + + Set get savedIds => Set.unmodifiable(_saved); + + bool isSaved(String dramaId) => _saved.contains(dramaId); + + void notify() { + if (!_disposed) notifyListeners(); + } + + /// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。 + Future load() async { + loading = true; + error = null; + notify(); + try { + final list = await _repository.list(); + cards = list; + _saved + ..clear() + ..addAll(list.map((drama) => drama.id)); + } catch (_) { + error = 'favoritesError'; + } finally { + loading = false; + notify(); + } + } + + /// 未登录时不要调用(服务端要求登录态)。 + /// + /// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表, + /// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。 + Future toggle(String dramaId, {Drama? card}) async { + final wasSaved = _saved.contains(dramaId); + // 乐观更新 + if (wasSaved) { + _saved.remove(dramaId); + cards = cards.where((drama) => drama.id != dramaId).toList(); + } else { + _saved.add(dramaId); + } + notify(); + try { + final saved = await _repository.toggle(dramaId); + // 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作) + if (saved) { + _saved.add(dramaId); + if (card != null && !cards.any((drama) => drama.id == dramaId)) { + cards = [card, ...cards]; + } + } else { + _saved.remove(dramaId); + cards = cards.where((drama) => drama.id != dramaId).toList(); + } + notify(); + return true; + } catch (_) { + // 回滚 + if (wasSaved) { + _saved.add(dramaId); + } else { + _saved.remove(dramaId); + } + notify(); + return false; + } + } + + /// 取消收藏("我的"页列表里删除某条)。 + Future remove(String dramaId) async { + final removed = cards.firstWhere( + (drama) => drama.id == dramaId, + orElse: () => const Drama( + id: '', + title: '', + synopsis: '', + posterUrl: '', + ), + ); + final hadCard = removed.id.isNotEmpty; + _saved.remove(dramaId); + cards = cards.where((drama) => drama.id != dramaId).toList(); + notify(); + try { + await _repository.remove(dramaId); + return true; + } catch (_) { + if (hadCard) cards = [...cards, removed]; + _saved.add(dramaId); + notify(); + return false; + } + } + + /// 退出登录时清空:收藏状态属于账号,不能跨账号串联。 + void clear() { + _saved.clear(); + cards = const []; + error = null; + notify(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart new file mode 100644 index 0000000..c538be9 --- /dev/null +++ b/lib/core/formatting/error_text.dart @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +import '../../l10n/app_localizations.dart'; + +/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与 +/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。 +String errorText(AppLocalizations l10n, String? code, String fallback) { + if (code == null || code.isEmpty) return fallback; + return switch (code) { + 'networkError' => l10n.networkError, + 'progressError' => l10n.progressError, + 'favoritesError' => l10n.favoritesError, + 'ticketError' => l10n.playbackError, + final value => value, + }; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ef8e20c..4fa0f67 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -34,7 +34,13 @@ "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", "watchedPercent": "Watched {percent}%", - "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "resume": "Resume", "history": "History", "watchlist": "Watchlist", @@ -72,16 +78,40 @@ "originalTag": "Original", "dramaTag": "Short drama", "episodeLabel": "Episode {number}", - "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "exploreSeries": "Explore series", "episodeCount": "{count} EPISODES", - "@episodeCount": {"placeholders": {"count": {"type": "int"}}}, + "@episodeCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "technicalDemoCount": "FREE PREVIEW · {count} EPISODES", - "@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}}, + "@technicalDemoCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "episodes": "Episodes", "watchEpisodeOne": "Watch episode 1", "continueEpisode": "Continue episode {number}", - "@continueEpisode": {"placeholders": {"number": {"type": "int"}}}, + "@continueEpisode": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "freeTestMedia": "Same demo clip · free", "lockedStoreUnavailable": "Locked · store unavailable", "freeTestClip": "Free test clip", @@ -89,10 +119,25 @@ "freeSamplesNotice": "Two free samples · store setup pending", "demoTestMedia": "FREE · SAME DEMO CLIP", "episodeProgress": "EPISODE {number} / {total}", - "@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}}, + "@episodeProgress": { + "placeholders": { + "number": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "testVideoNotice": "Self-generated technical test video", "lockedTitle": "Episode {number} is locked", - "@lockedTitle": {"placeholders": {"number": {"type": "int"}}}, + "@lockedTitle": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.", "playbackError": "This clip could not be played.", "catalogError": "The catalog could not be loaded.", @@ -180,5 +225,19 @@ "authGateTitle": "Sign in to continue", "authGateMessage": "Favorites and comments need an account.", "authGateAction": "Sign In", - "cancel": "Not now" + "cancel": "Not now", + "noEpisodesYet": "No playable episodes yet.", + "networkError": "Network is unreachable. Please check your connection and retry.", + "progressError": "Your watch position could not be loaded.", + "favoritesError": "Your favorites could not be loaded.", + "favoriteToggleFailed": "Could not update favorites. Please retry.", + "remove": "Remove", + "unlockComingSoon": "Unlocking with coins is coming soon.", + "resumeProgress": "Resume at {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 788b6e7..023a9e4 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -34,7 +34,13 @@ "localDemoProfile": "Profil demo lokal", "continueWatching": "Lanjut Tonton", "watchedPercent": "Sudah ditonton {percent}%", - "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "resume": "Lanjutkan", "history": "Riwayat", "watchlist": "Daftar Tonton", @@ -72,16 +78,40 @@ "originalTag": "Original", "dramaTag": "Drama pendek", "episodeLabel": "Episode {number}", - "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "exploreSeries": "Lihat serial", "episodeCount": "{count} EPISODE", - "@episodeCount": {"placeholders": {"count": {"type": "int"}}}, + "@episodeCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "technicalDemoCount": "PRATINJAU GRATIS · {count} EPISODE", - "@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}}, + "@technicalDemoCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "episodes": "Episode", "watchEpisodeOne": "Tonton episode 1", "continueEpisode": "Lanjut episode {number}", - "@continueEpisode": {"placeholders": {"number": {"type": "int"}}}, + "@continueEpisode": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "freeTestMedia": "Klip demo sama · gratis", "lockedStoreUnavailable": "Terkunci · toko belum tersedia", "freeTestClip": "Klip uji gratis", @@ -89,10 +119,25 @@ "freeSamplesNotice": "Dua sampel gratis · pengaturan toko belum selesai", "demoTestMedia": "GRATIS · KLIP DEMO SAMA", "episodeProgress": "EPISODE {number} / {total}", - "@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}}, + "@episodeProgress": { + "placeholders": { + "number": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "testVideoNotice": "Video uji teknis buatan sendiri", "lockedTitle": "Episode {number} terkunci", - "@lockedTitle": {"placeholders": {"number": {"type": "int"}}}, + "@lockedTitle": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "lockedBody": "Pembukaan kunci tersedia setelah toko dan verifikasi disiapkan. Demo ini tidak memproses pembayaran.", "playbackError": "Klip ini tidak bisa diputar.", "catalogError": "Katalog gagal dimuat.", @@ -180,5 +225,19 @@ "authGateTitle": "Masuk untuk melanjutkan", "authGateMessage": "Favorit dan komentar perlu akun.", "authGateAction": "Masuk", - "cancel": "Nanti saja" + "cancel": "Nanti saja", + "noEpisodesYet": "Belum ada episode yang bisa diputar.", + "networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.", + "progressError": "Posisi tonton gagal dimuat.", + "favoritesError": "Daftar favorit gagal dimuat.", + "favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.", + "remove": "Hapus", + "unlockComingSoon": "Pembukaan dengan koin segera hadir.", + "resumeProgress": "Lanjut di {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fcc3d0e..e372697 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1143,6 +1143,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Not now'** String get cancel; + + /// No description provided for @noEpisodesYet. + /// + /// In en, this message translates to: + /// **'No playable episodes yet.'** + String get noEpisodesYet; + + /// No description provided for @networkError. + /// + /// In en, this message translates to: + /// **'Network is unreachable. Please check your connection and retry.'** + String get networkError; + + /// No description provided for @progressError. + /// + /// In en, this message translates to: + /// **'Your watch position could not be loaded.'** + String get progressError; + + /// No description provided for @favoritesError. + /// + /// In en, this message translates to: + /// **'Your favorites could not be loaded.'** + String get favoritesError; + + /// No description provided for @favoriteToggleFailed. + /// + /// In en, this message translates to: + /// **'Could not update favorites. Please retry.'** + String get favoriteToggleFailed; + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @unlockComingSoon. + /// + /// In en, this message translates to: + /// **'Unlocking with coins is coming soon.'** + String get unlockComingSoon; + + /// No description provided for @resumeProgress. + /// + /// In en, this message translates to: + /// **'Resume at {position} / {total}'** + String resumeProgress(Object position, Object total); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dbc4e6a..60057e5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -551,4 +551,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancel => 'Not now'; + + @override + String get noEpisodesYet => 'No playable episodes yet.'; + + @override + String get networkError => + 'Network is unreachable. Please check your connection and retry.'; + + @override + String get progressError => 'Your watch position could not be loaded.'; + + @override + String get favoritesError => 'Your favorites could not be loaded.'; + + @override + String get favoriteToggleFailed => + 'Could not update favorites. Please retry.'; + + @override + String get remove => 'Remove'; + + @override + String get unlockComingSoon => 'Unlocking with coins is coming soon.'; + + @override + String resumeProgress(Object position, Object total) { + return 'Resume at $position / $total'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ebcf54d..12d319c 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -553,4 +553,31 @@ class AppLocalizationsId extends AppLocalizations { @override String get cancel => 'Nanti saja'; + + @override + String get noEpisodesYet => 'Belum ada episode yang bisa diputar.'; + + @override + String get networkError => + 'Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.'; + + @override + String get progressError => 'Posisi tonton gagal dimuat.'; + + @override + String get favoritesError => 'Daftar favorit gagal dimuat.'; + + @override + String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.'; + + @override + String get remove => 'Hapus'; + + @override + String get unlockComingSoon => 'Pembukaan dengan koin segera hadir.'; + + @override + String resumeProgress(Object position, Object total) { + return 'Lanjut di $position / $total'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 91dddb8..0a575af 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -543,4 +543,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cancel => '暂不'; + + @override + String get noEpisodesYet => '还没有可播放的剧集。'; + + @override + String get networkError => '网络异常,请检查网络后重试。'; + + @override + String get progressError => '续看进度加载失败。'; + + @override + String get favoritesError => '收藏列表加载失败。'; + + @override + String get favoriteToggleFailed => '收藏操作失败,请重试。'; + + @override + String get remove => '移除'; + + @override + String get unlockComingSoon => '金币解锁功能即将开放。'; + + @override + String resumeProgress(Object position, Object total) { + return '续看至 $position / $total'; + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6ab8562..1739dcc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -34,7 +34,13 @@ "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", "watchedPercent": "已观看 {percent}%", - "@watchedPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@watchedPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "resume": "继续播放", "history": "历史", "watchlist": "追剧", @@ -72,7 +78,13 @@ "originalTag": "原创", "dramaTag": "短剧", "episodeLabel": "第 {number} 集", - "@episodeLabel": {"placeholders": {"number": {"type": "int"}}}, + "@episodeLabel": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "exploreSeries": "查看剧集", "episodeCount": "{count} 集", "technicalDemoCount": "免费试看 · {count} 集", @@ -175,5 +187,19 @@ "authGateTitle": "登录后可继续", "authGateMessage": "收藏、评论等操作需要登录。", "authGateAction": "去登录", - "cancel": "暂不" + "cancel": "暂不", + "noEpisodesYet": "还没有可播放的剧集。", + "networkError": "网络异常,请检查网络后重试。", + "progressError": "续看进度加载失败。", + "favoritesError": "收藏列表加载失败。", + "favoriteToggleFailed": "收藏操作失败,请重试。", + "remove": "移除", + "unlockComingSoon": "金币解锁功能即将开放。", + "resumeProgress": "续看至 {position} / {total}", + "@resumeProgress": { + "placeholders": { + "position": {}, + "total": {} + } + } } diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index d17cf00..ae63580 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -5,9 +5,9 @@ import 'discover_logic.dart'; class DiscoverBinding { DiscoverLogic create({ required Drama drama, - WatchHistoryRepository? history, + required WatchHistoryRepository history, }) => DiscoverLogic( drama: drama, - history: history ?? SharedPreferencesWatchHistoryRepository(), + history: history, ); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 0536385..8b3de30 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -26,7 +26,11 @@ class DiscoverLogic extends ChangeNotifier { } void refreshPosition() { - position = history.resumePositionFor(demoProfileId, drama); + // 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看" + position = history + .resumePointFor(drama.id) + .then((point) => point?.position) + .catchError((Object _) => null); } void selectCategory(int index) { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 9027291..8fcb2a8 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -59,7 +59,7 @@ class _DiscoverPageState extends State { super.initState(); logic = DiscoverBinding().create( drama: widget.drama, - history: widget.history, + history: widget.history ?? context.read(), ); if (widget.focusSearch) logic.searchOpen = true; } diff --git a/lib/pages/drama_detail/drama_detail_binding.dart b/lib/pages/drama_detail/drama_detail_binding.dart index 0a3f649..0939681 100644 --- a/lib/pages/drama_detail/drama_detail_binding.dart +++ b/lib/pages/drama_detail/drama_detail_binding.dart @@ -1,13 +1,19 @@ import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/favorite_controller.dart'; import 'drama_detail_logic.dart'; class DramaDetailBinding { DramaDetailLogic create({ required Drama drama, - WatchHistoryRepository? history, + required WatchHistoryRepository history, + required PlaybackRepository playback, + required FavoriteController favorites, }) => DramaDetailLogic( drama: drama, - history: history ?? SharedPreferencesWatchHistoryRepository(), + history: history, + playback: playback, + favorites: favorites, ); } diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 7f613d1..2278b22 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -1,22 +1,35 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../player/player_view.dart'; class DramaDetailLogic extends ChangeNotifier { - DramaDetailLogic({required this.drama, required this.history}); + DramaDetailLogic({ + required this.drama, + required this.history, + required this.playback, + required this.favorites, + }); /// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。 Drama drama; final WatchHistoryRepository history; + final PlaybackRepository playback; + final FavoriteController favorites; bool loading = false; bool failed = false; /// 接口给出的失败原因;为空时界面回退到本地文案。 String? failureMessage; int? continueIndex; + WatchProgress? progress; + String? progressError; bool _disposed = false; void notify() { @@ -39,6 +52,10 @@ class DramaDetailLogic extends ChangeNotifier { } on ApiException catch (exception) { failed = true; failureMessage = exception.message; + } on DioException { + // 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户 + failed = true; + failureMessage = 'networkError'; } catch (_) { failed = true; } finally { @@ -49,18 +66,39 @@ class DramaDetailLogic extends ChangeNotifier { Future loadContinue() async { try { - final id = await history.lastEpisodeId(demoProfileId, drama.id); - if (id == null || _disposed) return; - final index = drama.episodes.indexWhere((episode) => episode.id == id); - if (index >= 0 && drama.episodes[index].isFree) { - continueIndex = index; - notify(); - } - } catch (_) { - // Keep the first episode available if preferences cannot be read. + final record = await history.progressFor(drama.id); + if (_disposed) return; + progress = record; + progressError = null; + continueIndex = record == null + ? null + : _indexOf(record.episodeId) ?? continueIndex; + notify(); + } catch (exception) { + if (_disposed) return; + // 拿不到续看位置不影响看第一集,但要让用户知道(可重试) + progressError = exception is ApiException + ? exception.message + : 'progressError'; + notify(); } } + int? _indexOf(String episodeId) { + final index = drama.episodes.indexWhere( + (episode) => episode.id == episodeId, + ); + return index >= 0 ? index : null; + } + + /// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。 + double? progressFractionFor(String episodeId) { + final record = progress; + if (record == null || record.episodeId != episodeId) return null; + if (!record.hasPosition) return null; + return record.fraction; + } + Future openPlayer(BuildContext context, int index) async { await Navigator.of(context).push( MaterialPageRoute( @@ -70,6 +108,9 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } + /// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。 + Future toggleFavorite() => favorites.toggle(drama.id, card: drama); + @override void dispose() { _disposed = true; diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 1dcc719..8bcd195 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -5,11 +5,16 @@ import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/widgets/remote_image.dart'; +import '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); @@ -25,7 +30,12 @@ class _DramaDetailPageState extends State { @override void initState() { super.initState(); - logic = DramaDetailBinding().create(drama: widget.drama); + logic = DramaDetailBinding().create( + drama: widget.drama, + history: context.read(), + playback: context.read(), + favorites: context.read(), + ); _load(); } @@ -69,7 +79,7 @@ class _DramaDetailPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - logic.failureMessage ?? l10n.detailError, + errorText(l10n, logic.failureMessage, l10n.detailError), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), @@ -82,6 +92,40 @@ class _DramaDetailPageState extends State { ); } final drama = logic.drama; + if (drama.episodes.isEmpty) { + // 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏 + return Scaffold( + backgroundColor: const Color(0xFF111111), + appBar: AppBar( + backgroundColor: const Color(0xFF111111), + foregroundColor: Colors.white, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.movie_filter_outlined, + size: 48, + color: Colors.white38, + ), + const SizedBox(height: 12), + Text( + l10n.noEpisodesYet, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + } return Scaffold( body: CustomScrollView( slivers: [ @@ -90,6 +134,7 @@ class _DramaDetailPageState extends State { pinned: true, backgroundColor: const Color(0xFF111111), foregroundColor: Colors.white, + actions: [_favoriteAction(context, l10n)], flexibleSpace: FlexibleSpaceBar( background: RemoteImage(drama.posterUrl), ), @@ -121,15 +166,54 @@ class _DramaDetailPageState extends State { ), ), const SizedBox(height: 20), - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: () => + logic.openPlayer(context, logic.continueIndex ?? 0), + icon: const Icon(Icons.play_arrow_rounded), + label: Text( + logic.continueIndex == null + ? l10n.watchEpisodeOne + : l10n.continueEpisode(logic.continueIndex! + 1), + ), + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 8), + _ResumeBar( + fraction: _continueFraction!, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), + ), + ], + if (logic.progressError != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + errorText( + l10n, + logic.progressError, + l10n.progressError, + ), + style: const TextStyle( + color: Colors.white60, + fontSize: 12, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + ], ), const SizedBox(height: 30), Text( @@ -147,6 +231,7 @@ class _DramaDetailPageState extends State { itemCount: drama.episodes.length, itemBuilder: (context, index) { final episode = drama.episodes[index]; + final fraction = logic.progressFractionFor(episode.id); return Card( margin: const EdgeInsets.only(bottom: 10), child: ListTile( @@ -158,6 +243,12 @@ class _DramaDetailPageState extends State { child: Text('${episode.number}'), ), title: Text(l10n.episodeLabel(episode.number)), + subtitle: fraction == null + ? null + : Padding( + padding: const EdgeInsets.only(top: 6), + child: _ResumeBar(fraction: fraction), + ), trailing: const Icon(Icons.play_circle_outline_rounded), onTap: () => logic.openPlayer(context, index), ), @@ -169,4 +260,71 @@ class _DramaDetailPageState extends State { ), ); } + + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; + } + + /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 + Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { + final favorites = context.watch(); + final saved = favorites.isSaved(logic.drama.id); + return IconButton( + tooltip: l10n.favorite, + icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), + onPressed: () async { + if (!await ensureSignedIn(context)) return; + final ok = await logic.toggleFavorite(); + if (ok || !context.mounted) return; + // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + }, + ); + } + + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return duration.inHours > 0 + ? '${duration.inHours}:$minutes:$seconds' + : '$minutes:$seconds'; + } +} + +/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。 +class _ResumeBar extends StatelessWidget { + const _ResumeBar({required this.fraction, this.label}); + + final double fraction; + final String? label; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction, + minHeight: 3, + backgroundColor: Colors.white24, + valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)), + ), + ), + if (label != null) ...[ + const SizedBox(height: 4), + Text( + label!, + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ], + ); + } } diff --git a/lib/pages/mine/mine_binding.dart b/lib/pages/mine/mine_binding.dart index 697d61d..15c5ad9 100644 --- a/lib/pages/mine/mine_binding.dart +++ b/lib/pages/mine/mine_binding.dart @@ -6,10 +6,10 @@ class MineBinding { MineLogic create({ required Drama drama, required int initialSection, - WatchHistoryRepository? history, + required WatchHistoryRepository history, }) => MineLogic( drama: drama, initialSection: initialSection, - history: history ?? SharedPreferencesWatchHistoryRepository(), + history: history, ); } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index bc699a3..7534197 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; @@ -20,6 +21,11 @@ class MineLogic extends ChangeNotifier { final WatchHistoryRepository history; Drama drama; late Future position; + List recent = const []; + bool recentLoading = false; + String? recentError; + /// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始) + bool positionFailed = false; int section; int filter = 0; bool _disposed = false; @@ -29,7 +35,33 @@ class MineLogic extends ChangeNotifier { } void refreshPosition() { - position = history.resumePositionFor(demoProfileId, drama); + position = history + .resumePointFor(drama.id) + .then((point) { + positionFailed = false; + return point?.position; + }) + .catchError((Object _) { + positionFailed = true; + notify(); + return null; + }); + refreshRecent(); + } + + /// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。 + Future refreshRecent() async { + recentLoading = true; + recentError = null; + notify(); + try { + recent = await history.recent(limit: 20); + } catch (_) { + recentError = 'progressError'; + } finally { + recentLoading = false; + notify(); + } } void selectSection(int index) { diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 141c7ed..671b780 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -6,6 +8,8 @@ import '../../core/data/models/drama.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/engagement_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; @@ -61,7 +65,7 @@ class _MyPageState extends State { logic = MineBinding().create( drama: widget.drama, initialSection: widget.initialSection, - history: widget.history, + history: widget.history ?? context.read(), ); } @@ -438,11 +442,14 @@ class _MyPageState extends State { (logic.filter == 1 && fraction < 0.99) || (logic.filter == 2 && fraction >= 0.99); final engagement = context.watch(); - final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) { - return logic.section == 1 - ? engagement.isSaved(drama.id) - : engagement.isLiked(drama.id); - }).toList(); + // 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。 + final favorites = context.watch(); + final isWatchlist = logic.section == 1; + final markedDramas = isWatchlist + ? favorites.cards + : (widget.dramas ?? [widget.drama]) + .where((drama) => engagement.isLiked(drama.id)) + .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -452,7 +459,11 @@ class _MyPageState extends State { children: [ for (var index = 0; index < tabs.length; index++) ...[ InkWell( - onTap: () => logic.selectSection(index), + onTap: () { + logic.selectSection(index); + // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) + if (index == 1) unawaited(favorites.load()); + }, borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.symmetric( @@ -577,6 +588,34 @@ class _MyPageState extends State { ), ), ) + else if (isWatchlist && favorites.loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 22), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (isWatchlist && favorites.error != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + errorText(l10n, favorites.error, l10n.favoritesError), + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + TextButton( + onPressed: () => unawaited(favorites.load()), + child: Text(l10n.retry), + ), + ], + ), + ) else if (logic.section != 0 && markedDramas.isNotEmpty) for (final drama in markedDramas) ...[ _markedDramaCard(drama, l10n), @@ -590,10 +629,90 @@ class _MyPageState extends State { style: const TextStyle(color: Color(0xFFE6E1E3)), ), ), + // 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧; + // 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。 + if (logic.section == 0) ...[ + const SizedBox(height: 18), + Text( + l10n.history, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 9), + if (logic.positionFailed) + Row( + children: [ + Expanded( + child: Text( + l10n.progressError, + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ), + TextButton( + onPressed: logic.refreshPosition, + child: Text(l10n.retry), + ), + ], + ) + else if (logic.recentLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (logic.recentError != null) + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, logic.recentError, l10n.progressError), + style: const TextStyle(color: Color(0xFFE6E1E3)), + ), + ), + TextButton( + onPressed: () => unawaited(logic.refreshRecent()), + child: Text(l10n.retry), + ), + ], + ) + else if (_recentDramas.isEmpty) + Text( + l10n.noStories, + style: const TextStyle(color: Color(0xFFB7B3B8)), + ) + else + for (final drama in _recentDramas) ...[ + _markedDramaCard(drama, l10n), + const SizedBox(height: 9), + ], + ], ], ); } + /// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。 + List get _recentDramas { + final pool = [...?widget.dramas, widget.drama]; + final result = []; + for (final item in logic.recent) { + for (final drama in pool) { + if (drama.id == item.dramaId && + !result.any((picked) => picked.id == drama.id)) { + result.add(drama); + } + } + } + return result; + } + Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( onTap: () { if (widget.onDramaSelected != null) { diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index ca3fd8e..fb46d6c 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -1,10 +1,15 @@ import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import 'player_logic.dart'; class PlayerBinding { - PlayerLogic create() => PlayerLogic( - history: SharedPreferencesWatchHistoryRepository(), + PlayerLogic create({ + required WatchHistoryRepository history, + required PlaybackRepository playback, + }) => PlayerLogic( + history: history, + playback: playback, pictureInPicture: AndroidPictureInPicture(), ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 5fee99d..034082c 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -5,7 +5,11 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/play_ticket.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/models/player_port.dart'; +import '../../core/data/remote/api_client.dart'; +import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; @@ -24,9 +28,14 @@ import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; class PlayerLogic extends ChangeNotifier { - PlayerLogic({required this.history, required this.pictureInPicture}); + PlayerLogic({ + required this.history, + required this.playback, + required this.pictureInPicture, + }); final WatchHistoryRepository history; + final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; late PageController pages; @@ -37,7 +46,18 @@ class PlayerLogic extends ChangeNotifier { Episode? trackedEpisode; Drama? trackedDrama; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); + /// 进度上报节流:契约建议每 10 秒一次 + Duration progressInterval = const Duration(seconds: 10); Future pendingWrite = Future.value(); + /// 服务端续看点:进入播放页时定位到"那一集的哪一秒" + ResumePoint? resumePoint; + bool resumeLoaded = false; + /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 + bool ticketLoading = false; + String? ticketError; + PlayTicket? blockedTicket; + String? blockedEpisodeId; + List sessionEpisodes = const []; int pageRequest = 0; bool muted = false; bool stoppedAtFeedEnd = false; @@ -64,6 +84,9 @@ class PlayerLogic extends ChangeNotifier { ValueChanged? onDramaChanged; ValueChanged? onImmersiveChanged; bool _disposed = false; + /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, + /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 + bool _started = false; bool get immersive => fullscreen || pip || pipPreparing; @@ -111,16 +134,19 @@ class PlayerLogic extends ChangeNotifier { required PlayerFactory factory, required PlaybackCoordinator coordinator, }) { + _started = true; pictureInPicture.setStateListener(onPipChanged); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); visibleIndices = List.generate(queue.length, (index) => index); pages = PageController(initialPage: initialIndex); + // 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它) + sessionEpisodes = [for (final entry in queue.entries) entry.episode]; session = PlaybackSession( - episodes: [for (final entry in queue.entries) entry.episode], + episodes: sessionEpisodes, factory: factory, - // 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定 + // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 canPlay: (episode) => episode.videoUrl.isNotEmpty, prepare: preparePlayer, activate: activatePlayer, @@ -129,9 +155,88 @@ class PlayerLogic extends ChangeNotifier { if (!active || visibleIndices.isEmpty) { session.setRouteVisible(false); } + unawaited(loadResumePoint()); unawaited(selectEpisode(initialIndex)); } + /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 + Future loadResumePoint() async { + final dramaId = queue.entryAt(initialIndex).drama.id; + try { + resumePoint = await history.resumePointFor(dramaId); + } catch (_) { + // 拿不到续看位置不影响播放:从当前集开头看 + resumePoint = null; + } finally { + resumeLoaded = true; + if (isMounted()) notify(); + } + } + + /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), + /// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。 + Duration? resumePositionFor(String episodeId) { + final point = resumePoint; + if (point == null || point.episodeId != episodeId) return null; + if (point.position <= Duration.zero) return null; + return point.position; + } + + /// 进度上报节流:距上次上报不足 [progressInterval] 就不报。 + /// + /// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。 + bool shouldReportProgress(DateTime now) { + if (now.difference(lastProgressWrite) < progressInterval) return false; + lastProgressWrite = now; + return true; + } + + /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), + /// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。 + Future ensureTicket(int index) async { + if (index < 0 || index >= sessionEpisodes.length) return false; + final episode = sessionEpisodes[index]; + final request = pageRequest; + ticketLoading = true; + ticketError = null; + notify(); + try { + final ticket = await playback.ticketFor(episode.id); + if (request != pageRequest) return false; + blockedTicket = null; + blockedEpisodeId = null; + if (ticket.accessible && ticket.playUrl.isNotEmpty) { + // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) + sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl); + return true; + } + blockedTicket = ticket; + blockedEpisodeId = episode.id; + return false; + } catch (exception) { + if (request != pageRequest) return false; + // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 + if (episode.videoUrl.isNotEmpty) { + ticketError = null; + return true; + } + ticketError = exception is ApiException + ? exception.message + : 'ticketError'; + return false; + } finally { + ticketLoading = false; + if (isMounted()) notify(); + } + } + + /// 重试当前集的票据(失败态的"重试"按钮)。 + Future retryTicket() async { + ticketError = null; + notify(); + unawaited(selectEpisode(session.currentIndex)); + } + void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); @@ -161,6 +266,14 @@ class PlayerLogic extends ChangeNotifier { void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); + if (state == AppLifecycleState.resumed) { + // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, + // 否则会撞上 403(契约 §1.4) + final episode = trackedEpisode; + if (episode != null && !playback.isFresh(episode.id)) { + unawaited(selectEpisode(session.currentIndex)); + } + } session.setAppResumed( state == AppLifecycleState.resumed || pip || pipPreparing, ); @@ -179,10 +292,12 @@ class PlayerLogic extends ChangeNotifier { unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); } unawaited(saveCurrentProgress()); - session.setRouteVisible(false); - cancelScrub(); - unawaited(session.close()); - pages.dispose(); + if (_started) { + session.setRouteVisible(false); + cancelScrub(); + unawaited(session.close()); + pages.dispose(); + } super.dispose(); } @@ -264,23 +379,18 @@ class PlayerLogic extends ChangeNotifier { Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; - final drama = queue.entries - .firstWhere((e) => e.episode.id == episode.id) - .drama; try { await pendingWrite; - final saved = await history.positionFor( - demoProfileId, - drama.id, - episode.id, - ); + // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 + if (resumePoint == null) await loadResumePoint(); + final saved = resumePositionFor(episode.id); if (saved != null) { await player.controller.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } } catch (_) { - // A local preference failure does not prevent playback. + // 续看位置取不到不影响播放:从头播。 } } @@ -303,7 +413,13 @@ class PlayerLogic extends ChangeNotifier { if (player.controller.value.isCompleted && !completionProgressSaved) { completionProgressSaved = true; unawaited( - queueProgress(drama, episode, player.controller.value.duration), + queueProgress( + drama, + episode, + player.controller.value.duration, + duration: player.controller.value.duration, + finished: true, + ), ); } if (!completionHandled && @@ -313,11 +429,7 @@ class PlayerLogic extends ChangeNotifier { completionHandled = true; unawaited(advanceAfterEnd(player, index)); } - final now = DateTime.now(); - if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) { - return; - } - lastProgressWrite = now; + if (!shouldReportProgress(DateTime.now())) return; unawaited( queueProgress(drama, episode, player.controller.value.position), ); @@ -347,13 +459,26 @@ class PlayerLogic extends ChangeNotifier { } } - Future queueProgress(Drama drama, Episode episode, Duration position) { + /// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。 + Future queueProgress( + Drama drama, + Episode episode, + Duration position, { + Duration? duration, + bool finished = false, + }) { pendingWrite = pendingWrite .then( - (_) => history.save(demoProfileId, drama.id, episode.id, position), + (_) => history.save( + episodeId: episode.id, + position: position, + duration: duration, + finished: finished, + ), ) .catchError((Object _) { - // Playback continues if local history cannot be stored. + // 进度上报失败不影响播放(下次节流点会再报一次)。 + return null; }); return pendingWrite; } @@ -368,7 +493,12 @@ class PlayerLogic extends ChangeNotifier { !player.controller.value.isInitialized) { return pendingWrite; } - return queueProgress(drama, episode, player.controller.value.position); + return queueProgress( + drama, + episode, + player.controller.value.position, + duration: player.controller.value.duration, + ); } Future selectEpisode(int index) async { @@ -377,6 +507,10 @@ class PlayerLogic extends ChangeNotifier { // Capture the old position synchronously, persist without blocking a swipe. unawaited(saveCurrentProgress()); if (request != pageRequest || !isMounted()) return; + // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) + final ready = await ensureTicket(index); + if (request != pageRequest || !isMounted()) return; + if (!ready && ticketError != null) return; await session.select(index, preloadIndex: nextVisibleIndex(index)); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a9a1237..e546f66 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -6,15 +6,20 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/play_ticket.dart'; import '../../core/data/services/engagement_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; +import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/widgets/auth_gate.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/seek_progress_bar.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/repositories/playback_repository.dart'; class PlayerPage extends StatefulWidget { const PlayerPage({ @@ -60,7 +65,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); - logic = PlayerBinding().create(); + logic = PlayerBinding().create( + history: context.read(), + playback: context.read(), + ); logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); logic.start( @@ -189,6 +197,39 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), + // 票据三态之一:起播前正在向后端要签名地址 + if (selected && logic.ticketLoading) + const Center( + child: CircularProgressIndicator(color: Color(0xFF2F7BFF)), + ), + // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 + if (selected && !logic.ticketLoading && logic.ticketError != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.wifi_off_rounded, + color: Colors.white70, + size: 36, + ), + const SizedBox(height: 12), + Text( + errorText(l10n, logic.ticketError, l10n.playbackError), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => unawaited(logic.retryTicket()), + child: Text(l10n.retry), + ), + ], + ), + ), + ), if (selected && logic.session.loading) const Center( child: CircularProgressIndicator(color: Color(0xFF2F7BFF)), @@ -215,9 +256,18 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - // 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。 + // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 + if (selected && !ready && logic.blockedEpisodeId == episode.id) + _BlockedNotice( + l10n: l10n, + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + ), + // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && !ready && + logic.blockedEpisodeId != episode.id && logic.session.error == null && logic.session.isLocked) Center( @@ -284,7 +334,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { episode: episode, controller: ready ? videoPort!.controller : null, liked: engagement.isLiked(drama.id), - saved: engagement.isSaved(drama.id), + // 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载) + saved: context.watch().isSaved(drama.id), onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), @@ -298,8 +349,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { } }, onSave: () async { + final favorites = context.read(); if (await ensureSignedIn(context)) { - unawaited(engagement.toggleSaved(drama.id)); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); } }, onComments: () => logic.showComments(context, drama), @@ -389,6 +445,68 @@ class _FullscreenControls extends StatelessWidget { ); } +/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 +class _BlockedNotice extends StatelessWidget { + const _BlockedNotice({ + required this.l10n, + required this.episode, + required this.accessType, + required this.unlockCoin, + }); + + final AppLocalizations l10n; + final Episode episode; + final String? accessType; + final int unlockCoin; + + @override + Widget build(BuildContext context) { + final needsUnlock = + accessType == PlayTicket.coinRequired || + accessType == PlayTicket.vipRequired; + final title = needsUnlock + ? l10n.lockedTitle(episode.number) + : accessType == PlayTicket.noMedia + ? l10n.noMediaNotice + : episode.locked + ? l10n.lockedTitle(episode.number) + : l10n.noMediaNotice; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + needsUnlock + ? Icons.lock_outline_rounded + : Icons.movie_filter_outlined, + color: Colors.white70, + size: 36, + ), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + if (needsUnlock) ...[ + const SizedBox(height: 6), + Text( + unlockCoin > 0 + ? '${l10n.unlockComingSoon} · $unlockCoin ${l10n.coins}' + : l10n.unlockComingSoon, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + ], + ], + ), + ), + ); + } +} + class _PlaybackOverlay extends StatelessWidget { const _PlaybackOverlay({ required this.l10n, diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index 471e64c..5b53f61 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; @@ -53,30 +52,6 @@ void main() { expect(controller.dramas.single.episodes, hasLength(2)); }); - test('should_write_play_url_for_free_episode_and_keep_locked_episode_empty_when_ticket_is_issued', () async { - final repository = ScriptedCatalogRepository(); - final controller = CatalogController(repository); - repository.tickets['10'] = const PlayTicket( - episodeId: '10', - accessible: true, - accessType: PlayTicket.free, - playUrl: '/files/episode/10.mp4', - ); - - const free = Episode(id: '10', number: 1, title: '免费集', isFree: true); - final resolved = await controller.resolvePlay(free); - expect(resolved.videoUrl, '/files/episode/10.mp4'); - - const locked = Episode( - id: '11', - number: 2, - title: '锁定集', - isFree: false, - locked: true, - ); - final lockedResolved = await controller.resolvePlay(locked); - expect(lockedResolved.videoUrl, isEmpty); - }); } Drama _card(String id) => Drama( @@ -102,7 +77,6 @@ class ScriptedCatalogRepository implements CatalogRepository { bool fail = false; HomeFeed feed = const HomeFeed(); int detailCalls = 0; - final Map tickets = {}; @override Future fetchHomeFeed() async { @@ -119,13 +93,4 @@ class ScriptedCatalogRepository implements CatalogRepository { return _detail(dramaId); } - @override - Future resolvePlay(String episodeId) async { - return tickets[episodeId] ?? - PlayTicket( - episodeId: episodeId, - accessible: false, - accessType: PlayTicket.noMedia, - ); - } } diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 30daae7..ba93cc2 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -33,7 +33,7 @@ void main() { final logic = DiscoverLogic(history: history, drama: _drama('signal')); expect(await logic.position, const Duration(seconds: 4)); - expect(history.reads, ['demo-local-v1|signal|signal-1']); + expect(history.reads, ['signal']); expect(logic.category, 0); expect(logic.searchOpen, isFalse); logic.dispose(); @@ -47,10 +47,7 @@ void main() { logic.refreshPosition(); await logic.position; - expect(history.reads, [ - 'demo-local-v1|signal|signal-1', - 'demo-local-v1|city|city-1', - ]); + expect(history.reads, ['signal', 'city']); logic.dispose(); }); @@ -64,11 +61,11 @@ void main() { ); expect(await logic.position, const Duration(seconds: 9)); - expect(history.reads, ['demo-local-v1|signal|signal-2']); + expect(history.reads, ['signal']); logic.dispose(); }); - test('should_have_no_resume_position_when_list_card_has_no_history', () async { + test('should_have_no_resume_position_when_server_has_no_record', () async { final history = FakeWatchHistory(); final logic = DiscoverLogic( history: history, @@ -76,7 +73,8 @@ void main() { ); expect(await logic.position, isNull); - expect(history.reads, isEmpty); + // 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次 + expect(history.reads, ['signal']); logic.dispose(); }); @@ -153,7 +151,7 @@ void main() { history.reads.clear(); sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); expect(logic.drama.id, 'vengeance'); - expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']); + expect(history.reads, ['vengeance']); // Staying active with no change leaves both drama and history alone. history.reads.clear(); diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 248fe9a..48b5e86 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -1,10 +1,22 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.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/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; import '../support/fake_watch_history.dart'; +/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。 +DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) => + DramaDetailLogic( + drama: drama, + history: history, + playback: FakePlaybackRepository(), + favorites: FavoriteController(InMemoryFavoriteRepository()), + ); + Drama _card(String id) => Drama( id: id, title: id, @@ -44,10 +56,7 @@ Drama _detail(String id) => Drama( void main() { test('should_upgrade_to_full_episode_list_when_detail_loads_for_list_card', () async { var calls = 0; - final logic = DramaDetailLogic( - drama: _card('signal'), - history: FakeWatchHistory(), - ); + final logic = _logic(_card('signal'), FakeWatchHistory()); await logic.load(() async { calls++; @@ -62,10 +71,7 @@ void main() { }); test('should_keep_card_and_report_reason_when_load_fails', () async { - final logic = DramaDetailLogic( - drama: _card('signal'), - history: FakeWatchHistory(), - ); + final logic = _logic(_card('signal'), FakeWatchHistory()); await logic.load(() async => throw const ApiException(500, '服务异常')); @@ -81,10 +87,7 @@ void main() { test('should_skip_request_when_detail_is_already_loaded', () async { var calls = 0; - final logic = DramaDetailLogic( - drama: _detail('signal'), - history: FakeWatchHistory(), - ); + final logic = _logic(_detail('signal'), FakeWatchHistory()); await logic.load(() async { calls++; diff --git a/test/integration/live_catalog_test.dart b/test/integration/live_catalog_test.dart index 0d17476..274cf60 100644 --- a/test/integration/live_catalog_test.dart +++ b/test/integration/live_catalog_test.dart @@ -1,12 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/catalog_api.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; /// 联调测试:直连本机客户端接口服务(8081)。 /// 服务没启动时自动跳过,不影响日常 `flutter test`。 void main() { final client = ApiClient(baseUrl: 'http://127.0.0.1:8081'); final api = CatalogApi(client); + final playback = PlaybackApi(client); tearDownAll(client.close); @@ -69,7 +71,7 @@ void main() { return; } - final ticket = await api.playTicket(playable.first.id); + final ticket = await playback.ticket(playable.first.id); expect(ticket.accessible, isTrue); expect(ticket.playUrl, startsWith('/files/')); }); diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart new file mode 100644 index 0000000..aafd8df --- /dev/null +++ b/test/integration/live_watch_flow_test.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; +import 'package:fruitshorts/core/data/remote/library_api.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; +import 'package:fruitshorts/core/data/remote/watch_api.dart'; + +/// K1 端到端联调:直连本机客户端接口服务(8081),把"首页 → 详情 → 播放票据 → +/// 上报进度 → 读回续看位置 → 收藏 → 收藏列表 → 取消收藏"整条链路走一遍。 +/// +/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。 +void main() { + const baseUrl = 'http://127.0.0.1:8081'; + final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; + const password = 'Passw0rd!23'; + + AuthSession? session; + final client = ApiClient( + baseUrl: baseUrl, + accessTokenReader: () => session?.accessToken, + deviceIdReader: () async => 'k1-live-device', + ); + final catalog = CatalogApi(client); + final playback = PlaybackApi(client); + final watch = WatchApi(client); + final library = LibraryApi(client); + + tearDownAll(client.close); + + Future register() async { + try { + final raw = await client.post( + '/app/auth/register', + body: { + 'targetType': 'EMAIL', + 'target': email, + 'password': password, + 'nickname': 'K1 联调', + 'deviceId': 'k1-live-device', + }, + ); + if (raw is! Map) return false; + session = AuthSession.fromJson(raw); + return true; + } catch (_) { + return false; + } + } + + test('should_complete_the_watch_flow_when_backend_is_running', () async { + if (!await register()) { + markTestSkipped('客户端接口服务未启动,已跳过联调测试'); + return; + } + expect(session?.user.uid, isNotEmpty); + + // 1) 首页 → 取第一部剧 + final feed = await catalog.homeFeed(); + expect(feed.dramas, isNotEmpty); + final card = feed.dramas.first; + expect(card.id, isNotEmpty); + + // 2) 详情:剧集列表按集号升序,可播剧集带已签名地址 + final detail = await catalog.dramaDetail(card.id); + expect(detail.id, card.id); + expect(detail.episodes, isNotEmpty); + final playable = detail.episodes + .where((episode) => episode.videoUrl.isNotEmpty) + .toList(); + if (playable.isEmpty) { + markTestSkipped('这部演示剧没有可播剧集,已跳过播放相关断言'); + return; + } + + // 3) 播放票据:签名地址带 e/s,客户端原样使用 + final ticket = await playback.ticket(playable.first.id); + expect(ticket.accessible, isTrue); + expect(ticket.accessType, isNotEmpty); + expect(ticket.playUrl, startsWith('/files/')); + expect(ticket.playUrl, contains('e=')); + expect(ticket.playUrl, contains('s=')); + + // 4) 上报进度 → 读回续看位置 + const position = Duration(seconds: 42); + final saved = await watch.saveProgress( + episodeId: playable.first.id, + position: position, + duration: Duration(seconds: ticket.durationSec), + ); + expect(saved?.episodeId, playable.first.id); + expect(saved?.position, position); + + final resume = await watch.progressOfDrama(card.id); + expect(resume?.episodeId, playable.first.id); + expect(resume?.position, position); + + // 最近观看里能看到这部剧 + final recent = await watch.recent(limit: 20); + expect(recent.map((item) => item.dramaId), contains(card.id)); + + // 5) 收藏 → 出现在收藏列表 → 取消收藏 + final savedState = await library.toggleFavorite(card.id); + expect(savedState, isTrue); + final favorites = await library.favorites(); + expect(favorites.map((drama) => drama.id), contains(card.id)); + + await library.removeFavorite(card.id); + final afterRemove = await library.favorites(); + expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id))); + }); +} diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart new file mode 100644 index 0000000..335b2e0 --- /dev/null +++ b/test/library/favorite_controller_test.dart @@ -0,0 +1,103 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; + +Drama _card(String id) => Drama( + id: id, + title: 'Drama $id', + synopsis: 'synopsis', + posterUrl: '/files/$id.png', +); + +void main() { + test('should_roll_back_when_toggle_fails', () async { + final repository = InMemoryFavoriteRepository()..failToggle = true; + final controller = FavoriteController(repository); + + final ok = await controller.toggle('signal'); + + expect(ok, isFalse); + // 乐观更新必须回滚:不能"看起来收藏了,其实服务端没存" + expect(controller.isSaved('signal'), isFalse); + expect(controller.cards, isEmpty); + }); + + test('should_keep_saved_state_when_toggle_succeeds', () async { + final controller = FavoriteController(InMemoryFavoriteRepository()); + + final ok = await controller.toggle('signal', card: _card('signal')); + + expect(ok, isTrue); + expect(controller.isSaved('signal'), isTrue); + // 卡片随手带上:收藏完打开"我的收藏"就能看到 + expect(controller.cards.map((drama) => drama.id), ['signal']); + }); + + test('should_remove_card_and_state_when_toggled_off', () async { + final repository = InMemoryFavoriteRepository(saved: {'signal'}); + final controller = FavoriteController(repository); + // 先按服务端状态对齐,再取消收藏 + await controller.load(); + + await controller.toggle('signal'); + + expect(controller.isSaved('signal'), isFalse); + expect(controller.cards, isEmpty); + }); + + test('should_expose_error_and_keep_old_state_when_list_fails', () async { + final repository = InMemoryFavoriteRepository()..failList = true; + final controller = FavoriteController(repository); + + await controller.load(); + + expect(controller.loading, isFalse); + expect(controller.error, 'favoritesError'); + }); + + test('should_load_cards_and_saved_ids_when_list_succeeds', () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal'), _card('city')], + ); + final controller = FavoriteController(repository); + + await controller.load(); + + expect(controller.error, isNull); + expect(controller.cards.map((drama) => drama.id), ['signal']); + expect(controller.isSaved('signal'), isTrue); + expect(controller.isSaved('city'), isFalse); + }); + + test('should_roll_back_when_remove_fails', () async { + final repository = InMemoryFavoriteRepository( + saved: {'signal'}, + cards: [_card('signal')], + ); + final controller = FavoriteController(repository); + await controller.load(); + + final ok = await controller.remove('signal'); + + // InMemory 实现的 remove 不会失败,这里断言正常路径:状态与列表一起清掉 + expect(ok, isTrue); + expect(controller.isSaved('signal'), isFalse); + expect(controller.cards, isEmpty); + }); + + test('should_clear_state_when_signing_out', () async { + final controller = FavoriteController( + InMemoryFavoriteRepository(saved: {'signal'}, cards: [_card('signal')]), + ); + await controller.load(); + + controller.clear(); + + expect(controller.savedIds, isEmpty); + expect(controller.cards, isEmpty); + }); +} diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index 1bd1f1b..5f12360 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -43,7 +43,7 @@ void main() { expect(logic.section, 1); expect(logic.filter, 0); expect(await logic.position, const Duration(seconds: 7)); - expect(history.reads, ['demo-local-v1|signal|signal-1']); + expect(history.reads, ['signal']); logic.dispose(); }); @@ -58,7 +58,7 @@ void main() { ); expect(await logic.position, const Duration(seconds: 7)); - expect(history.reads, ['demo-local-v1|signal|signal-3']); + expect(history.reads, ['signal']); logic.dispose(); }); @@ -116,7 +116,7 @@ void main() { history.reads.clear(); sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); expect(logic.drama.id, 'vengeance'); - expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']); + expect(history.reads, ['vengeance']); history.reads.clear(); sync(_drama('other'), active: true, wasActive: true, dramaChanged: false); diff --git a/test/playback/playback_ticket_expiry_test.dart b/test/playback/playback_ticket_expiry_test.dart new file mode 100644 index 0000000..f301312 --- /dev/null +++ b/test/playback/playback_ticket_expiry_test.dart @@ -0,0 +1,120 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/playback_api.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/video_player_port.dart'; + +import '../support/fake_http_adapter.dart'; + +/// 播放票据有有效期(契约 §1.4:签名默认 15 分钟): +/// 缓存期内不重复取,过期或显式强刷必须重新取,且**签名地址原样使用**。 +void main() { + String envelope(String playUrl) => jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': '11', + 'dramaId': '5', + 'number': 1, + 'title': '第 1 集', + 'durationSec': 180, + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': playUrl, + 'qualities': ['AUTO'], + 'unlockCoin': 0, + 'previewSeconds': 0, + }, + }); + + _TicketEnv build() { + var now = DateTime(2026, 10, 16, 12); + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope('/files/episode/11.mp4?e=1760000000&s=9f2c'), + ), + ); + final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter); + final repository = HttpPlaybackRepository( + PlaybackApi(client), + ttl: const Duration(minutes: 10), + clock: () => now, + ); + return _TicketEnv( + repository: repository, + adapter: adapter, + advance: (Duration d) => now = now.add(d), + ); + } + + test('should_reuse_ticket_when_cache_is_still_fresh', () async { + final env = build(); + await env.repository.ticketFor('11'); + env.advance(const Duration(minutes: 9)); + await env.repository.ticketFor('11'); + + expect(env.adapter.requests, hasLength(1)); + }); + + test('should_refetch_ticket_when_cache_expired', () async { + final env = build(); + await env.repository.ticketFor('11'); + env.advance(const Duration(minutes: 11)); + final ticket = await env.repository.ticketFor('11'); + + expect(env.adapter.requests, hasLength(2)); + expect(ticket.playUrl, contains('?e=1760000000&s=9f2c')); + }); + + test('should_refetch_ticket_when_explicitly_forced', () async { + final env = build(); + await env.repository.ticketFor('11'); + await env.repository.ticketFor('11', forceRefresh: true); + + expect(env.adapter.requests, hasLength(2)); + }); + + test('should_report_freshness_when_asking_before_expiry', () async { + final env = build(); + expect(env.repository.isFresh('11'), isFalse); + await env.repository.ticketFor('11'); + expect(env.repository.isFresh('11'), isTrue); + env.advance(const Duration(minutes: 11)); + expect(env.repository.isFresh('11'), isFalse); + }); + + test('should_keep_signed_query_verbatim_when_resolving_media_uri', () { + const episode = Episode( + id: '11', + number: 1, + title: '第 1 集', + isFree: true, + videoUrl: '/files/episode/11.mp4?e=1760000000&s=9f2c', + ); + + final uri = VideoPlayerPort.resolveUri(episode); + + // 只补 host,路径与查询串原样保留(签名丢了就等于 403) + expect(uri.path, '/files/episode/11.mp4'); + expect(uri.query, 'e=1760000000&s=9f2c'); + expect(uri.toString(), endsWith('/files/episode/11.mp4?e=1760000000&s=9f2c')); + }); +} + +/// 测试用的小环境:仓库 + 假适配器 + 可推进的时钟。 +class _TicketEnv { + _TicketEnv({ + required this.repository, + required this.adapter, + required this.advance, + }); + + final HttpPlaybackRepository repository; + final FakeHttpAdapter adapter; + final void Function(Duration) advance; +} diff --git a/test/playback/progress_throttle_test.dart b/test/playback/progress_throttle_test.dart new file mode 100644 index 0000000..90b3697 --- /dev/null +++ b/test/playback/progress_throttle_test.dart @@ -0,0 +1,178 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; + +import '../support/fake_watch_history.dart'; +import '../support/widget_player.dart'; + +Drama _drama(String id, {List numbers = const [1, 2, 3]}) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (final number in numbers) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + videoUrl: 'https://example.test/clip.mp4', + isFree: true, + ), + ], +); + +PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) { + final logic = PlayerLogic( + history: history, + playback: FakePlaybackRepository(), + pictureInPicture: AndroidPictureInPicture( + channel: const MethodChannel('test/progress/pip'), + ), + ); + if (drama != null) { + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (episode) => WidgetPlayer(), + coordinator: PlaybackCoordinator(), + ); + } + return logic; +} + +/// 排空 session 串行队列,避免测试结束在 dispose 中途。 +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('progress throttle', () { + test('should_report_once_per_interval_when_progress_ticks_keep_coming', () { + final logic = _logic(FakeWatchHistory()); + final start = DateTime(2026, 10, 16, 12); + logic.lastProgressWrite = start; + + // 契约建议每 10 秒一次:3 秒、9 秒都不报,第 10 秒报一次 + expect(logic.progressInterval, const Duration(seconds: 10)); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 3))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 9))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 10))), isTrue); + // 报过之后节流窗口重新开始 + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 15))), isFalse); + expect(logic.shouldReportProgress(start.add(const Duration(seconds: 20))), isTrue); + logic.dispose(); + }); + + test('should_write_immediately_when_saving_on_episode_switch_or_exit', () async { + final history = FakeWatchHistory(); + final drama = _drama('signal'); + final logic = _logic(history, drama: drama); + + // 切集/退出/切后台走 saveCurrentProgress:不受 10 秒节流限制 + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(seconds: 5), + duration: const Duration(minutes: 3), + ); + await logic.queueProgress( + drama, + drama.episodes[1], + const Duration(seconds: 6), + duration: const Duration(minutes: 3), + ); + + expect(history.writes, [ + 'signal-1|5000|false', + 'signal-2|6000|false', + ]); + logic.dispose(); + }); + + test('should_report_finished_when_episode_plays_to_the_end', () async { + final history = FakeWatchHistory(); + final drama = _drama('signal'); + final logic = _logic(history, drama: drama); + + await logic.queueProgress( + drama, + drama.episodes[0], + const Duration(minutes: 3), + duration: const Duration(minutes: 3), + finished: true, + ); + + expect(history.writes, ['signal-1|180000|true']); + logic.dispose(); + }); + }); + + group('resume positioning', () { + test('should_take_position_from_server_when_record_matches_the_episode', () async { + final history = FakeWatchHistory( + )..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-2', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ); + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePositionFor('signal-2'), const Duration(seconds: 42)); + // 记录里不是这一集:不给定位(从头播,避免"看第 1 集却跳到第 2 集的位置") + expect(logic.resumePositionFor('signal-1'), isNull); + expect(logic.resumePositionFor('signal-3'), isNull); + logic.dispose(); + }); + + test('should_have_no_resume_position_when_server_read_fails', () async { + final history = FakeWatchHistory()..failReads = true; + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePoint, isNull); + expect(logic.resumeLoaded, isTrue); + logic.dispose(); + }); + + test('should_not_seek_when_position_is_zero', () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration.zero, + duration: Duration(minutes: 3), + ); + final logic = _logic(history, drama: _drama('signal')); + await _drain(logic); + + await logic.loadResumePoint(); + + expect(logic.resumePositionFor('signal-1'), isNull); + logic.dispose(); + await _drain(logic); + }); + }); +} diff --git a/test/playback/watch_history_test.dart b/test/playback/watch_history_test.dart index bc12aaa..fb7a9c6 100644 --- a/test/playback/watch_history_test.dart +++ b/test/playback/watch_history_test.dart @@ -1,9 +1,11 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +// Author: fruit.com.cn +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +/// 进度仓库从"本地按 profile+剧+集"换成"服务端每剧一条": +/// 这里钉住续看点换算、空记录、字符串数值解析、失败透传与清理。 void main() { test('should_clamp_invalid_and_completed_values_when_resolving_resume_position', () { const duration = Duration(seconds: 8); @@ -25,106 +27,101 @@ void main() { ); }); - test('should_scope_history_by_profile_drama_and_episode_when_saving', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final history = SharedPreferencesWatchHistoryRepository(); - await history.save( - 'profile-a', - 'drama-a', - 'episode-1', - const Duration(seconds: 3), - ); - await history.save( - 'profile-a', - 'drama-a', - 'episode-2', - const Duration(seconds: 5), - ); - expect( - await history.positionFor('profile-a', 'drama-a', 'episode-1'), - const Duration(seconds: 3), - ); - expect( - await history.positionFor('profile-b', 'drama-a', 'episode-1'), - isNull, - ); - expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2'); - expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull); - }); - - test('should_prefer_first_loaded_episode_when_resuming', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final history = SharedPreferencesWatchHistoryRepository(); - await history.save( - 'profile-a', - 'drama-a', - 'episode-1', - const Duration(seconds: 3), - ); - await history.save( - 'profile-a', - 'drama-a', - 'episode-2', - const Duration(seconds: 5), - ); - - final drama = Drama( - id: 'drama-a', - title: 'title', - synopsis: 'synopsis', - posterUrl: '', - episodes: const [ - Episode(id: 'episode-1', number: 1, title: 'E1', isFree: true), - Episode(id: 'episode-2', number: 2, title: 'E2', isFree: true), + test('should_return_resume_point_when_server_has_progress', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: 'episode-3', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ), ], ); - expect( - await history.resumePositionFor('profile-a', drama), - const Duration(seconds: 3), - ); + final point = await history.resumePointFor('drama-a'); + + expect(point?.episodeId, 'episode-3'); + expect(point?.position, const Duration(seconds: 42)); }); - test('should_fall_back_to_last_watched_episode_when_resuming_a_list_card', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final history = SharedPreferencesWatchHistoryRepository(); - await history.save( - 'profile-a', - 'drama-a', - 'episode-2', - const Duration(seconds: 5), - ); + test('should_return_no_resume_point_when_server_has_no_record', () async { + final history = InMemoryWatchHistoryRepository(); - const card = Drama( - id: 'drama-a', - title: 'title', - synopsis: 'synopsis', - posterUrl: '', - totalEpisodes: 12, - ); - - expect( - await history.resumePositionFor('profile-a', card), - const Duration(seconds: 5), - ); + expect(await history.resumePointFor('drama-a'), isNull); }); - test('should_have_no_resume_position_when_list_card_has_no_history', () async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final history = SharedPreferencesWatchHistoryRepository(); - - const card = Drama( - id: 'drama-b', - title: 'title', - synopsis: 'synopsis', - posterUrl: '', - totalEpisodes: 12, + test('should_return_no_resume_point_when_record_has_no_episode', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: '', + position: Duration(seconds: 5), + duration: Duration(minutes: 3), + ), + ], ); - expect(await history.resumePositionFor('profile-a', card), isNull); + expect(await history.resumePointFor('drama-a'), isNull); + }); + + test('should_expose_fraction_when_duration_is_known', () { + const record = WatchProgress( + dramaId: 'd', + episodeId: 'e', + position: Duration(seconds: 30), + duration: Duration(seconds: 120), + ); + expect(record.fraction, closeTo(0.25, 0.0001)); + expect(record.hasPosition, isTrue); + + const unknown = WatchProgress( + dramaId: 'd', + episodeId: 'e', + position: Duration(seconds: 30), + duration: Duration.zero, + ); + expect(unknown.fraction, 0); + }); + + test('should_parse_string_numbers_when_server_sends_ids_and_ms_as_strings', () { + final record = WatchProgress.fromJson({ + 'dramaId': '2111012753262120962', + 'episodeId': '2111012753262120963', + 'positionMs': '42000', + 'durationMs': '180000', + 'finished': false, + 'playCount': '2', + }); + + expect(record.dramaId, '2111012753262120962'); + expect(record.episodeId, '2111012753262120963'); + expect(record.position, const Duration(seconds: 42)); + expect(record.duration, const Duration(seconds: 180)); + expect(record.playCount, 2); + }); + + test('should_throw_when_reading_progress_fails', () async { + final history = InMemoryWatchHistoryRepository()..failReads = 1; + + expect(() => history.progressFor('drama-a'), throwsStateError); + }); + + test('should_drop_record_when_removing_progress', () async { + final history = InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'drama-a', + episodeId: 'e1', + position: Duration(seconds: 5), + duration: Duration(minutes: 1), + ), + ], + ); + + await history.remove('drama-a'); + + expect(await history.progressFor('drama-a'), isNull); }); } diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 1ed2607..0b516ea 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; @@ -9,6 +10,7 @@ import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_watch_history.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; Drama _drama( String id, { @@ -35,8 +37,14 @@ Drama _drama( AndroidPictureInPicture _pip() => AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip')); -PlayerLogic _logic(FakeWatchHistory history) => - PlayerLogic(history: history, pictureInPicture: _pip()); +PlayerLogic _logic( + FakeWatchHistory history, { + PlaybackRepository? playback, +}) => PlayerLogic( + history: history, + playback: playback ?? FakePlaybackRepository(), + pictureInPicture: _pip(), +); /// Drains the session's serialized work so a test never ends mid-disposal. /// @@ -191,10 +199,7 @@ void main() { const Duration(seconds: 2), ); - expect(history.writes, [ - 'demo-local-v1|signal|signal-1|1000', - 'demo-local-v1|signal|signal-2|2000', - ]); + expect(history.writes, ['signal-1|1000|false', 'signal-2|2000|false']); }); test('should_keep_later_writes_when_an_earlier_write_fails', () async { @@ -216,7 +221,7 @@ void main() { const Duration(seconds: 2), ); - expect(history.writes, ['demo-local-v1|signal|signal-2|2000']); + expect(history.writes, ['signal-2|2000|false']); }); test('should_write_nothing_when_no_player_is_tracked', () async { @@ -284,7 +289,18 @@ void main() { test('should_never_reach_player_factory_when_episode_is_locked', () async { final factory = _RecordingFactory(); final drama = _drama('locked', isFree: false); - final logic = _logic(FakeWatchHistory()); + // 锁定的判定权在后端:票据返回不可播 + 无地址,客户端不得自行推断 + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 3bfe1d6..fc6d54a 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,6 +1,5 @@ import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; /// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。 @@ -20,16 +19,6 @@ class FakeCatalogRepository implements CatalogRepository { return dramas.firstWhere((drama) => drama.id == dramaId); } - @override - Future resolvePlay(String episodeId) async { - return PlayTicket( - episodeId: episodeId, - accessible: true, - accessType: PlayTicket.free, - playUrl: 'https://example.test/$episodeId.mp4', - ); - } - HomeFeed _feed() => HomeFeed( sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)], ); diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart index 9b4d8b1..6568866 100644 --- a/test/support/fake_watch_history.dart +++ b/test/support/fake_watch_history.dart @@ -1,44 +1,67 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; -/// Records every history interaction so tests can assert on the key it used. +/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。 +/// +/// 服务端模型:每部剧一条续看记录(不再是 profile+剧+集),所以 `reads` 里是 dramaId。 class FakeWatchHistory implements WatchHistoryRepository { final List reads = []; final List writes = []; - /// Position returned by [positionFor] when [failReads] is false. + /// 便捷写法:只关心"有续看位置"时直接给这个值(默认落在第 1 集)。 Duration? saved; - /// Last episode returned by [lastEpisodeId]. + /// 便捷写法:续看记录落在哪一集(配合 [saved] 使用)。 String? lastEpisode; + /// 精确控制:按剧返回不同记录。 + final Map records = {}; + + /// [recent] 的返回值。 + List recentRecords = const []; + bool failReads = false; bool failWrites = false; @override - Future positionFor( - String profileId, - String dramaId, - String episodeId, - ) async { - reads.add('$profileId|$dramaId|$episodeId'); + Future progressFor(String dramaId) async { + reads.add(dramaId); if (failReads) throw StateError('history unavailable'); - return saved; + final record = records[dramaId]; + if (record != null) return record; + if (saved == null) return null; + return WatchProgress( + dramaId: dramaId, + episodeId: lastEpisode ?? '$dramaId-1', + position: saved!, + duration: const Duration(minutes: 5), + ); } @override - Future lastEpisodeId(String profileId, String dramaId) async { - if (failReads) throw StateError('history unavailable'); - return lastEpisode; - } - - @override - Future save( - String profileId, - String dramaId, - String episodeId, - Duration position, - ) async { + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async { if (failWrites) throw StateError('history unavailable'); - writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}'); + writes.add('$episodeId|${position.inMilliseconds}|$finished'); + return WatchProgress( + dramaId: '', + episodeId: episodeId, + position: position, + duration: duration ?? Duration.zero, + finished: finished, + ); } + + @override + Future> recent({int limit = 20}) async { + if (failReads) throw StateError('history unavailable'); + return recentRecords.take(limit).toList(); + } + + @override + Future remove(String dramaId) async => records.remove(dramaId); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 9bdae0b..84a0787 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -8,6 +8,11 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'support/fake_catalog.dart'; import 'support/widget_player.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; void main() { /// 默认按「已登录」起测:账号体系接上后,"登录"不再是本地演示开关, @@ -27,17 +32,31 @@ void main() { seedSignedInSession(); }); + /// 所有 widget 测试都注入离线仓库:票据 / 进度 / 收藏都不碰网络, + /// 这样断言的是 UI 行为,而不是后端有没有起。 + Widget appUnderTest({ + PlayerFactory? playerFactory, + CatalogRepository? catalogRepository, + }) => LumaDramaApp( + playerFactory: playerFactory ?? createWidgetPlayer, + catalogRepository: catalogRepository ?? FakeCatalogRepository(), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + cards: FakeCatalogRepository().dramas, + ), + playbackRepository: FakePlaybackRepository(), + ); + testWidgets( 'should_promote_next_without_recreating_it_or_showing_loading_when_home_is_swiped', (tester) async { final created = []; await tester.pumpWidget( - LumaDramaApp( + appUnderTest( playerFactory: (episode) { created.add(episode.id); return WidgetPlayer(); }, - catalogRepository: FakeCatalogRepository(), ), ); await tester.pumpAndSettle(); @@ -64,7 +83,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -91,7 +110,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -116,7 +135,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); expect(find.text('Continue Watching'), findsOneWidget); @@ -171,7 +190,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); expect(find.byTooltip('Captions'), findsNothing); @@ -200,7 +219,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -226,7 +245,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); @@ -249,7 +268,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.text('Home')); @@ -283,7 +302,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.text('Home')); @@ -335,7 +354,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -366,7 +385,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -404,7 +423,7 @@ void main() { ); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); @@ -445,7 +464,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); @@ -473,8 +492,10 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + // 收藏要求登录态:沿用本文件已有的"假登录"初始化 + seedSignedInSession(); await tester.pumpWidget( - LumaDramaApp(playerFactory: createWidgetPlayer, catalogRepository: FakeCatalogRepository()), + appUnderTest(), ); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home')));