// Author: fruit.com.cn import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:floating/floating.dart'; import 'package:permission_handler/permission_handler.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'; import '../../core/data/remote/api_client.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; 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'; import '../../core/theme/app_colors.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../l10n/app_localizations.dart'; import '../discover/search_page.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/drama_info_sheet.dart'; typedef _PlaybackSnapshot = ({ Duration position, Duration duration, bool completed, }); class PlayerLogic extends ChangeNotifier { /// Android PiP 管理器(floating 插件);负责退后台自动进小窗。 static final Floating _floating = Floating(); /// 挂了自动小窗的播放器实例(引用计数):沉浸流与独立播放页可能 /// 同时在树上,只有全部不需要时才真正注销 OnLeavePiP。 static final Set _pipOwners = {}; /// OnLeavePiP 当前在原生侧的挂载状态(进程级,与 [_pipOwners] 对应)。 static bool _pipNativeArmed = false; PlayerLogic({ required this.history, required this.playback, required this.pictureInPicture, this.wallet, this.downloads, UnlockedEpisodeRegistry? unlockedEpisodes, PlaybackPositionCache? positionCache, Future Function()? waitForFrame, DateTime Function()? clock, bool Function()? isSignedIn, }) : unlockedEpisodes = unlockedEpisodes ?? UnlockedEpisodeRegistry(), positionCache = positionCache ?? PlaybackPositionCache(), _waitForFrame = waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), _clock = clock ?? DateTime.now, _isSignedIn = isSignedIn ?? (() => true); final WatchHistoryRepository history; final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; final WalletController? wallet; final DownloadController? downloads; /// 已确认可播的收费集(登录期内共享):退出播放页再进来不必重新挡一次。 final UnlockedEpisodeRegistry unlockedEpisodes; final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; final DateTime Function() _clock; final bool Function() _isSignedIn; late PageController pages; late PlaybackQueue queue; late List visibleIndices; late PlaybackSession session; VideoPlayerPort? trackedPlayer; Episode? trackedEpisode; Drama? trackedDrama; int? _trackedPlayerIndex; bool _completionHandled = false; bool _completionProgressSaved = false; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); /// 进度上报节流:契约建议每 10 秒一次 Duration progressInterval = const Duration(seconds: 10); Future pendingWrite = Future.value(); /// 服务端续看点:进入播放页时定位到"那一集的哪一秒" ResumePoint? resumePoint; bool resumeLoaded = false; /// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。 Future? _resumePointLoad; /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 bool ticketLoading = false; String? ticketError; PlayTicket? blockedTicket; String? blockedEpisodeId; List sessionEpisodes = const []; List qualities = const []; List qualityTracks = const []; String selectedQuality = 'AUTO'; Duration? _qualitySeek; int pageRequest = 0; int? _pendingSelectionIndex; int? _pendingSelectionRevision; Future? _selectionDrain; 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; bool _wasPlayingBeforeIosPip = false; Timer? _pipStartTimer; /// iOS 自动小窗开关(对齐红果:开启后退 App 自动进入画中画)。 static const _iosAutoPipPrefKey = 'player.ios.autoEnterPip'; bool iosAutoPipEnabled = false; /// 播放页前台活跃标志:沉浸流切走 tab / 独立页关闭后置 false, /// 此时退后台不再自动拉起画中画(影子只同步不拉起)。 bool pipPageActive = false; /// 由播放视图同步活跃标志;离开播放页时撤销影子的自动小窗资格, /// 重新进入时重建影子待命(切走期间视图可能被卸载、逻辑实例已换新)。 void setPipPageActive(bool active) { debugPrint('pip: page-active -> $active'); if (pipPageActive == active) return; pipPageActive = active; if (!active && iosDevice) { unawaited(pictureInPicture.disarmAutoEnter()); return; } if (active && iosDevice && iosAutoPipEnabled && !pip) { final episode = trackedEpisode; if (episode != null) { debugPrint('pip: page re-entered, ensure shadow standby'); unawaited(_ensureIosShadowStandby(episode)); } } } /// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。 int pipResumeToken = 0; /// 画中画还原过渡期:为 true 时播放区盖当前集封面,等纹理出帧后淡出, /// 盖住回前台瞬间的黑纹理帧。700ms 后自动复位。 bool pipRecovering = false; Timer? _pipRecoverTimer; /// iOS 影子播放器当前承载的集 id(全局单例状态);用于避免重复换源。 static String? iosShadowEpisodeId; /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 bool get iosDevice => !kIsWeb && Platform.isIOS; /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: /// 推荐页每页一个 logic,若只保留最后一次注册的 handler, /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 static final Set _pipSubscribers = {}; /// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入 /// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。 static final Map pickedEpisode = {}; final Map _episodeStats = {}; final Map _episodeStatsFetchedAt = {}; final Map> _episodeStatsInFlight = {}; final Map _episodeStatsRevision = {}; final Map _dramaStatsRevision = {}; /// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。 String? unlockingEpisodeId; String? unlockError; bool Function() isMounted = () => false; bool homeMode = false; bool embedded = false; bool active = true; int resumeRequest = 0; int backRequest = 0; int initialIndex = 0; late Drama pageDrama; List? feedDramas; VoidCallback? onDiscover; VoidCallback? onMy; VoidCallback? onSearch; VoidCallback? onMenu; ValueChanged? onDramaChanged; ValueChanged? onImmersiveChanged; bool _disposed = false; PlayerFactory? _sessionFactory; PlaybackCoordinator? _sessionCoordinator; int feedRevision = 0; List _latestFeed = const []; String? _latestKeepDramaId; bool _latestResetToFirst = false; bool _feedUpdateRunning = false; bool _hasPendingFeed = false; bool _deferredFeedNotifyScheduled = false; int _feedJumpSerial = 0; Future _feedUpdateFuture = Future.value(); Future? _pendingSeek; /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 bool _started = false; bool get immersive => pip || pipPreparing; bool get chromeHidden => immersive; /// 最近一次 feed 更新流程完成;测试和页面退出可等待它结束。 Future get feedUpdateSettled => _feedUpdateFuture; /// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。 String? get currentDramaId { if (!_started || visibleIndices.isEmpty) return null; final index = session.currentIndex; if (index < 0 || index >= queue.length) return null; return queue.entryAt(index).drama.id; } Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; if (number == null) return fallback; if (fallback.number == number) return fallback; for (final episode in drama.episodes) { if (episode.number == number) return episode; } return fallback; } EpisodeStats? episodeStats(String episodeId) => _episodeStats[episodeId]; void notify() { if (!_disposed) notifyListeners(); } void attach(PlayerPage page, {required bool Function() isMounted}) { this.isMounted = isMounted; homeMode = page.homeMode; embedded = page.embedded; active = page.active; resumeRequest = page.resumeRequest; backRequest = page.backRequest; initialIndex = page.initialIndex; pageDrama = page.drama; feedDramas = page.feedDramas; onDiscover = page.onDiscover; onMy = page.onMy; onSearch = page.onSearch; onMenu = page.onMenu; onDramaChanged = page.onDramaChanged; onImmersiveChanged = page.onImmersiveChanged; } void start({ required PlayerFactory factory, required PlaybackCoordinator coordinator, bool initialUserWantsPlay = true, }) { _started = true; _sessionFactory = factory; _sessionCoordinator = coordinator; _pipSubscribers.add(this); pictureInPicture.setStateListener(_dispatchPipChanged); pictureInPicture.setPlaybackEndedListener(_dispatchPipPlaybackEnded); unawaited(_loadIosAutoPipPref()); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); visibleIndices = List.generate(queue.length, (index) => index); pages = PageController(initialPage: initialIndex); // 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它) sessionEpisodes = [for (final entry in queue.entries) entry.episode]; session = _createSession(factory, coordinator); if (!active || visibleIndices.isEmpty) { session.setRouteVisible(false); } unawaited(loadResumePoint()); unawaited( selectEpisode( initialIndex, userWantsPlay: initialUserWantsPlay ? null : false, ), ); } /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 Future pauseForFullPlayer() async { if (!_started) return; _logFeedHandoff('pause'); cancelScrub(); await session.pauseForRouteHandoff(); _logFeedHandoff('paused'); } /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 Future resumeAfterFullPlayer() async { if (!_started) return; _logFeedHandoff('resume'); if (visibleIndices.isNotEmpty) { await session.resumeForRouteHandoff(); } else { session.setRouteVisible(false); await session.playbackSettled; } _logFeedHandoff('resumed'); } void _logFeedHandoff(String phase) { if (!kDebugMode) return; final player = session.active; final video = player is VideoPlayerPort ? player : null; debugPrint( '[feed-session] phase=$phase session=${identityHashCode(session)} ' 'index=${session.currentIndex} episode=${session.currentEpisode.id} ' 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' 'user=${session.userWantsPlay} routeVisible=${session.debugRouteVisible} ' 'appResumed=${session.debugAppResumed} overlayOpen=${session.debugOverlayOpen} ' 'live=${session.livePlayerCount}', ); } /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 void updateFeed( List dramas, { String? keepDramaId, bool resetToFirst = false, }) { feedRevision++; pageRequest++; _latestFeed = List.unmodifiable(dramas); _latestKeepDramaId = keepDramaId; _latestResetToFirst = resetToFirst; _hasPendingFeed = true; if (!_started) { feedDramas = _latestFeed; return; } if (!_feedUpdateRunning) { _feedUpdateFuture = _drainFeedUpdates(); unawaited(_feedUpdateFuture); } } PlaybackSession _createSession( PlayerFactory factory, PlaybackCoordinator coordinator, ) { return PlaybackSession( episodes: sessionEpisodes, factory: factory, // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 canPlay: (episode) => episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode), prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, owner: homeMode ? PlaybackSessionOwner.home : PlaybackSessionOwner.player, dramaIdAt: (index) => index >= 0 && index < queue.length ? queue.entryAt(index).drama.id : null, ); } Future _drainFeedUpdates() async { _feedUpdateRunning = true; while (true) { if (!_hasPendingFeed) { _feedUpdateRunning = false; return; } _hasPendingFeed = false; final revision = feedRevision; final dramas = _latestFeed; final keepDramaId = _latestKeepDramaId; final resetToFirst = _latestResetToFirst; if (revision != feedRevision) continue; await _applyFeedUpdate( dramas, keepDramaId: keepDramaId, resetToFirst: resetToFirst, revision: revision, ); } } Future _applyFeedUpdate( List dramas, { required String? keepDramaId, required bool resetToFirst, required int revision, }) async { if (!_started || revision != feedRevision) return; final currentId = currentDramaId; final nextQueue = PlaybackQueue.home(dramas); final nextVisible = List.generate(nextQueue.length, (index) => index); final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode]; feedDramas = dramas; queue = nextQueue; visibleIndices = nextVisible; if (dramas.isNotEmpty) pageDrama = dramas.first; if (nextQueue.length == 0) { session.setRouteVisible(false); _notifyAfterFrameIfNeeded(); return; } final keepIndex = !resetToFirst && keepDramaId != null && keepDramaId == currentId ? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId) : -1; if (keepIndex >= 0 && session.active != null) { sessionEpisodes ..clear() ..addAll(nextEpisodes); session.remapCurrentIndex( keepIndex, preloadIndex: nextVisibleIndex(keepIndex), previousIndex: previousVisibleIndex(keepIndex), ); _jumpToFeedPage(keepIndex); _notifyAfterFrameIfNeeded(); return; } await _replaceSession( nextEpisodes, keepIndex >= 0 ? keepIndex : 0, revision: revision, ); } Future _replaceSession( List nextEpisodes, int targetIndex, { required int revision, }) async { if (revision != feedRevision) return; final factory = _sessionFactory; final coordinator = _sessionCoordinator; if (factory == null || coordinator == null) return; unawaited(saveCurrentProgress()); final previous = session; sessionEpisodes = List.of(nextEpisodes); _detachTrackedPlayerListener(); trackedPlayer = null; trackedEpisode = null; trackedDrama = null; final nextSession = _createSession(factory, coordinator); session = nextSession; nextSession.setRouteVisible(active && visibleIndices.isNotEmpty); _notifyAfterFrameIfNeeded(); await _waitForFrame(); if (revision != feedRevision || !identical(session, nextSession)) return; await previous.close(); if (revision != feedRevision || !identical(session, nextSession)) return; _jumpToFeedPage(targetIndex); await selectEpisode(targetIndex, revision: revision); } void _jumpToFeedPage(int index) { if (!_started) return; final serial = ++_feedJumpSerial; if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle && pages.hasClients) { pages.jumpToPage(index); return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (_disposed || serial != _feedJumpSerial || !_started) return; if (pages.hasClients) pages.jumpToPage(index); }); SchedulerBinding.instance.scheduleFrame(); } void _notifyAfterFrameIfNeeded() { if (_disposed) return; if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { notify(); return; } if (_deferredFeedNotifyScheduled) return; _deferredFeedNotifyScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _deferredFeedNotifyScheduled = false; if (!_disposed) notify(); }); SchedulerBinding.instance.scheduleFrame(); } /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 Future loadResumePoint() { final inFlight = _resumePointLoad; if (inFlight != null) return inFlight; return _resumePointLoad = _loadResumePoint().whenComplete(() { _resumePointLoad = null; }); } Future _loadResumePoint() async { final entry = queue.entryAt(initialIndex); final cached = positionCache.read( entry.episode.id, dramaId: entry.drama.id, ); if (cached != null) { resumePoint = ResumePoint( episodeId: cached.episodeId, position: cached.position, ); resumeLoaded = true; if (isMounted()) notify(); return; } try { resumePoint = await history.resumePointFor(entry.drama.id); } catch (_) { // 拿不到续看位置不影响播放:从当前集开头看 resumePoint = null; } finally { resumeLoaded = true; if (isMounted()) notify(); } } /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), /// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。 Duration? resumePositionFor(String episodeId) { final point = resumePoint; if (point == null || point.episodeId != episodeId) return null; if (point.position <= Duration.zero) return null; return point.position; } /// 进度上报节流:距上次上报不足 [progressInterval] 就不报。 /// /// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。 bool shouldReportProgress(DateTime now) { if (now.difference(lastProgressWrite) < progressInterval) return false; lastProgressWrite = now; return true; } /// 票据门:起播前先拿票(命中未过期缓存就不会重复请求), /// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。 Future ensureTicket(int index, {bool forceRefresh = false}) async { if (index < 0 || index >= sessionEpisodes.length) return false; final episode = sessionEpisodes[index]; final request = pageRequest; ticketLoading = true; ticketError = null; notify(); try { final localPath = await downloads?.findLocalPath(episode.id); if (request != pageRequest) return false; if (localPath != null) { unlockedEpisodes.markUnlocked(episode.id); blockedTicket = null; blockedEpisodeId = null; qualities = const []; qualityTracks = const []; sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath)); return true; } final ticket = await playback.ticketFor( episode.id, forceRefresh: forceRefresh, ); if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; qualities = ticket.qualities; qualityTracks = ticket.tracks; final preferred = qualityPlayUrl( quality: selectedQuality, tracks: qualityTracks, currentUrl: ticket.playUrl, ); final playUrl = preferred ?? ticket.playUrl; if (preferred == null) selectedQuality = 'AUTO'; if (ticket.accessible && playUrl.isNotEmpty) { unlockedEpisodes.markUnlocked(episode.id); // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) sessionEpisodes[index] = episode.withVideoUrl( playUrl, likeCount: ticket.likeCount, commentCount: ticket.commentCount, thumbnailsUrl: ticket.thumbnailsUrl, posterUrl: ticket.posterUrl, ); return true; } blockedTicket = ticket; blockedEpisodeId = episode.id; return false; } catch (exception) { if (request != pageRequest) return false; // 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了 if (episode.videoUrl.isNotEmpty && !isEpisodeLocked(episode)) { ticketError = null; return true; } ticketError = exception is ApiException ? exception.message : 'ticketError'; return false; } finally { ticketLoading = false; if (isMounted()) notify(); } } /// 当前集激活后刷新轻量互动计数;同集命中 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(); 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(); } /// 收藏成功后让旧 stats 请求失效,计数本身只写 FavoriteController。 void markDramaFavoriteChanged(String dramaId) { _dramaStatsRevision.update( dramaId, (value) => value + 1, ifAbsent: () => 1, ); } String _fileUrl(String path) => path.startsWith('file:') ? path : Uri.file(path).toString(); /// 切到指定清晰度。新片源出画后再替换,失败则继续播原来的。 Future selectQuality(String quality) async { final index = session.currentIndex; if (index < 0 || index >= sessionEpisodes.length) return false; final current = sessionEpisodes[index]; final url = qualityPlayUrl( quality: quality, tracks: qualityTracks, currentUrl: current.videoUrl, ); if (url == null || url.isEmpty) return false; if (selectedQuality.toUpperCase() == quality.toUpperCase() && current.videoUrl == url) { return true; } final previousQuality = selectedQuality; final active = session.active; if (active is VideoPlayerPort && active.controller.value.isInitialized) { _qualitySeek = active.controller.value.position; } selectedQuality = quality.toUpperCase(); sessionEpisodes[index] = current.withVideoUrl(url); final switched = await session.replaceCurrent(); if (switched) { if (isMounted()) notify(); return true; } _qualitySeek = null; selectedQuality = previousQuality; if (index < sessionEpisodes.length) { sessionEpisodes[index] = current; } if (isMounted()) notify(); return false; } Future _waitUntilFrameReady(VideoPlayerController controller) async { if (controller.value.hasError) { throw StateError('quality source failed'); } if (!controller.value.isBuffering) return; final done = Completer(); void onChange() { final value = controller.value; if (done.isCompleted) return; if (value.hasError || !value.isBuffering) done.complete(); } controller.addListener(onChange); try { await done.future.timeout(const Duration(seconds: 4)); } finally { controller.removeListener(onChange); } if (controller.value.hasError) { throw StateError('quality source failed'); } } /// 重试当前集的票据(失败态的"重试"按钮)。 Future retryTicket() async { ticketError = null; notify(); unawaited(selectEpisode(session.currentIndex)); } /// 金币解锁当前集;成功后强制刷新票据并直接起播。 Future unlockEpisode(int index) async { final service = wallet; if (service == null || !_isSignedIn() || index < 0 || index >= sessionEpisodes.length) { return false; } final episode = sessionEpisodes[index]; unlockingEpisodeId = episode.id; unlockError = null; notify(); try { final result = await service.unlock(episode.id); if (result == null || !result.unlocked) { unlockError = service.unlockError; return false; } unlockedEpisodes.markUnlocked(episode.id); final ready = await ensureTicket(index, forceRefresh: true); if (ready) { await _selectVisible(index); } return ready; } finally { unlockingEpisodeId = null; if (isMounted()) notify(); } } /// 当前播放器页面被其它 PageRoute 覆盖时暂停并保存进度。 void onRouteCovered() { if (!_started) return; session.setRouteVisible(false); cancelScrub(); unawaited(saveCurrentProgress()); } /// 覆盖播放器的 PageRoute 返回后恢复当前 Tab 的播放状态。 void onRouteRevealed() { if (!_started || !isMounted()) return; session.setRouteVisible(active); } void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); if (!active) { cancelScrub(); unawaited(saveCurrentProgress()); } } if (previous.resumeRequest != resumeRequest) { session.setUserWantsPlay(true); } if (previous.backRequest != backRequest) { final request = backRequest; WidgetsBinding.instance.addPostFrameCallback((_) { if (isMounted() && backRequest == request) { onMenu?.call(); } }); } } void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state == AppLifecycleState.resumed) { final wasInPip = pip || pipPreparing; // 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位, // 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。 if (pip || pipPreparing) { pip = false; pipPreparing = false; if (iosDevice) { // 部分回前台路径不回调 didStop:销毁影子、seek 刷纹理并恢复播放, // 否则停在 PiP 期间的暂停态表现为黑屏;自动小窗开着则重建待命。 unawaited(_recoverFromIosPipFallback()); } notify(); notifyImmersive(); } // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 否则会撞上 403(契约 §1.4) final episode = trackedEpisode; final shouldAutoResume = !wasInPip && _canAutoResume(episode); if (episode != null && !playback.isFresh(episode.id)) { unawaited( _refreshTicketThenResume( episode, session.currentIndex, shouldAutoResume, ), ); } else if (shouldAutoResume) { session.setUserWantsPlay(true); } } session.setAppResumed( state == AppLifecycleState.resumed || pip || pipPreparing, ); if (state != AppLifecycleState.resumed) cancelScrub(); // iOS 自动小窗:退后台瞬间把影子对齐到当前进度起播,系统据此自动进画中画; // 回前台取消待命。手动小窗的恢复由 onPipChanged(false) 处理。 if (iosDevice && iosAutoPipEnabled && !pip) { final video = session.active is VideoPlayerPort ? session.active as VideoPlayerPort : null; if (state == AppLifecycleState.inactive) { debugPrint( 'pip: inactive gate pageActive=$pipPageActive ' 'video=${video != null} userWantsPlay=${session.userWantsPlay}', ); } if (state == AppLifecycleState.inactive && pipPageActive && video != null && session.userWantsPlay) { debugPrint('pip: lifecycle inactive -> arm auto enter'); unawaited( pictureInPicture.armAutoEnter( video.controller.value.position.inMilliseconds, ), ); } else if (state == AppLifecycleState.resumed) { debugPrint('pip: lifecycle resumed -> disarm auto enter'); unawaited(pictureInPicture.disarmAutoEnter()); } } } Future _refreshTicketThenResume( Episode episode, int index, bool shouldResume, ) async { await selectEpisode(index); if (!shouldResume || !isMounted() || trackedEpisode?.id != episode.id || !_canAutoResume(episode)) { return; } session.setUserWantsPlay(true); } bool _canAutoResume(Episode? episode) { return episode != null && ticketError == null && blockedEpisodeId != episode.id && unlockingEpisodeId == null && !session.isLocked && session.error == null && session.canResumeAfterBackground; } @override void dispose() { if (_disposed) return; _disposed = true; _pipSubscribers.remove(this); _pipStartTimer?.cancel(); if (_pipSubscribers.isEmpty) { pictureInPicture.setStateListener(null); pictureInPicture.setPlaybackEndedListener(null); } unawaited(syncPipAutoArm(false)); unawaited(saveCurrentProgress()); _detachTrackedPlayerListener(); if (_started) { session.setRouteVisible(false); cancelScrub(); unawaited(session.close()); pages.dispose(); } super.dispose(); } void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); /// 按 [want] 挂/摘退后台自动小窗(引用计数): /// - 沉浸流:随所在 tab 的激活状态调用(active 时挂,切走时摘); /// - 独立播放页:进入时挂、销毁时摘; /// - 权限未授权时静默跳过(申请统一在首次点「画中画」时,见 [enterPip])。 Future syncPipAutoArm(bool want) async { if (!pictureInPicture.isPlatformSupported) return; // iOS 无「退后台自动小窗」(系统不开放 Activity 级自动进入),走手动入口。 if (!kIsWeb && Platform.isIOS) return; try { if (want) { _pipOwners.add(this); } else { _pipOwners.remove(this); } final arm = _pipOwners.isNotEmpty; if (arm == _pipNativeArmed) return; if (arm) { if (!await _floating.isPipAvailable) return; if (!await Permission.systemAlertWindow.isGranted) return; await _floating.enable(const OnLeavePiP(aspectRatio: Rational(9, 16))); _pipNativeArmed = true; } else { _pipNativeArmed = false; await _floating.cancelOnLeavePiP(); } } catch (_) { // 自动小窗挂载失败不影响正常播放。 } } Future enterPip(BuildContext context) async { if (pip || pipPreparing) { debugPrint('pip: tap ignored (pip=$pip preparing=$pipPreparing)'); return; } final isIos = !kIsWeb && Platform.isIOS; debugPrint('pip: tap platform=${isIos ? "ios" : "android"}'); final activeVideo = session.active is VideoPlayerPort ? session.active as VideoPlayerPort : null; if (activeVideo == null) { debugPrint('pip: no active video port'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), ); return; } // 首次点击「画中画」:申请「显示在其他应用上层」权限(特殊权限, // 会跳系统设置页);拒绝则视为设备无法使用画中画。仅 Android 需要。 if (!isIos && pictureInPicture.isPlatformSupported) { try { if (!await Permission.systemAlertWindow.isGranted) { await Permission.systemAlertWindow.request(); } if (await Permission.systemAlertWindow.isGranted) { _pipOwners.add(this); if (!_pipNativeArmed && await _floating.isPipAvailable) { await _floating.enable( const OnLeavePiP(aspectRatio: Rational(9, 16)), ); _pipNativeArmed = true; } } else { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.pipUnavailable), ), ); return; } } catch (_) { // 权限流程异常时按原有流程继续尝试直接进入。 } } pipPreparing = true; notify(); notifyImmersive(); await WidgetsBinding.instance.endOfFrame; final url = VideoPlayerPort.resolveUri( sessionEpisodes[session.currentIndex], ).toString(); final positionMs = activeVideo.controller.value.position.inMilliseconds; debugPrint('pip: invoke enter pos=$positionMs'); bool entered = false; try { entered = await pictureInPicture.enter(url: url, positionMs: positionMs); } catch (e) { debugPrint('pip: enter threw $e'); // Android reports unavailable for devices without PiP support. } debugPrint('pip: entered=$entered'); if (!context.mounted) return; if (entered) { if (isIos) { // iOS 等影子流就绪后自动进入小窗(原生 pendingStart 兜底不会过期); // 超时仅复位按钮状态,避免长时间卡在「准备中」。 _pipStartTimer?.cancel(); _pipStartTimer = Timer(const Duration(seconds: 6), () { if (!isMounted() || pip || !pipPreparing) return; pipPreparing = false; notify(); notifyImmersive(); }); } else { pip = true; pipPreparing = false; notify(); notifyImmersive(); session.setAppResumed(true); } } else { pipPreparing = false; notify(); notifyImmersive(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), ); } } static void _dispatchPipChanged(bool enabled, int finalPositionMs) { for (final logic in List.of(_pipSubscribers)) { logic.onPipChanged(enabled, finalPositionMs: finalPositionMs); } } static void _dispatchPipPlaybackEnded() { for (final logic in List.of(_pipSubscribers)) { logic._handleIosPipPlaybackEnded(); } } void onPipChanged(bool enabled, {int finalPositionMs = 0}) { if (!isMounted()) return; if (!kIsWeb && Platform.isIOS) { _onIosPipChanged(enabled, finalPositionMs); return; } pip = enabled; pipPreparing = false; _pipStartTimer?.cancel(); _pipStartTimer = null; notify(); notifyImmersive(); session.setAppResumed( enabled || WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, ); } /// iOS 影子接力:进入小窗后画面/声音交给原生影子播放器, /// Flutter 播放器暂停(避免双声);退出时按影子进度 seek 对齐恢复。 void _onIosPipChanged(bool enabled, int finalPositionMs) { final video = session.active is VideoPlayerPort ? session.active as VideoPlayerPort : null; pip = enabled; pipPreparing = false; _pipStartTimer?.cancel(); _pipStartTimer = null; if (enabled) { _wasPlayingBeforeIosPip = session.userWantsPlay; session.setUserWantsPlay(false); if (video != null) unawaited(video.pause()); unawaited(pictureInPicture.unmute()); session.setAppResumed(true); } else { unawaited(pictureInPicture.teardown()); PlayerLogic.iosShadowEpisodeId = null; pipResumeToken++; _beginPipRecover(); if (video != null) { final target = finalPositionMs > 0 ? clampResumePosition( Duration(milliseconds: finalPositionMs), video.controller.value.duration, ) : video.controller.value.position; // seek 同时兜住进度对齐与回前台纹理黑屏(强制 AVPlayer 重新出帧)。 unawaited(video.seekTo(target)); } session.setUserWantsPlay(_wasPlayingBeforeIosPip); session.setAppResumed( WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, ); // 自动小窗开着时影子回待命,等待下次退后台。 if (iosAutoPipEnabled) { final episode = trackedEpisode; if (episode != null) unawaited(_ensureIosShadowStandby(episode)); } } notify(); notifyImmersive(); } /// iOS 画中画里影子播完当前集:小窗内直接换下一集片源无缝连播(不翻页), /// 同时用无动画跳页把 App 内位置同步到新集,还原后正好停在新集; /// 没有下一集或下一集锁定时退出画中画回到 App。 Future _handleIosPipPlaybackEnded() async { if (!isMounted() || !pip || !(!kIsWeb && Platform.isIOS)) return; final index = session.currentIndex; final positionInVisible = visibleIndices.indexOf(index) + 1; if (positionInVisible <= 0 || positionInVisible >= visibleIndices.length) { await pictureInPicture.stop(); return; } final nextIndex = visibleIndices[positionInVisible]; if (nextIndex < 0 || nextIndex >= sessionEpisodes.length) { await pictureInPicture.stop(); return; } if (isEpisodeLocked(sessionEpisodes[nextIndex])) { await pictureInPicture.stop(); return; } final episode = sessionEpisodes[nextIndex]; debugPrint('pip: auto next episode=${episode.id}'); // 影子直接切下一集从头播,窗口不中断。 try { await pictureInPicture.replaceItem( url: VideoPlayerPort.resolveUri(episode).toString(), positionMs: 0, ); await pictureInPicture.unmute(); PlayerLogic.iosShadowEpisodeId = episode.id; } catch (_) { await pictureInPicture.stop(); return; } // 同步 App 内会话与页面位置(无动画),还原后停在当前连播到的集。 final controller = pages; try { if (controller.hasClients) controller.jumpToPage(positionInVisible); } catch (_) { // 页面可能已离树,影子连播不受影响。 } } Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; try { await player.controller.setPlaybackSpeed(playbackSpeed); final override = _qualitySeek; _qualitySeek = null; if (override != null && override > Duration.zero) { await player.seekTo( clampResumePosition(override, player.controller.value.duration), ); await _waitUntilFrameReady(player.controller); return; } // 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。 // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]); if (resumePositionFor(episode.id) == null) { final dramaId = _dramaIdForEpisode(episode.id); final cached = dramaId == null ? null : positionCache.read(episode.id, dramaId: dramaId); if (cached != null) { resumePoint = ResumePoint( episodeId: cached.episodeId, position: cached.position, ); } } final saved = resumePositionFor(episode.id); if (saved != null) { await player.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } } catch (_) { // 续看或清晰度定位失败时仍用已初始化的播放器,不把切换判失败。 } } Future activatePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; final index = session.currentIndex; final drama = queue.entryAt(index).drama; await player.controller.setVolume(muted ? 0 : 1); await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; _detachTrackedPlayerListener(); trackedPlayer = player; trackedEpisode = episode; trackedDrama = drama; _trackedPlayerIndex = index; _completionHandled = false; _completionProgressSaved = false; lastProgressWrite = DateTime.now(); player.controller.addListener(_handleTrackedPlayerChanged); // iOS:小窗内换集则影子直切新片源继续播;自动小窗开启时影子换源保持待命。 // 影子已在该集(连播刚换过源)时跳过,避免重置回片头。 if (!kIsWeb && Platform.isIOS) { if (pip) { if (PlayerLogic.iosShadowEpisodeId != episode.id) { unawaited(_syncIosShadowToEpisode(episode, playWhenPip: true)); } } else if (iosAutoPipEnabled) { unawaited(_ensureIosShadowStandby(episode)); } } } Future _syncIosShadowToEpisode( Episode episode, { required bool playWhenPip, }) async { debugPrint('pip: shadow sync episode=${episode.id} play=$playWhenPip'); try { await pictureInPicture.replaceItem( url: VideoPlayerPort.resolveUri(episode).toString(), positionMs: 0, ); PlayerLogic.iosShadowEpisodeId = episode.id; if (playWhenPip) await pictureInPicture.unmute(); } catch (_) { // 影子切换失败时保持小窗停在旧集末帧,不阻断 Flutter 侧换集。 } } /// 读取自动小窗开关;开启时影子待命由集激活钩子建立。 Future _loadIosAutoPipPref() async { if (!iosDevice) return; try { iosAutoPipEnabled = await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false; } catch (_) { iosAutoPipEnabled = false; } } /// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。 Future setIosAutoPip(bool enabled) async { debugPrint('pip: auto-pip switch -> $enabled'); if (!iosDevice || iosAutoPipEnabled == enabled) return; iosAutoPipEnabled = enabled; try { await SharedPreferencesAsync().setBool(_iosAutoPipPrefKey, enabled); } catch (_) { // 偏好写失败只影响下次启动的记忆,本次会话照常生效。 } if (enabled) { final index = session.currentIndex; if (index >= 0 && index < sessionEpisodes.length) { await _ensureIosShadowStandby(sessionEpisodes[index]); } } else { await pictureInPicture.teardown(); PlayerLogic.iosShadowEpisodeId = null; } } /// 还原过渡:盖封面直至视频真正出帧(onVideoVisible 时收起),兜底 900ms。 void _beginPipRecover() { _pipRecoverTimer?.cancel(); pipRecovering = true; _pipRecoverTimer = Timer(const Duration(milliseconds: 900), endPipRecover); } /// 视频首帧确认后立即收起还原封面(由播放视图回调)。 void endPipRecover() { _pipRecoverTimer?.cancel(); if (pipRecovering) { pipRecovering = false; notify(); } // 原生过渡层在还原动画开始时已盖上,这里等表面恢复后淡出。 unawaited(pictureInPicture.hideRestoreOverlay()); } /// 回前台兜底恢复:销毁影子、seek 强制重新出帧并恢复播放, /// 修复部分还原路径不回调 didStop 导致的黑屏 + 暂停。 Future _recoverFromIosPipFallback() async { pipResumeToken++; _beginPipRecover(); await pictureInPicture.teardown(); PlayerLogic.iosShadowEpisodeId = null; final video = session.active is VideoPlayerPort ? session.active as VideoPlayerPort : null; if (video != null) { await video.seekTo(video.controller.value.position); session.setUserWantsPlay(true); } session.setAppResumed(true); final episode = trackedEpisode; if (iosAutoPipEnabled && episode != null) { await _ensureIosShadowStandby(episode); } } /// 影子待命形态:静音暂停在片头,退后台时由系统自动切画中画。 Future _ensureIosShadowStandby(Episode episode) async { if (!iosDevice || pip) return; try { debugPrint('pip: prepare shadow standby (autoEnter)'); await pictureInPicture.prepareShadow( url: VideoPlayerPort.resolveUri(episode).toString(), positionMs: 0, autoEnter: true, ); PlayerLogic.iosShadowEpisodeId = episode.id; } catch (e) { debugPrint('pip: prepare standby failed: $e'); } } void _detachTrackedPlayerListener() { trackedPlayer?.controller.removeListener(_handleTrackedPlayerChanged); _trackedPlayerIndex = null; } void _handleTrackedPlayerChanged() { if (_disposed) return; final player = trackedPlayer; final episode = trackedEpisode; final drama = trackedDrama; final index = _trackedPlayerIndex; if (player == null || episode == null || drama == null || index == null || session.active != player) { return; } final snapshot = _readPlaybackSnapshot(player); if (snapshot == null) return; _cachePlaybackPosition(drama, episode, snapshot); if (snapshot.completed && !_completionProgressSaved) { _completionProgressSaved = true; unawaited( queueProgress( drama, episode, snapshot.duration, duration: snapshot.duration, finished: true, ), ); } if (!_completionHandled && snapshot.completed && session.canAutoAdvance && session.currentIndex == index) { _completionHandled = true; unawaited(advanceAfterEnd(player, index)); } if (!shouldReportProgress(DateTime.now())) return; unawaited( queueProgress( drama, episode, snapshot.position, duration: snapshot.duration, ), ); } Future advanceAfterEnd(VideoPlayerPort player, int index) async { if (!isMounted() || session.active != player || !session.canAutoAdvance) { return; } final next = visibleIndices.indexOf(index) + 1; if (next <= 0 || next >= visibleIndices.length) { stoppedAtFeedEnd = true; session.setUserWantsPlay(false); return; } final controller = pages; try { if (!controller.hasClients) return; await controller.animateToPage( next, duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, ); } catch (_) { // The page can leave the tree before the animation finishes. } } /// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。 Future queueProgress( Drama drama, Episode episode, Duration position, { Duration? duration, bool finished = false, }) { pendingWrite = pendingWrite .then( (_) => history.save( episodeId: episode.id, position: position, duration: duration, finished: finished, ), ) .catchError((Object _) { // 进度上报失败不影响播放(下次节流点会再报一次)。 return null; }); return pendingWrite; } Future saveCurrentProgress() async { final pendingSeek = _pendingSeek; if (pendingSeek != null) await pendingSeek; await _saveCurrentProgressNow(); } Future _saveCurrentProgressNow() { final player = trackedPlayer; final episode = trackedEpisode; final drama = trackedDrama; if (player == null || episode == null || drama == null) return pendingWrite; final snapshot = _readPlaybackSnapshot(player); if (snapshot == null) return pendingWrite; return _persistPlaybackSnapshot(drama, episode, snapshot); } _PlaybackSnapshot? _readPlaybackSnapshot(VideoPlayerPort player) { final value = player.controller.value; if (!value.isInitialized || value.duration <= Duration.zero) return null; return ( position: clampPlaybackPosition(value.position, value.duration), duration: value.duration, completed: value.isCompleted, ); } void _cachePlaybackPosition( Drama drama, Episode episode, _PlaybackSnapshot snapshot, ) { if (session.isLocked || ticketError != null || blockedEpisodeId == episode.id) { return; } positionCache.save( episodeId: episode.id, dramaId: drama.id, position: snapshot.position, duration: snapshot.duration, ); } Future _persistPlaybackSnapshot( Drama drama, Episode episode, _PlaybackSnapshot snapshot, { bool finished = false, }) { _cachePlaybackPosition(drama, episode, snapshot); return queueProgress( drama, episode, snapshot.position, duration: snapshot.duration, finished: finished, ); } /// 翻页入口使用 single-flight:执行中的切集不被重复排队,只保留最新目标。 Future requestEpisodeSelection(int index, {int? revision}) { if (_disposed || index < 0 || index >= sessionEpisodes.length) { return Future.value(); } _pendingSelectionIndex = index; _pendingSelectionRevision = revision; return _selectionDrain ??= _drainEpisodeSelections(); } Future _drainEpisodeSelections() async { try { while (!_disposed) { final index = _pendingSelectionIndex; if (index == null) break; final revision = _pendingSelectionRevision; _pendingSelectionIndex = null; _pendingSelectionRevision = null; await selectEpisode(index, revision: revision); _recordPickedEpisode(index); } } finally { _selectionDrain = null; final index = _pendingSelectionIndex; if (!_disposed && index != null) { unawaited( requestEpisodeSelection(index, revision: _pendingSelectionRevision), ); } } } /// 记录用户从选集面板主动挑的集,供 feed 侧「记住上次选集」展示。 /// 只在切集确实落到该集时记录,避免快速连点时把没播过的集记成"上次选集"。 void _recordPickedEpisode(int index) { if (!_started || _disposed || session.currentIndex != index) return; if (index < 0 || index >= sessionEpisodes.length) return; final episode = sessionEpisodes[index]; final dramaId = _dramaIdForEpisode(episode.id); if (dramaId != null) { pickedEpisode[dramaId] = episode.number; } } Future selectEpisode( int index, { int? revision, bool? userWantsPlay, }) async { if (index < 0 || index >= sessionEpisodes.length) return; final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); // Capture the old position synchronously, persist without blocking a swipe. unawaited(saveCurrentProgress()); if (request != pageRequest || feedRevisionAtStart != feedRevision || !isMounted()) { return; } final episode = sessionEpisodes[index]; if (isEpisodeLocked(episode)) { blockedTicket = null; blockedEpisodeId = episode.id; ticketError = null; await session.selectLocked( index, preloadIndex: nextVisibleIndex(index), previousIndex: previousVisibleIndex(index), ); if (request != pageRequest || feedRevisionAtStart != feedRevision || !isMounted()) { return; } if (!_isSignedIn()) { unawaited(refreshEpisodeStats(index)); return; } } // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) final ready = await ensureTicket(index); if (request != pageRequest || feedRevisionAtStart != feedRevision || !isMounted()) { return; } if (!ready) { unawaited(refreshEpisodeStats(index)); return; } await _selectVisible(index, userWantsPlay: userWantsPlay); } /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 bool isEpisodeLocked(Episode episode) { return (episode.locked || !episode.isFree) && !unlockedEpisodes.contains(episode.id); } /// 单页锁定判定:剧集自身锁定 + 会话锁态(只约束当前页)+ 后端拒绝。 /// /// 播放页的多个渲染分支都必须走这里,否则相邻预加载页会被会话锁态误判成锁定。 bool isEpisodeBlockedFor(Episode episode, int index) { return isEpisodeLocked(episode) || (index == session.currentIndex && session.isLocked) || blockedEpisodeId == episode.id; } /// 按可见队列选择一集,并预热上一集与下一集。 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) { final position = visibleIndices.indexOf(index); return position >= 0 && position + 1 < visibleIndices.length ? visibleIndices[position + 1] : null; } /// 可见队列里当前集的上一集;已经是第一集时为空。 int? previousVisibleIndex(int index) { final position = visibleIndices.indexOf(index); if (position <= 0) return null; return visibleIndices[position - 1]; } bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; Future playNextEpisode() async { final next = nextVisibleIndex(session.currentIndex); if (next == null || !pages.hasClients) return; await pages.animateToPage( visibleIndices.indexOf(next), duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, ); } void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; } void cancelScrub() { if (scrubbingPlayer == null) return; scrubbingPlayer = null; } Future endScrub( BuildContext context, VideoPlayerPort player, Duration position, ) => seekTo(context, player, position); Future seekTo( BuildContext context, VideoPlayerPort player, Duration position, ) { final operation = _performSeek(context, player, position); _pendingSeek = operation; return operation.whenComplete(() { if (identical(_pendingSeek, operation)) _pendingSeek = null; }); } Future _performSeek( BuildContext context, VideoPlayerPort player, Duration position, ) async { if (scrubbingPlayer != player || session.active != player) return; try { await session.playbackSettled; if (scrubbingPlayer != player || session.active != player) return; final duration = player.controller.value.duration; final target = clampPlaybackPosition(position, duration); if (target <= Duration.zero) return; await player.seekTo(target); if (session.active != player) return; final snapshot = _readPlaybackSnapshot(player); if (snapshot == null) return; final entry = queue.entryAt(session.currentIndex); final save = _persistPlaybackSnapshot( entry.drama, entry.episode, snapshot, ); // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 resumeAfterSeek(); await save; } catch (_) { if (context.mounted && session.active == player) { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); } } finally { if (scrubbingPlayer == player) cancelScrub(); } } /// 拖动进度落点后恢复播放;暂停态拖动不应停留在暂停。 @visibleForTesting void resumeAfterSeek() { if (!session.userWantsPlay) session.setUserWantsPlay(true); } Future leave(BuildContext context) async { await saveCurrentProgress(); if (context.mounted) Navigator.of(context).pop(); } /// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。 Future openDetail(BuildContext context) => _showDramaSheet(context, DramaInfoTab.detail); String? _dramaIdForEpisode(String episodeId) { for (final entry in queue.entries) { if (entry.episode.id == episodeId) return entry.drama.id; } return null; } Future openSeriesAt( BuildContext context, Drama drama, int index, ) async { final route = Navigator.of(context).push( FadePageRoute( transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, builder: (_) => PlayerPage( drama: drama, initialIndex: index, wallet: wallet, downloads: downloads, ), ), ); if (kDebugMode) { debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0'); } unawaited(pauseForFullPlayer()); unawaited(saveCurrentProgress()); await route; if (!isMounted()) return; await resumeAfterFullPlayer(); } /// 选集入口:同一个弹窗,直接停在选集页签。 Future showEpisodes(BuildContext context) => _showDramaSheet(context, DramaInfoTab.episodes); Future _showDramaSheet( BuildContext context, DramaInfoTab initialTab, ) async { final entry = queue.entryAt(session.currentIndex); final drama = await _withFullEpisodes(context, entry.drama); if (!context.mounted) return; final homeFeed = feedDramas != null; final currentNumber = entry.episode.number; session.setOverlayOpen(true); await saveCurrentProgress(); await session.playbackSettled; if (!context.mounted) { session.setOverlayOpen(false); return; } try { await showModalBottomSheet( context: context, backgroundColor: AppColors.transparent, isScrollControlled: true, builder: (_) => DramaInfoSheet( drama: drama, currentNumber: currentNumber, initialTab: initialTab, onSelectEpisode: (index) => _selectFromSheet(context, drama, index, homeFeed, currentNumber), onOpenRelated: (related) => unawaited(openSeriesAt(context, related, 0)), ), ); } finally { session.setOverlayOpen(false); } await saveCurrentProgress(); } /// 列表卡片只带一集可播预览,需要完整剧集时补一次详情; /// 取不到(或页面外没有目录来源)就退回卡片本身。 Future _withFullEpisodes(BuildContext context, Drama drama) async { if (drama.detailLoaded) return drama; final CatalogController catalog; try { catalog = context.read(); } catch (_) { return drama; } try { return await catalog.requireDetail(drama); } catch (_) { return drama; } } void _selectFromSheet( BuildContext pageContext, Drama drama, int index, bool homeFeed, int currentNumber, ) { if (homeFeed) { if (drama.episodes[index].number == currentNumber) return; unawaited(openSeriesAt(pageContext, drama, index)); return; } if (index == session.currentIndex) return; unawaited( pages.animateToPage( index, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ), ); } Future showMy(BuildContext context) async { if (embedded) { onMy?.call(); return; } session.setRouteVisible(false); await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; final action = await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.recommend) { await showDiscover(context); } else { session.setRouteVisible(true); } if (action == MyPageAction.resume) session.setUserWantsPlay(true); } Future showSearch(BuildContext context) async { if (embedded) { onSearch?.call(); return; } await showDiscover(context); } Future showDiscover(BuildContext context) async { if (embedded) { onDiscover?.call(); return; } session.setRouteVisible(false); await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const SearchPage())); if (context.mounted) { session.setRouteVisible(true); } } Future toggleMute() async { final port = session.active; if (port is! VideoPlayerPort) return; final next = !muted; await port.controller.setVolume(next ? 0 : 1); if (isMounted()) muted = next; notify(); } Future setPlaybackSpeed(double value) async { playbackSpeed = value; final active = session.active; if (active is VideoPlayerPort && active.controller.value.isInitialized) { await active.controller.setPlaybackSpeed(value); } notify(); } Future showComments( BuildContext context, Drama drama, { String? episodeId, }) async { session.setOverlayOpen(true); await session.playbackSettled; if (!context.mounted) { session.setOverlayOpen(false); return; } try { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: AppColors.transparent, showDragHandle: false, isDismissible: false, enableDrag: false, builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId), ); } finally { session.setOverlayOpen(false); } } Future shareDrama(BuildContext context, Drama drama) async { final l10n = AppLocalizations.of(context)!; session.setOverlayOpen(true); await session.playbackSettled; try { await const PlatformShareService().shareDrama( title: localizedDramaTitle(l10n, drama), synopsis: localizedDramaSynopsis(l10n, drama), footer: l10n.shareFooter, ); } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)), ); } } finally { session.setOverlayOpen(false); } } }