From 57f84c45bdeea5108dca489c06ef960ddd42be74 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 29 Oct 2026 10:44:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E8=A7=A3=E9=94=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=99=BB=E5=BD=95=E6=9C=9F=E5=86=85=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E8=BF=9B=E6=92=AD=E6=94=BE=E9=A1=B5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=B8=A2=E8=A7=A3=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解锁记录原本是播放页实例的私有集合,退出再进入会先闪一次锁定态,还会 被 selectLocked 提前释放播放器。改为进程内共享的 UnlockedEpisodeRegistry, 由全局注入并在登录态变化时清空;播放器三处渲染分支统一走同一判定, 会话锁态只约束当前页,避免相邻预加载页被误判为锁定。 --- lib/app.dart | 8 +++++ .../services/unlocked_episode_registry.dart | 22 +++++++++++++ lib/pages/player/player_binding.dart | 3 ++ lib/pages/player/player_logic.dart | 28 ++++++++++++---- lib/pages/player/player_view.dart | 27 ++++++++------- test/player/player_locked_auth_flow_test.dart | 33 +++++++++++++++++++ 6 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 lib/core/data/services/unlocked_episode_registry.dart diff --git a/lib/app.dart b/lib/app.dart index 5fd2cf8..6585de2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -47,6 +47,7 @@ import 'core/data/services/playlist_controller.dart'; import 'core/data/services/event_tracker.dart'; import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_session.dart'; +import 'core/data/services/unlocked_episode_registry.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; import 'core/navigation/app_route_observer.dart'; @@ -207,6 +208,10 @@ class _LumaDramaAppState extends State { late final LocaleController _localeController = LocaleController(); + /// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。 + late final UnlockedEpisodeRegistry _unlockedEpisodes = + UnlockedEpisodeRegistry(); + @override void initState() { super.initState(); @@ -226,6 +231,8 @@ class _LumaDramaAppState extends State { if (_auth.isSignedIn == _wasSignedIn) return; _wasSignedIn = _auth.isSignedIn; _interactionController.clear(); + // 解锁结论属于账号:换人登录后不能沿用上一个人的可播判断。 + _unlockedEpisodes.clear(); if (_auth.isSignedIn) { unawaited(_favoriteController.load()); unawaited(_walletController.load()); @@ -271,6 +278,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _catalogApi), Provider.value(value: _playback), Provider.value(value: _playbackPositionCache), + Provider.value(value: _unlockedEpisodes), Provider.value(value: _watchHistory), Provider.value(value: _favorites), Provider.value(value: _commentRepository), diff --git a/lib/core/data/services/unlocked_episode_registry.dart b/lib/core/data/services/unlocked_episode_registry.dart new file mode 100644 index 0000000..a86f0d3 --- /dev/null +++ b/lib/core/data/services/unlocked_episode_registry.dart @@ -0,0 +1,22 @@ +// Author: fruit.com.cn + +/// 本次登录期内已确认可播的收费集。 +/// +/// 解锁本身是服务端行为(`POST /app/episodes/{id}/unlock` 成功即生效), +/// 客户端只需要记住「这一集不用再挡」。若不记住,退出播放页再进来会先闪 +/// 一次锁定态,且会被 [PlaybackSession.selectLocked] 提前释放播放器。 +/// +/// 生命周期跟随账号:登录态变化时由 `app.dart` 调用 [clear],避免换账号后 +/// 沿用上一个人的解锁结论。 +class UnlockedEpisodeRegistry { + final Set _episodeIds = {}; + + bool contains(String episodeId) => _episodeIds.contains(episodeId); + + void markUnlocked(String episodeId) { + if (episodeId.isEmpty) return; + _episodeIds.add(episodeId); + } + + void clear() => _episodeIds.clear(); +} diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 256e822..c5f3b27 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -5,6 +5,7 @@ import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/playback_position_cache.dart'; +import '../../core/data/services/unlocked_episode_registry.dart'; import '../../core/data/services/wallet_controller.dart'; import 'player_logic.dart'; @@ -13,6 +14,7 @@ class PlayerBinding { required WatchHistoryRepository history, required PlaybackRepository playback, required PlaybackPositionCache positionCache, + UnlockedEpisodeRegistry? unlockedEpisodes, WalletController? wallet, DownloadController? downloads, bool Function()? isSignedIn, @@ -20,6 +22,7 @@ class PlayerBinding { history: history, playback: playback, positionCache: positionCache, + unlockedEpisodes: unlockedEpisodes, pictureInPicture: AndroidPictureInPicture(), wallet: wallet, downloads: downloads, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index c20607a..027a00c 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -27,6 +27,7 @@ import '../../core/data/services/platform_share_service.dart'; import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/unlocked_episode_registry.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/localized_content.dart'; @@ -64,11 +65,13 @@ class PlayerLogic extends ChangeNotifier { required this.pictureInPicture, this.wallet, this.downloads, + UnlockedEpisodeRegistry? unlockedEpisodes, PlaybackPositionCache? positionCache, Future Function()? waitForFrame, DateTime Function()? clock, bool Function()? isSignedIn, - }) : positionCache = positionCache ?? PlaybackPositionCache(), + }) : unlockedEpisodes = unlockedEpisodes ?? UnlockedEpisodeRegistry(), + positionCache = positionCache ?? PlaybackPositionCache(), _waitForFrame = waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), _clock = clock ?? DateTime.now, @@ -79,6 +82,9 @@ class PlayerLogic extends ChangeNotifier { final AndroidPictureInPicture pictureInPicture; final WalletController? wallet; final DownloadController? downloads; + + /// 已确认可播的收费集(登录期内共享):退出播放页再进来不必重新挡一次。 + final UnlockedEpisodeRegistry unlockedEpisodes; final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; final DateTime Function() _clock; @@ -169,14 +175,15 @@ class PlayerLogic extends ChangeNotifier { /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 bool get iosDevice => !kIsWeb && Platform.isIOS; + /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: /// 推荐页每页一个 logic,若只保留最后一次注册的 handler, /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 static final Set _pipSubscribers = {}; + /// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入 /// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。 static final Map pickedEpisode = {}; - final Set _unlockedEpisodeIds = {}; final Map _episodeStats = {}; final Map _episodeStatsFetchedAt = {}; final Map> _episodeStatsInFlight = {}; @@ -562,7 +569,7 @@ class PlayerLogic extends ChangeNotifier { final localPath = await downloads?.findLocalPath(episode.id); if (request != pageRequest) return false; if (localPath != null) { - _unlockedEpisodeIds.add(episode.id); + unlockedEpisodes.markUnlocked(episode.id); blockedTicket = null; blockedEpisodeId = null; qualities = const []; @@ -587,7 +594,7 @@ class PlayerLogic extends ChangeNotifier { final playUrl = preferred ?? ticket.playUrl; if (preferred == null) selectedQuality = 'AUTO'; if (ticket.accessible && playUrl.isNotEmpty) { - _unlockedEpisodeIds.add(episode.id); + unlockedEpisodes.markUnlocked(episode.id); // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl( playUrl, @@ -860,7 +867,7 @@ class PlayerLogic extends ChangeNotifier { unlockError = service.unlockError; return false; } - _unlockedEpisodeIds.add(episode.id); + unlockedEpisodes.markUnlocked(episode.id); final ready = await ensureTicket(index, forceRefresh: true); if (ready) { await _selectVisible(index); @@ -1688,7 +1695,16 @@ class PlayerLogic extends ChangeNotifier { /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 bool isEpisodeLocked(Episode episode) { return (episode.locked || !episode.isFree) && - !_unlockedEpisodeIds.contains(episode.id); + !unlockedEpisodes.contains(episode.id); + } + + /// 单页锁定判定:剧集自身锁定 + 会话锁态(只约束当前页)+ 后端拒绝。 + /// + /// 播放页的多个渲染分支都必须走这里,否则相邻预加载页会被会话锁态误判成锁定。 + bool isEpisodeBlockedFor(Episode episode, int index) { + return isEpisodeLocked(episode) || + (index == session.currentIndex && session.isLocked) || + blockedEpisodeId == episode.id; } /// 按可见队列选择一集,并预热上一集与下一集。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 864221a..8267568 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -20,6 +20,7 @@ import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; +import '../../core/data/services/unlocked_episode_registry.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; @@ -167,6 +168,7 @@ class _PlayerPageState extends State history: context.read(), playback: context.read(), positionCache: context.read(), + unlockedEpisodes: _readUnlockedEpisodes(context), wallet: widget.wallet, downloads: widget.downloads, isSignedIn: () => context.read().isSignedIn, @@ -349,6 +351,16 @@ class _PlayerPageState extends State setState(() => _playbackSpeed = value); } + /// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider, + /// 返回 null 让 PlayerLogic 退化成页面自用的一份。 + UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) { + try { + return context.read(); + } on ProviderNotFoundException { + return null; + } + } + void _hideChrome() { if (!_chromeVisible) return; setState(() => _chromeVisible = false); @@ -956,10 +968,7 @@ class _PlayerPageState extends State } final episode = _resolvedEpisode(index); final selected = index == currentIndex; - final locked = - logic.isEpisodeLocked(episode) || - (selected && logic.session.isLocked) || - logic.blockedEpisodeId == episode.id; + final locked = logic.isEpisodeBlockedFor(episode, index); final port = locked ? null : (selected ? logic.session.active : null) ?? @@ -1042,10 +1051,7 @@ class _PlayerPageState extends State final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; - final locked = - logic.isEpisodeLocked(episode) || - logic.session.isLocked || - logic.blockedEpisodeId == episode.id; + final locked = logic.isEpisodeBlockedFor(episode, index); // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. final port = locked @@ -1294,10 +1300,7 @@ class _PlayerPageState extends State final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; - final locked = - logic.isEpisodeLocked(episode) || - logic.session.isLocked || - logic.blockedEpisodeId == episode.id; + final locked = logic.isEpisodeBlockedFor(episode, index); final port = locked ? null : (selected ? logic.session.active : null) ?? diff --git a/test/player/player_locked_auth_flow_test.dart b/test/player/player_locked_auth_flow_test.dart index c3e99ac..f216577 100644 --- a/test/player/player_locked_auth_flow_test.dart +++ b/test/player/player_locked_auth_flow_test.dart @@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/repositories/commerce_repository.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/core/data/services/unlocked_episode_registry.dart'; import 'package:fruitshorts/core/data/services/wallet_controller.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; @@ -120,6 +121,36 @@ void main() { logic.dispose(); await _drain(logic); }); + + test('should_keep_episode_unlocked_when_reentering_player_page', () async { + final registry = UnlockedEpisodeRegistry(); + registry.markUnlocked('locked-1'); + final playback = FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: true, + accessType: 'ENTITLED', + playUrl: '/files/locked-1.mp4', + ), + ); + // 重进播放页 = 新建 PlayerLogic,但沿用同一张解锁登记表。 + final logic = _logic(playback, unlockedEpisodes: registry); + logic.attach( + const PlayerPage(drama: _lockedDrama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: (_) => _NoopPlayer(), + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + expect(logic.isEpisodeLocked(logic.sessionEpisodes.single), isFalse); + expect(logic.session.active, isNotNull); + + logic.dispose(); + await _drain(logic); + }); } PlayerLogic _logic( @@ -127,11 +158,13 @@ PlayerLogic _logic( bool signedIn = true, bool Function()? isSignedIn, WalletController? wallet, + UnlockedEpisodeRegistry? unlockedEpisodes, }) { return PlayerLogic( history: FakeWatchHistory(), playback: playback, wallet: wallet, + unlockedEpisodes: unlockedEpisodes, pictureInPicture: AndroidPictureInPicture( channel: const MethodChannel('test/player_locked_auth_flow/pip'), ),