diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 0ca516e..c02e6dd 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -60,6 +60,20 @@ class Episode { posterUrl: posterUrl ?? this.posterUrl, ); + Episode withStats({int? likeCount, int? commentCount}) => Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: videoUrl, + durationSec: durationSec, + locked: locked, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + thumbnailsUrl: thumbnailsUrl, + posterUrl: posterUrl, + ); + factory Episode.fromJson(Map json) { return Episode( id: (json['id'] ?? '').toString(), diff --git a/lib/core/data/models/episode_stats.dart b/lib/core/data/models/episode_stats.dart new file mode 100644 index 0000000..65a29d6 --- /dev/null +++ b/lib/core/data/models/episode_stats.dart @@ -0,0 +1,55 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 播放页轻量互动计数快照,不包含播放地址或票据信息。 +class EpisodeStats { + const EpisodeStats({ + required this.episodeId, + required this.dramaId, + required this.likeCount, + required this.commentCount, + required this.favoriteCount, + required this.liked, + required this.favorited, + }); + + final String episodeId; + final String dramaId; + final int likeCount; + final int commentCount; + final int favoriteCount; + final bool liked; + final bool favorited; + + EpisodeStats copyWith({ + String? dramaId, + int? likeCount, + int? commentCount, + int? favoriteCount, + bool? liked, + bool? favorited, + }) { + return EpisodeStats( + episodeId: episodeId, + dramaId: dramaId ?? this.dramaId, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + favoriteCount: favoriteCount ?? this.favoriteCount, + liked: liked ?? this.liked, + favorited: favorited ?? this.favorited, + ); + } + + factory EpisodeStats.fromJson(Map json) { + return EpisodeStats( + episodeId: jsonToId(json['episodeId']), + dramaId: jsonToId(json['dramaId']), + likeCount: jsonToInt(json['likeCount']), + commentCount: jsonToInt(json['commentCount']), + favoriteCount: jsonToInt(json['favoriteCount']), + liked: json['liked'] == true, + favorited: json['favorited'] == true, + ); + } +} diff --git a/lib/core/data/remote/playback_api.dart b/lib/core/data/remote/playback_api.dart index 0122d95..6b40fb7 100644 --- a/lib/core/data/remote/playback_api.dart +++ b/lib/core/data/remote/playback_api.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/episode_stats.dart'; import '../models/play_ticket.dart'; import 'api_client.dart'; @@ -17,4 +18,11 @@ class PlaybackApi { if (data is Map) return PlayTicket.fromJson(data); throw const ApiException(-1, '返回数据格式异常'); } + + /// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。 + Future episodeStats(String episodeId) async { + final data = await _client.get('/app/episodes/$episodeId/stats'); + if (data is Map) return EpisodeStats.fromJson(data); + throw const ApiException(-1, '返回数据格式异常'); + } } diff --git a/lib/core/data/repositories/playback_repository.dart b/lib/core/data/repositories/playback_repository.dart index d68065a..c2bc4af 100644 --- a/lib/core/data/repositories/playback_repository.dart +++ b/lib/core/data/repositories/playback_repository.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/episode_stats.dart'; import '../models/play_ticket.dart'; import '../remote/playback_api.dart'; @@ -10,6 +11,9 @@ import '../remote/playback_api.dart'; abstract interface class PlaybackRepository { Future ticketFor(String episodeId, {bool forceRefresh = false}); + /// 读取互动计数快照;与 [ticketFor] 分离,避免为了刷新计数重新签发地址。 + Future episodeStats(String episodeId); + /// 当前缓存的票据是否还在有效期内(用于"回前台发现过期就重新取")。 bool isFresh(String episodeId); } @@ -42,6 +46,10 @@ class HttpPlaybackRepository implements PlaybackRepository { return ticket; } + @override + Future episodeStats(String episodeId) => + _api.episodeStats(episodeId); + @override bool isFresh(String episodeId) { final cached = _cache[episodeId]; @@ -65,11 +73,22 @@ class _CachedTicket { /// 测试与离线场景用:按集返回固定票据,并记录被请求过几次。 class FakePlaybackRepository implements PlaybackRepository { - FakePlaybackRepository({this.ticket, this.error, this.fresh = true}); + FakePlaybackRepository({ + this.ticket, + this.error, + this.fresh = true, + this.stats, + this.statsError, + Map? statsByEpisode, + }) : statsByEpisode = statsByEpisode ?? {}; PlayTicket? ticket; Object? error; + EpisodeStats? stats; + Object? statsError; + final Map statsByEpisode; final List requests = []; + final List statsRequests = []; final List forced = []; /// 模拟"票据是否还有效":置 false 可验证回前台会重新取。 @@ -95,4 +114,13 @@ class FakePlaybackRepository implements PlaybackRepository { qualities: const ['AUTO', '720P', '1080P'], ); } + + @override + Future episodeStats(String episodeId) async { + statsRequests.add(episodeId); + if (statsError != null) throw statsError!; + final value = statsByEpisode[episodeId] ?? stats; + if (value != null) return value; + throw StateError('episode stats not configured for $episodeId'); + } } diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 1a30e0e..d57ca21 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -32,6 +32,17 @@ class FavoriteController extends ChangeNotifier { bool isSaved(String dramaId) => _saved.contains(dramaId); + /// 用统计快照校准收藏状态;已有列表卡片结构不因一次状态同步而改动。 + void applyServerState(String dramaId, {required bool saved}) { + final changed = _saved.contains(dramaId) != saved; + if (saved) { + _saved.add(dramaId); + } else { + _saved.remove(dramaId); + } + if (changed) notify(); + } + void notify() { if (!_disposed) notifyListeners(); } diff --git a/lib/core/data/services/interaction_controller.dart b/lib/core/data/services/interaction_controller.dart index fa585ea..a21f247 100644 --- a/lib/core/data/services/interaction_controller.dart +++ b/lib/core/data/services/interaction_controller.dart @@ -40,6 +40,24 @@ class InteractionController extends ChangeNotifier { /// 该集服务端最新点赞数。 int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId]; + /// 用轻量统计快照校准当前用户的分集点赞状态与计数。 + void applyEpisodeStats( + String episodeId, { + required bool liked, + required int likeCount, + }) { + final changed = + _likedEpisodeIds.contains(episodeId) != liked || + _episodeLikeCounts[episodeId] != likeCount; + if (liked) { + _likedEpisodeIds.add(episodeId); + } else { + _likedEpisodeIds.remove(episodeId); + } + _episodeLikeCounts[episodeId] = likeCount; + if (changed) _notify(); + } + /// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。 Future> toggleDramaLike(String dramaId) => _toggleLike( diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 86fd109..4bb67af 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/episode_stats.dart'; import '../../core/data/models/play_ticket.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/models/player_port.dart'; @@ -51,9 +52,11 @@ class PlayerLogic extends ChangeNotifier { this.downloads, PlaybackPositionCache? positionCache, Future Function()? waitForFrame, + DateTime Function()? clock, }) : positionCache = positionCache ?? PlaybackPositionCache(), _waitForFrame = - waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame); + waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), + _clock = clock ?? DateTime.now; final WatchHistoryRepository history; final PlaybackRepository playback; @@ -62,6 +65,7 @@ class PlayerLogic extends ChangeNotifier { final DownloadController? downloads; final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; + final DateTime Function() _clock; late PageController pages; late PlaybackQueue queue; @@ -100,11 +104,19 @@ class PlayerLogic extends ChangeNotifier { bool muted = false; double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; + Duration episodeStatsTtl = const Duration(seconds: 60); + ValueChanged? onEpisodeStatsUpdated; VideoPlayerPort? scrubbingPlayer; bool pip = false; bool pipPreparing = false; final Map pickedEpisode = {}; final Set _unlockedEpisodeIds = {}; + final Map _episodeStats = {}; + final Map _episodeStatsFetchedAt = {}; + final Map> _episodeStatsInFlight = {}; + final Map _episodeStatsRevision = {}; + final Map _dramaStatsRevision = {}; + final Map _dramaFavoriteCounts = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; @@ -168,6 +180,10 @@ class PlayerLogic extends ChangeNotifier { return fallback; } + EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId]; + + int? dramaFavoriteCount(String dramaId) => _dramaFavoriteCounts[dramaId]; + void notify() { if (!_disposed) notifyListeners(); } @@ -536,6 +552,179 @@ class PlayerLogic extends ChangeNotifier { } } + /// 当前集激活后刷新轻量互动计数;同集命中 TTL 或已有请求时不重复发请求。 + Future refreshEpisodeStats(int index) { + if (_disposed || index < 0 || index >= sessionEpisodes.length) { + return Future.value(); + } + final episodeId = sessionEpisodes[index].id; + final cached = _episodeStats[episodeId]; + final fetchedAt = _episodeStatsFetchedAt[episodeId]; + if (cached != null && + fetchedAt != null && + _clock().difference(fetchedAt) < episodeStatsTtl) { + _logEpisodeStatsRefresh(episodeId, cached: true); + onEpisodeStatsUpdated?.call(cached); + return Future.value(); + } + final inFlight = _episodeStatsInFlight[episodeId]; + if (inFlight != null) { + _logEpisodeStatsRefresh(episodeId, cached: true, inFlight: true); + return inFlight; + } + _logEpisodeStatsRefresh(episodeId, cached: false); + final expectedDramaId = index < queue.length + ? queue.entryAt(index).drama.id + : ''; + final episodeRevision = _episodeStatsRevision[episodeId] ?? 0; + final dramaRevision = _dramaStatsRevision[expectedDramaId] ?? 0; + late final Future request; + request = + _fetchEpisodeStats( + episodeId, + expectedDramaId: expectedDramaId, + episodeRevision: episodeRevision, + dramaRevision: dramaRevision, + ).whenComplete(() { + if (identical(_episodeStatsInFlight[episodeId], request)) { + _episodeStatsInFlight.remove(episodeId); + } + }); + _episodeStatsInFlight[episodeId] = request; + return request; + } + + Future _fetchEpisodeStats( + String episodeId, { + required String expectedDramaId, + required int episodeRevision, + required int dramaRevision, + }) async { + try { + final fetched = await playback.episodeStats(episodeId); + if (_disposed) return; + if ((_episodeStatsRevision[episodeId] ?? 0) != episodeRevision || + (_dramaStatsRevision[expectedDramaId] ?? 0) != dramaRevision) { + if (kDebugMode) { + debugPrint( + '[player] phase=episode-stats-discarded episodeId=$episodeId ' + 'reason=local-state-newer', + ); + } + return; + } + final stats = fetched.copyWith( + dramaId: fetched.dramaId.isEmpty ? expectedDramaId : fetched.dramaId, + ); + _episodeStats[episodeId] = stats; + _episodeStatsFetchedAt[episodeId] = _clock(); + if (stats.dramaId.isNotEmpty) { + _dramaFavoriteCounts[stats.dramaId] = stats.favoriteCount; + } + final episodeIndex = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (episodeIndex >= 0) { + sessionEpisodes[episodeIndex] = sessionEpisodes[episodeIndex].withStats( + likeCount: stats.likeCount, + commentCount: stats.commentCount, + ); + } + onEpisodeStatsUpdated?.call(stats); + notify(); + } catch (_) { + // 统计接口只负责校准计数,失败时保留播放与旧值。 + } + } + + void _logEpisodeStatsRefresh( + String episodeId, { + required bool cached, + bool inFlight = false, + }) { + if (!kDebugMode) return; + debugPrint( + '[player] phase=episode-stats-refresh episodeId=$episodeId ' + 'cached=${cached ? 1 : 0}${inFlight ? ' inFlight=1' : ''}', + ); + } + + /// 点赞成功后同步本地统计快照,避免切回时被旧缓存覆盖。 + void applyEpisodeLikeState( + String episodeId, { + required bool liked, + required int likeCount, + }) { + _episodeStatsRevision.update( + episodeId, + (value) => value + 1, + ifAbsent: () => 1, + ); + final stats = _episodeStats[episodeId]; + if (stats != null) { + _episodeStats[episodeId] = stats.copyWith( + liked: liked, + likeCount: likeCount, + ); + } + final index = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (index >= 0) { + sessionEpisodes[index] = sessionEpisodes[index].withStats( + likeCount: likeCount, + ); + } + notify(); + } + + /// 评论面板关闭后同步评论数。 + void applyEpisodeCommentCount(String episodeId, int commentCount) { + _episodeStatsRevision.update( + episodeId, + (value) => value + 1, + ifAbsent: () => 1, + ); + final stats = _episodeStats[episodeId]; + if (stats != null) { + _episodeStats[episodeId] = stats.copyWith(commentCount: commentCount); + } + final index = sessionEpisodes.indexWhere( + (episode) => episode.id == episodeId, + ); + if (index >= 0) { + sessionEpisodes[index] = sessionEpisodes[index].withStats( + commentCount: commentCount, + ); + } + notify(); + } + + /// 收藏成功时同时修正剧级计数和所有已缓存的同剧互动状态。 + void applyDramaFavoriteState( + String dramaId, { + required bool favorited, + int? favoriteCount, + }) { + _dramaStatsRevision.update( + dramaId, + (value) => value + 1, + ifAbsent: () => 1, + ); + if (favoriteCount != null) { + _dramaFavoriteCounts[dramaId] = favoriteCount; + } + for (final entry in _episodeStats.entries.toList()) { + final stats = entry.value; + if (stats.dramaId != dramaId) continue; + _episodeStats[entry.key] = stats.copyWith( + favorited: favorited, + favoriteCount: favoriteCount, + ); + } + notify(); + } + String _fileUrl(String path) => path.startsWith('file:') ? path : Uri.file(path).toString(); @@ -1076,7 +1265,10 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } - if (!ready) return; + if (!ready) { + unawaited(refreshEpisodeStats(index)); + return; + } await _selectVisible(index, userWantsPlay: userWantsPlay); } @@ -1087,13 +1279,16 @@ class PlayerLogic extends ChangeNotifier { } /// 按可见队列选择一集,并预热上一集与下一集。 - Future _selectVisible(int index, {bool? userWantsPlay}) { - return session.select( + Future _selectVisible(int index, {bool? userWantsPlay}) async { + await session.select( index, preloadIndex: nextVisibleIndex(index), previousIndex: previousVisibleIndex(index), userWantsPlay: userWantsPlay, ); + if (!_disposed && session.currentIndex == index) { + unawaited(refreshEpisodeStats(index)); + } } int? nextVisibleIndex(int index) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index e2feb43..0e9c110 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/episode_stats.dart'; import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; @@ -153,8 +154,7 @@ class _PlayerPageState extends State PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; - /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 - final Map _favoriteDelta = {}; + /// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。 final Map _commentCounts = {}; @override @@ -169,6 +169,7 @@ class _PlayerPageState extends State wallet: widget.wallet, downloads: widget.downloads, ); + logic.onEpisodeStatsUpdated = _applyEpisodeStats; logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -550,14 +551,39 @@ class _PlayerPageState extends State setState(() {}); } - /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 - void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { + void _applyEpisodeStats(EpisodeStats stats) { + if (!mounted) return; + context.read().applyEpisodeStats( + stats.episodeId, + liked: stats.liked, + likeCount: stats.likeCount, + ); + if (stats.dramaId.isNotEmpty) { + context.read().applyServerState( + stats.dramaId, + saved: stats.favorited, + ); + } setState(() { - _favoriteDelta[dramaId] = - (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); + _commentCounts[stats.episodeId] = stats.commentCount; }); } + /// 收藏状态确认后同步右侧剧级计数。 + void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) { + final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount; + final next = current == null + ? null + : nowSaved + ? current + 1 + : (current > 0 ? current - 1 : 0); + logic.applyDramaFavoriteState( + drama.id, + favorited: nowSaved, + favoriteCount: next, + ); + } + Future _openComments( BuildContext context, Drama drama, @@ -573,6 +599,7 @@ class _PlayerPageState extends State try { final count = await comments.count(drama.id, episodeId: episode.id); if (!mounted) return; + logic.applyEpisodeCommentCount(episode.id, count); setState(() => _commentCounts[episode.id] = count); } catch (_) { // 评论面板已经关闭,计数刷新失败时保留旧值即可。 @@ -1159,10 +1186,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: _commentCounts[episode.id] ?? episode.commentCount, + favoriteCount: + logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, + commentCount: + logic.episodeStats(episode.id)?.commentCount ?? + _commentCounts[episode.id] ?? + episode.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { await _toggleLike(context, interactions, episode); @@ -1265,10 +1294,12 @@ class _PlayerPageState extends State liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, - commentCount: _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + commentCount: + logic.episodeStats(episode.id)?.commentCount ?? + _commentCounts[episode.id] ?? + episode.commentCount, + favoriteCount: + logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount, saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 19f6667..5a19507 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -151,6 +151,14 @@ extension _PlayerViewActions on _PlayerPageState { ); } if (result.isSuccess) { + final likeCount = interactions.episodeLikeCount(episode.id); + if (likeCount != null) { + logic.applyEpisodeLikeState( + episode.id, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: likeCount, + ); + } _refreshLikeState(); return; } @@ -171,7 +179,7 @@ extension _PlayerViewActions on _PlayerPageState { if (ok) { final nowSaved = favorites.isSaved(drama.id); if (nowSaved != wasSaved) { - _applyFavoriteDelta(drama.id, nowSaved: nowSaved); + _applyFavoriteDelta(drama, nowSaved: nowSaved); } return; } diff --git a/test/player/player_episode_stats_test.dart b/test/player/player_episode_stats_test.dart new file mode 100644 index 0000000..da1d269 --- /dev/null +++ b/test/player/player_episode_stats_test.dart @@ -0,0 +1,301 @@ +import 'dart:convert'; + +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/episode_stats.dart'; +import 'package:fruitshorts/core/data/models/player_port.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/android_picture_in_picture.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_http_adapter.dart'; +import '../support/fake_watch_history.dart'; + +Drama _drama(String id) => Drama( + id: id, + title: id, + synopsis: 'synopsis', + posterUrl: '/files/poster.png', + episodes: [ + for (var number = 1; number <= 3; number++) + Episode( + id: '$id-$number', + number: number, + title: 'Episode $number', + isFree: true, + videoUrl: 'https://example.test/$id-$number.mp4', + likeCount: 1, + commentCount: 1, + ), + ], +); + +AndroidPictureInPicture _pip() => AndroidPictureInPicture( + channel: const MethodChannel('test/player_episode_stats/pip'), +); + +PlayerLogic _logic({ + required PlaybackRepository playback, + DateTime Function()? clock, +}) => PlayerLogic( + history: FakeWatchHistory(), + playback: playback, + pictureInPicture: _pip(), + waitForFrame: () async {}, + clock: clock, +); + +Future _drain(PlayerLogic logic) async { + for (var attempt = 0; attempt < 4; attempt++) { + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + } + await pumpEventQueue(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'should_refresh_episode_stats_on_up_and_down_swipes_without_reopening_play', + () async { + var now = DateTime(2026, 10, 26, 12); + final adapter = FakeHttpAdapter((options) async { + final path = options.uri.path; + if (path.endsWith('/play')) { + final episodeId = path.split('/')[3]; + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': episodeId, + 'accessible': true, + 'accessType': 'FREE', + 'playUrl': '/files/$episodeId.mp4', + 'qualities': ['AUTO'], + }, + }), + ); + } + if (path.endsWith('/stats')) { + final episodeId = path.split('/')[3]; + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'episodeId': episodeId, + 'dramaId': 'signal', + 'likeCount': episodeId == 'signal-1' ? '11' : '22', + 'commentCount': episodeId == 'signal-1' ? '3' : '4', + 'favoriteCount': '30', + 'liked': episodeId == 'signal-1', + 'favorited': false, + }, + }), + ); + } + throw StateError('unexpected request $path'); + }); + final client = ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + ); + final playback = HttpPlaybackRepository( + PlaybackApi(client), + clock: () => now, + ); + final logs = []; + final previousDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message == null) return; + logs.add(message); + if (message.contains('[player] phase=episode-stats-refresh')) { + previousDebugPrint(message, wrapWidth: wrapWidth); + } + }; + addTearDown(() { + debugPrint = previousDebugPrint; + client.close(); + }); + + final factory = _RecordingFactory(); + final logic = _logic( + playback: playback, + clock: () => now, + ); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + await logic.requestEpisodeSelection(1); + await _drain(logic); + final playRequestsBeforeBack = adapter.requests + .where((request) => request.uri.path.endsWith('/play')) + .length; + final statsRequestsBeforeBack = adapter.requests + .where((request) => request.uri.path.endsWith('/stats')) + .length; + + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect( + adapter.requests.where((request) => request.uri.path.endsWith('/play')), + hasLength(playRequestsBeforeBack), + ); + expect( + adapter.requests.where( + (request) => request.uri.path.endsWith('/stats'), + ), + hasLength(statsRequestsBeforeBack), + ); + expect( + logs, + contains( + '[player] phase=episode-stats-refresh episodeId=signal-1 cached=1', + ), + ); + expect(logic.episodeStats('signal-1')?.likeCount, 11); + + now = now.add(const Duration(seconds: 61)); + await logic.requestEpisodeSelection(1); + await _drain(logic); + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect( + adapter.requests.where( + (request) => request.uri.path.endsWith('/stats'), + ), + hasLength(statsRequestsBeforeBack + 2), + ); + expect( + adapter.requests.where((request) => request.uri.path.endsWith('/play')), + hasLength(playRequestsBeforeBack), + ); + expect( + logs.where( + (line) => line.contains( + 'episode-stats-refresh episodeId=signal-1 cached=0', + ), + ), + hasLength(2), + ); + + logic.dispose(); + await _drain(logic); + expect(factory.live, 0); + }, + ); + + test('should_keep_local_like_count_when_switching_back', () async { + final stats = EpisodeStats( + episodeId: 'signal-1', + dramaId: 'signal', + likeCount: 7, + commentCount: 2, + favoriteCount: 30, + liked: false, + favorited: false, + ); + final playback = FakePlaybackRepository( + statsByEpisode: {'signal-1': stats}, + ); + final logic = _logic(playback: playback); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + expect(logic.episodeStats('signal-1')?.likeCount, 7); + + logic.applyEpisodeLikeState('signal-1', liked: true, likeCount: 8); + await logic.requestEpisodeSelection(1); + await _drain(logic); + await logic.requestEpisodeSelection(0); + await _drain(logic); + + expect(logic.episodeStats('signal-1')?.liked, isTrue); + expect(logic.episodeStats('signal-1')?.likeCount, 8); + + logic.dispose(); + await _drain(logic); + }); + + test('should_keep_playback_when_episode_stats_fail', () async { + final playback = FakePlaybackRepository( + statsError: StateError('stats unavailable'), + ); + final logic = _logic(playback: playback); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(logic.session.active, isNotNull); + expect(logic.ticketError, isNull); + expect(logic.episodeStats('signal-1'), isNull); + + logic.dispose(); + await _drain(logic); + }); +} + +class _RecordingFactory { + int live = 0; + final List<_RecordingPlayer> created = []; + + PlayerPort create(Episode episode) { + live++; + final player = _RecordingPlayer(onDispose: () => live--); + created.add(player); + return player; + } +} + +class _RecordingPlayer implements PlayerPort { + _RecordingPlayer({required this.onDispose}); + + final void Function() onDispose; + bool disposed = false; + + @override + Future initialize() async {} + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + onDispose(); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b967a9f..eb58e01 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -11,6 +11,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/episode_stats.dart'; import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; @@ -2158,11 +2159,15 @@ class _RecordingPlayer implements PlayerPort { class _PendingPlaybackRepository implements PlaybackRepository { final Completer ticket = Completer(); + final Completer stats = Completer(); @override Future ticketFor(String episodeId, {bool forceRefresh = false}) => ticket.future; + @override + Future episodeStats(String episodeId) => stats.future; + @override bool isFresh(String episodeId) => false; }