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/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<LumaDramaApp> {
late final LocaleController _localeController = LocaleController();
/// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。
late final UnlockedEpisodeRegistry _unlockedEpisodes =
UnlockedEpisodeRegistry();
@override
void initState() {
super.initState();
@@ -226,6 +231,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
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<LumaDramaApp> {
Provider<CatalogApi>.value(value: _catalogApi),
Provider<PlaybackRepository>.value(value: _playback),
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
Provider<UnlockedEpisodeRegistry>.value(value: _unlockedEpisodes),
Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites),
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/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,
+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_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<void> 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<void> 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<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
static final Map<String, int> pickedEpisode = {};
final Set<String> _unlockedEpisodeIds = {};
final Map<String, EpisodeStats> _episodeStats = {};
final Map<String, DateTime> _episodeStatsFetchedAt = {};
final Map<String, Future<void>> _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;
}
/// 按可见队列选择一集,并预热上一集与下一集。
+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/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<PlayerPage>
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
unlockedEpisodes: _readUnlockedEpisodes(context),
wallet: widget.wallet,
downloads: widget.downloads,
isSignedIn: () => context.read<AuthController>().isSignedIn,
@@ -349,6 +351,16 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _playbackSpeed = value);
}
/// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider,
/// 返回 null 让 PlayerLogic 退化成页面自用的一份。
UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) {
try {
return context.read<UnlockedEpisodeRegistry>();
} on ProviderNotFoundException {
return null;
}
}
void _hideChrome() {
if (!_chromeVisible) return;
setState(() => _chromeVisible = false);
@@ -956,10 +968,7 @@ class _PlayerPageState extends State<PlayerPage>
}
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<PlayerPage>
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<PlayerPage>
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) ??