From 5e2b8d9e4db19c6f7967f7ed58528671fa272156 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 11:39:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=94=B6=E8=97=8F=E4=B8=8E?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=95=B0=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/remote/comment_api.dart | 10 +++ .../data/repositories/comment_repository.dart | 6 ++ .../drama_detail/drama_detail_logic.dart | 23 ++++- lib/pages/drama_detail/drama_detail_view.dart | 6 +- .../widgets/drama_detail_sections.dart | 13 ++- lib/pages/player/player_view.dart | 39 ++++----- test/comment/comment_api_test.dart | 27 ++++++ .../drama_detail/drama_detail_logic_test.dart | 59 ++++++++++++- test/drama_detail/drama_detail_view_test.dart | 22 +++++ test/player/player_fullscreen_test.dart | 86 ++++++++++++++++++- .../fake_interaction_repositories.dart | 13 +++ 11 files changed, 275 insertions(+), 29 deletions(-) diff --git a/lib/core/data/remote/comment_api.dart b/lib/core/data/remote/comment_api.dart index f99438e..536523d 100644 --- a/lib/core/data/remote/comment_api.dart +++ b/lib/core/data/remote/comment_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/comment.dart'; +import '../models/json_numbers.dart'; import 'api_client.dart'; /// 评论接口,对应契约 §1.7 的 `/app/comments*`。 @@ -29,6 +30,15 @@ class CommentApi { return CommentPage.fromJson(data); } + /// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。 + Future count(String dramaId) async { + final data = await _client.get( + '/app/comments/count', + query: {'dramaId': dramaId}, + ); + return jsonToInt(data); + } + /// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。 Future publish({ required String dramaId, diff --git a/lib/core/data/repositories/comment_repository.dart b/lib/core/data/repositories/comment_repository.dart index 92d93e2..744daf9 100644 --- a/lib/core/data/repositories/comment_repository.dart +++ b/lib/core/data/repositories/comment_repository.dart @@ -13,6 +13,9 @@ abstract interface class CommentRepository { int size = commentPageSize, }); + /// 查询单部剧的有效评论总数。 + Future count(String dramaId); + /// 发表评论或回复。 Future publish({ required String dramaId, @@ -44,6 +47,9 @@ class HttpCommentRepository implements CommentRepository { size: size, ); + @override + Future count(String dramaId) => _api.count(dramaId); + @override Future publish({ required String dramaId, diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 9dc74df..42c0a73 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -35,6 +35,7 @@ class DramaDetailLogic extends ChangeNotifier { int? continueIndex; WatchProgress? progress; String? progressError; + int _favoriteCountDelta = 0; bool _disposed = false; void notify() { @@ -118,8 +119,26 @@ class DramaDetailLogic extends ChangeNotifier { await loadContinue(); } - /// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。 - Future toggleFavorite() => favorites.toggle(drama.id, card: drama); + /// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。 + int? get adjustedFavoriteCount { + final base = drama.favoriteCount; + if (base == null) return null; + final adjusted = base + _favoriteCountDelta; + return adjusted < 0 ? 0 : adjusted; + } + + /// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。 + Future toggleFavorite() async { + final wasSaved = favorites.isSaved(drama.id); + final ok = await favorites.toggle(drama.id, card: drama); + if (!ok) return false; + final nowSaved = favorites.isSaved(drama.id); + if (nowSaved != wasSaved) { + _favoriteCountDelta += nowSaved ? 1 : -1; + notify(); + } + return true; + } @override void dispose() { diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index d3f0725..b995301 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -142,7 +142,11 @@ class _DramaDetailPageState extends State { ), ), SliverToBoxAdapter( - child: DramaStatsCard(l10n: l10n, drama: drama), + child: DramaStatsCard( + l10n: l10n, + drama: drama, + favoriteCount: logic.adjustedFavoriteCount, + ), ), SliverToBoxAdapter( child: DramaSynopsisSection( diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart index 1b8981f..8c5eda1 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_sections.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -29,16 +29,23 @@ const TextStyle _synopsisStyle = TextStyle( /// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。 class DramaStatsCard extends StatelessWidget { - const DramaStatsCard({super.key, required this.l10n, required this.drama}); + const DramaStatsCard({ + super.key, + required this.l10n, + required this.drama, + this.favoriteCount, + }); final AppLocalizations l10n; final Drama drama; + final int? favoriteCount; @override Widget build(BuildContext context) { - final favorite = drama.favoriteCount == null + final currentFavoriteCount = favoriteCount ?? drama.favoriteCount; + final favorite = currentFavoriteCount == null ? l10n.episodeCountLabel(drama.episodeCount) - : '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}'; + : '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}'; final like = drama.likeCount == null ? '—' : '${formatInteractionCount(drama.likeCount!)} ${l10n.like}'; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 96c5af8..91d05cb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; @@ -99,6 +100,7 @@ class _PlayerPageState extends State /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; + final Map _commentCounts = {}; @override void initState() { @@ -203,6 +205,18 @@ class _PlayerPageState extends State }); } + Future _openComments(BuildContext context, Drama drama) async { + final comments = context.read(); + await openCommentsWithAuth(context, drama, show: logic.showComments); + try { + final count = await comments.count(drama.id); + if (!mounted) return; + setState(() => _commentCounts[drama.id] = count); + } catch (_) { + // 评论面板已经关闭,计数刷新失败时保留旧值即可。 + } + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -446,7 +460,7 @@ class _PlayerPageState extends State favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - commentCount: drama.commentCount, + commentCount: _commentCounts[drama.id] ?? drama.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { @@ -465,21 +479,8 @@ class _PlayerPageState extends State } } }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onComments: () => unawaited( - openCommentsWithAuth(context, drama, show: logic.showComments), - ), + onSave: () => unawaited(_toggleSave(context, drama)), + onComments: () => unawaited(_openComments(context, drama)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { @@ -502,7 +503,7 @@ class _PlayerPageState extends State controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, - commentCount: drama.commentCount, + commentCount: _commentCounts[drama.id] ?? drama.commentCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), @@ -523,9 +524,7 @@ class _PlayerPageState extends State onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), - onComments: () => unawaited( - openCommentsWithAuth(context, drama, show: logic.showComments), - ), + onComments: () => unawaited(_openComments(context, drama)), onLike: () => unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), diff --git a/test/comment/comment_api_test.dart b/test/comment/comment_api_test.dart index a83dfce..6c25dbb 100644 --- a/test/comment/comment_api_test.dart +++ b/test/comment/comment_api_test.dart @@ -126,4 +126,31 @@ void main() { '/app/comments/2111012753262120962', ); }); + + test('should_parse_numeric_comment_count', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(17)), + ); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final count = await api.count('5'); + + expect(count, 17); + expect(adapter.requests.single.method, 'GET'); + expect(adapter.requests.single.uri.path, '/app/comments/count'); + expect(adapter.requests.single.queryParameters, {'dramaId': '5'}); + }); + + test('should_parse_string_comment_count', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope('2111012753262120962')), + ); + final api = CommentApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + expect(await api.count('5'), 2111012753262120962); + }); } diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 48b5e86..97aa4f1 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -9,12 +9,16 @@ import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; import '../support/fake_watch_history.dart'; /// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。 -DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) => +DramaDetailLogic _logic( + Drama drama, + FakeWatchHistory history, { + FavoriteController? favorites, +}) => DramaDetailLogic( drama: drama, history: history, playback: FakePlaybackRepository(), - favorites: FavoriteController(InMemoryFavoriteRepository()), + favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), ); Drama _card(String id) => Drama( @@ -98,4 +102,55 @@ void main() { expect(logic.drama.episodes, hasLength(3)); logic.dispose(); }); + + test('should_adjust_favorite_count_only_after_successful_changes', () async { + final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]); + final favorites = FavoriteController(repository); + await favorites.load(); + final logic = _logic( + _favoriteDrama, + FakeWatchHistory(), + favorites: favorites, + ); + + expect(logic.adjustedFavoriteCount, 2); + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 3); + + repository.failToggle = true; + expect(await logic.toggleFavorite(), isFalse); + expect(logic.adjustedFavoriteCount, 3); + + repository.failToggle = false; + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 2); + logic.dispose(); + }); + + test('should_decrement_favorite_count_from_saved_state', () async { + final repository = InMemoryFavoriteRepository( + saved: {'favorite-detail'}, + cards: [_favoriteDrama], + ); + final favorites = FavoriteController(repository); + await favorites.load(); + final logic = _logic( + _favoriteDrama, + FakeWatchHistory(), + favorites: favorites, + ); + + expect(await logic.toggleFavorite(), isTrue); + expect(logic.adjustedFavoriteCount, 1); + logic.dispose(); + }); } + +const _favoriteDrama = Drama( + id: 'favorite-detail', + title: 'Favorite detail', + synopsis: 'favorite count', + posterUrl: '/files/favorite-detail.jpg', + favoriteCount: 2, + detailLoaded: true, +); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index f66c0ba..490e7bd 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -140,11 +140,23 @@ void main() { expect(decoration().color, AppColors.card); expect((decoration().border! as Border).top.color, AppColors.blue); + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3200 Favorite', + ); await tester.tap(find.byKey(const Key('detail-favorite'))); await tester.pumpAndSettle(); expect(decoration().color, AppColors.blue); + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3201 Favorite', + ); expect( find.descendant( of: find.byKey(const Key('detail-favorite')), @@ -152,6 +164,16 @@ void main() { ), findsOneWidget, ); + + await tester.tap(find.byKey(const Key('detail-favorite'))); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.byKey(const Key('detail-stat-favorite-value'))) + .data, + '3200 Favorite', + ); }); testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index d47ee87..4c809b0 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -175,6 +175,68 @@ void main() { expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); }); + testWidgets('should_refresh_comment_count_after_comment_sheet_closes', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + final comments = FakeCommentRepository(countResult: 97); + await _pumpApp(tester, commentRepository: comments); + await _openSeriesPlayer(tester); + + expect( + tester + .widget(find.byKey(const Key('player-rail-comments-count'))) + .data, + '96', + ); + + await tester.tap(find.byKey(const Key('player-rail-comments'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(comments.countCalls, ['city-lights-demo']); + expect( + tester + .widget(find.byKey(const Key('player-rail-comments-count'))) + .data, + '97', + ); + }); + + testWidgets('should_update_home_feed_favorite_count_after_toggle', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + final catalog = FakeCatalogRepository(dramas: [_countingDrama]); + await _pumpApp(tester, catalog: catalog); + + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '2', + ); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const Key('rail-save-count'))).data, + '3', + ); + }); + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); @@ -1211,6 +1273,7 @@ Future _pumpApp( FakeCatalogRepository? catalog, FakePlaybackRepository? playback, FakeInteractionRepository? interactions, + FakeCommentRepository? commentRepository, PlayerFactory? playerFactory, }) async { tester.view.physicalSize = size; @@ -1226,7 +1289,7 @@ Future _pumpApp( watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), + commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), @@ -1235,6 +1298,27 @@ Future _pumpApp( await tester.pumpAndSettle(); } +const _countingDrama = Drama( + id: 'counting-demo', + title: 'Counting Demo', + synopsis: 'Counting interaction totals', + posterUrl: '/files/counting-demo.png', + genre: 'Romance', + detailLoaded: true, + favoriteCount: 2, + commentCount: 3, + likeCount: 4, + episodes: [ + Episode( + id: 'counting-demo-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/counting-1.mp4', + ), + ], +); + class _RecordingPlayer implements PlayerPort { bool playing = false; diff --git a/test/support/fake_interaction_repositories.dart b/test/support/fake_interaction_repositories.dart index 51ebf04..b96cc52 100644 --- a/test/support/fake_interaction_repositories.dart +++ b/test/support/fake_interaction_repositories.dart @@ -42,6 +42,8 @@ class FakeCommentRepository implements CommentRepository { this.publishError, this.deleteError, this.pageError, + this.countResult = 0, + this.countError, }) : pages = pages ?? const []; final List pages; @@ -49,9 +51,12 @@ class FakeCommentRepository implements CommentRepository { final Object? publishError; final Object? deleteError; final Object? pageError; + final int countResult; + final Object? countError; final List pageCalls = []; final List publishCalls = []; final List deleteCalls = []; + final List countCalls = []; int _pageIndex = 0; @override @@ -75,6 +80,14 @@ class FakeCommentRepository implements CommentRepository { return pages[_pageIndex++]; } + @override + Future count(String dramaId) async { + countCalls.add(dramaId); + final error = countError; + if (error != null) throw error; + return countResult; + } + @override Future publish({ required String dramaId,