fix(player): 解锁状态登录期内共享,重进播放页不再丢解锁

解锁记录原本是播放页实例的私有集合,退出再进入会先闪一次锁定态,还会
被 selectLocked 提前释放播放器。改为进程内共享的 UnlockedEpisodeRegistry,
由全局注入并在登录态变化时清空;播放器三处渲染分支统一走同一判定,
会话锁态只约束当前页,避免相邻预加载页被误判为锁定。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:44 +08:00
1 parent a4d08e7c1e
commit 57f84c45bd
6 files changed
+103 -18

No files matched your search

+8
View File
@@ -47,6 +47,7 @@ import 'core/data/services/playlist_controller.dart';
import 'core/data/services/event_tracker.dart'; import 'core/data/services/event_tracker.dart';
import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_position_cache.dart';
import 'core/data/services/playback_session.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/video_player_port.dart';
import 'core/data/services/wallet_controller.dart'; import 'core/data/services/wallet_controller.dart';
import 'core/navigation/app_route_observer.dart'; import 'core/navigation/app_route_observer.dart';
@@ -207,6 +208,10 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
late final LocaleController _localeController = LocaleController(); late final LocaleController _localeController = LocaleController();
/// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。
late final UnlockedEpisodeRegistry _unlockedEpisodes =
UnlockedEpisodeRegistry();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -226,6 +231,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
if (_auth.isSignedIn == _wasSignedIn) return; if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn; _wasSignedIn = _auth.isSignedIn;
_interactionController.clear(); _interactionController.clear();
// 解锁结论属于账号:换人登录后不能沿用上一个人的可播判断。
_unlockedEpisodes.clear();
if (_auth.isSignedIn) { if (_auth.isSignedIn) {
unawaited(_favoriteController.load()); unawaited(_favoriteController.load());
unawaited(_walletController.load()); unawaited(_walletController.load());
@@ -271,6 +278,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<CatalogApi>.value(value: _catalogApi), Provider<CatalogApi>.value(value: _catalogApi),
Provider<PlaybackRepository>.value(value: _playback), Provider<PlaybackRepository>.value(value: _playback),
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache), Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
Provider<UnlockedEpisodeRegistry>.value(value: _unlockedEpisodes),
Provider<WatchHistoryRepository>.value(value: _watchHistory), Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites), Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository), Provider<CommentRepository>.value(value: _commentRepository),
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
/// 本次登录期内已确认可播的收费集。
///
/// 解锁本身是服务端行为(`POST /app/episodes/{id}/unlock` 成功即生效),
/// 客户端只需要记住「这一集不用再挡」。若不记住,退出播放页再进来会先闪
/// 一次锁定态,且会被 [PlaybackSession.selectLocked] 提前释放播放器。
///
/// 生命周期跟随账号:登录态变化时由 `app.dart` 调用 [clear],避免换账号后
/// 沿用上一个人的解锁结论。
class UnlockedEpisodeRegistry {
final Set<String> _episodeIds = <String>{};
bool contains(String episodeId) => _episodeIds.contains(episodeId);
void markUnlocked(String episodeId) {
if (episodeId.isEmpty) return;
_episodeIds.add(episodeId);
}
void clear() => _episodeIds.clear();
}
+3
View File
@@ -5,6 +5,7 @@ import '../../core/data/repositories/playback_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/download_controller.dart'; import '../../core/data/services/download_controller.dart';
import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/unlocked_episode_registry.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import 'player_logic.dart'; import 'player_logic.dart';
@@ -13,6 +14,7 @@ class PlayerBinding {
required WatchHistoryRepository history, required WatchHistoryRepository history,
required PlaybackRepository playback, required PlaybackRepository playback,
required PlaybackPositionCache positionCache, required PlaybackPositionCache positionCache,
UnlockedEpisodeRegistry? unlockedEpisodes,
WalletController? wallet, WalletController? wallet,
DownloadController? downloads, DownloadController? downloads,
bool Function()? isSignedIn, bool Function()? isSignedIn,
@@ -20,6 +22,7 @@ class PlayerBinding {
history: history, history: history,
playback: playback, playback: playback,
positionCache: positionCache, positionCache: positionCache,
unlockedEpisodes: unlockedEpisodes,
pictureInPicture: AndroidPictureInPicture(), pictureInPicture: AndroidPictureInPicture(),
wallet: wallet, wallet: wallet,
downloads: downloads, downloads: downloads,
+22 -6
View File
@@ -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_position_cache.dart';
import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart'; import '../../core/data/services/playback_session.dart';
import '../../core/data/services/unlocked_episode_registry.dart';
import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
@@ -64,11 +65,13 @@ class PlayerLogic extends ChangeNotifier {
required this.pictureInPicture, required this.pictureInPicture,
this.wallet, this.wallet,
this.downloads, this.downloads,
UnlockedEpisodeRegistry? unlockedEpisodes,
PlaybackPositionCache? positionCache, PlaybackPositionCache? positionCache,
Future<void> Function()? waitForFrame, Future<void> Function()? waitForFrame,
DateTime Function()? clock, DateTime Function()? clock,
bool Function()? isSignedIn, bool Function()? isSignedIn,
}) : positionCache = positionCache ?? PlaybackPositionCache(), }) : unlockedEpisodes = unlockedEpisodes ?? UnlockedEpisodeRegistry(),
positionCache = positionCache ?? PlaybackPositionCache(),
_waitForFrame = _waitForFrame =
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
_clock = clock ?? DateTime.now, _clock = clock ?? DateTime.now,
@@ -79,6 +82,9 @@ class PlayerLogic extends ChangeNotifier {
final AndroidPictureInPicture pictureInPicture; final AndroidPictureInPicture pictureInPicture;
final WalletController? wallet; final WalletController? wallet;
final DownloadController? downloads; final DownloadController? downloads;
/// 已确认可播的收费集(登录期内共享):退出播放页再进来不必重新挡一次。
final UnlockedEpisodeRegistry unlockedEpisodes;
final PlaybackPositionCache positionCache; final PlaybackPositionCache positionCache;
final Future<void> Function() _waitForFrame; final Future<void> Function() _waitForFrame;
final DateTime Function() _clock; final DateTime Function() _clock;
@@ -169,14 +175,15 @@ class PlayerLogic extends ChangeNotifier {
/// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。
bool get iosDevice => !kIsWeb && Platform.isIOS; bool get iosDevice => !kIsWeb && Platform.isIOS;
/// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic:
/// 推荐页每页一个 logic,若只保留最后一次注册的 handler, /// 推荐页每页一个 logic,若只保留最后一次注册的 handler,
/// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。
static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{}; static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入 /// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。 /// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
static final Map<String, int> pickedEpisode = {}; static final Map<String, int> pickedEpisode = {};
final Set<String> _unlockedEpisodeIds = {};
final Map<String, EpisodeStats> _episodeStats = {}; final Map<String, EpisodeStats> _episodeStats = {};
final Map<String, DateTime> _episodeStatsFetchedAt = {}; final Map<String, DateTime> _episodeStatsFetchedAt = {};
final Map<String, Future<void>> _episodeStatsInFlight = {}; final Map<String, Future<void>> _episodeStatsInFlight = {};
@@ -562,7 +569,7 @@ class PlayerLogic extends ChangeNotifier {
final localPath = await downloads?.findLocalPath(episode.id); final localPath = await downloads?.findLocalPath(episode.id);
if (request != pageRequest) return false; if (request != pageRequest) return false;
if (localPath != null) { if (localPath != null) {
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
blockedTicket = null; blockedTicket = null;
blockedEpisodeId = null; blockedEpisodeId = null;
qualities = const []; qualities = const [];
@@ -587,7 +594,7 @@ class PlayerLogic extends ChangeNotifier {
final playUrl = preferred ?? ticket.playUrl; final playUrl = preferred ?? ticket.playUrl;
if (preferred == null) selectedQuality = 'AUTO'; if (preferred == null) selectedQuality = 'AUTO';
if (ticket.accessible && playUrl.isNotEmpty) { if (ticket.accessible && playUrl.isNotEmpty) {
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl( sessionEpisodes[index] = episode.withVideoUrl(
playUrl, playUrl,
@@ -860,7 +867,7 @@ class PlayerLogic extends ChangeNotifier {
unlockError = service.unlockError; unlockError = service.unlockError;
return false; return false;
} }
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
final ready = await ensureTicket(index, forceRefresh: true); final ready = await ensureTicket(index, forceRefresh: true);
if (ready) { if (ready) {
await _selectVisible(index); await _selectVisible(index);
@@ -1688,7 +1695,16 @@ class PlayerLogic extends ChangeNotifier {
/// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。
bool isEpisodeLocked(Episode episode) { bool isEpisodeLocked(Episode episode) {
return (episode.locked || !episode.isFree) && 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;
} }
/// 按可见队列选择一集,并预热上一集与下一集。 /// 按可见队列选择一集,并预热上一集与下一集。
+15 -12
View File
@@ -20,6 +20,7 @@ import '../../core/data/services/playback_position_cache.dart';
import '../../core/data/services/playback_session.dart'; import '../../core/data/services/playback_session.dart';
import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart';
import '../../core/data/services/playback_thumbnail_service.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/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
@@ -167,6 +168,7 @@ class _PlayerPageState extends State<PlayerPage>
history: context.read<WatchHistoryRepository>(), history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(), playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(), positionCache: context.read<PlaybackPositionCache>(),
unlockedEpisodes: _readUnlockedEpisodes(context),
wallet: widget.wallet, wallet: widget.wallet,
downloads: widget.downloads, downloads: widget.downloads,
isSignedIn: () => context.read<AuthController>().isSignedIn, isSignedIn: () => context.read<AuthController>().isSignedIn,
@@ -349,6 +351,16 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _playbackSpeed = value); setState(() => _playbackSpeed = value);
} }
/// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider,
/// 返回 null 让 PlayerLogic 退化成页面自用的一份。
UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) {
try {
return context.read<UnlockedEpisodeRegistry>();
} on ProviderNotFoundException {
return null;
}
}
void _hideChrome() { void _hideChrome() {
if (!_chromeVisible) return; if (!_chromeVisible) return;
setState(() => _chromeVisible = false); setState(() => _chromeVisible = false);
@@ -956,10 +968,7 @@ class _PlayerPageState extends State<PlayerPage>
} }
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == currentIndex; final selected = index == currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
(selected && logic.session.isLocked) ||
logic.blockedEpisodeId == episode.id;
final port = locked final port = locked
? null ? null
: (selected ? logic.session.active : null) ?? : (selected ? logic.session.active : null) ??
@@ -1042,10 +1051,7 @@ class _PlayerPageState extends State<PlayerPage>
final drama = entry.drama; final drama = entry.drama;
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
// Selection changes before the prepared slot is promoted. Use its controller // Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder. // throughout the handoff instead of briefly falling back to a placeholder.
final port = locked final port = locked
@@ -1294,10 +1300,7 @@ class _PlayerPageState extends State<PlayerPage>
final drama = entry.drama; final drama = entry.drama;
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
final port = locked final port = locked
? null ? null
: (selected ? logic.session.active : null) ?? : (selected ? logic.session.active : null) ??
@@ -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/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/playback_session.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/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
@@ -120,6 +121,36 @@ void main() {
logic.dispose(); logic.dispose();
await _drain(logic); 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( PlayerLogic _logic(
@@ -127,11 +158,13 @@ PlayerLogic _logic(
bool signedIn = true, bool signedIn = true,
bool Function()? isSignedIn, bool Function()? isSignedIn,
WalletController? wallet, WalletController? wallet,
UnlockedEpisodeRegistry? unlockedEpisodes,
}) { }) {
return PlayerLogic( return PlayerLogic(
history: FakeWatchHistory(), history: FakeWatchHistory(),
playback: playback, playback: playback,
wallet: wallet, wallet: wallet,
unlockedEpisodes: unlockedEpisodes,
pictureInPicture: AndroidPictureInPicture( pictureInPicture: AndroidPictureInPicture(
channel: const MethodChannel('test/player_locked_auth_flow/pip'), channel: const MethodChannel('test/player_locked_auth_flow/pip'),
), ),