Files
ReelShort/lib/pages/player/player_logic.dart
Vicky 79463e65c2 feat(player): 剧名与选集合并为同一个弹窗
点剧名默认停在详情页签,选集入口直接停在选集页签,不再跳详情页;
详情页签补上简介、标签、收藏与相似推荐,原选集弹窗合并进来后删除。
2026-10-29 15:44:10 +08:00

2064 lines
68 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<PlayerLogic> _pipOwners = <PlayerLogic>{};
/// 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<void> 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<void> Function() _waitForFrame;
final DateTime Function() _clock;
final bool Function() _isSignedIn;
late PageController pages;
late PlaybackQueue queue;
late List<int> 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<void> pendingWrite = Future<void>.value();
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
ResumePoint? resumePoint;
bool resumeLoaded = false;
/// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。
Future<void>? _resumePointLoad;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false;
String? ticketError;
PlayTicket? blockedTicket;
String? blockedEpisodeId;
List<Episode> sessionEpisodes = const [];
List<String> qualities = const [];
List<PlayQualityTrack> qualityTracks = const [];
String selectedQuality = 'AUTO';
Duration? _qualitySeek;
int pageRequest = 0;
int? _pendingSelectionIndex;
int? _pendingSelectionRevision;
Future<void>? _selectionDrain;
bool muted = false;
double playbackSpeed = 1.0;
bool stoppedAtFeedEnd = false;
Duration episodeStatsTtl = const Duration(seconds: 60);
ValueChanged<EpisodeStats>? 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<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
static final Map<String, int> pickedEpisode = {};
final Map<String, EpisodeStats> _episodeStats = {};
final Map<String, DateTime> _episodeStatsFetchedAt = {};
final Map<String, Future<void>> _episodeStatsInFlight = {};
final Map<String, int> _episodeStatsRevision = {};
final Map<String, int> _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<Drama>? feedDramas;
VoidCallback? onDiscover;
VoidCallback? onMy;
VoidCallback? onSearch;
VoidCallback? onMenu;
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
PlayerFactory? _sessionFactory;
PlaybackCoordinator? _sessionCoordinator;
int feedRevision = 0;
List<Drama> _latestFeed = const [];
String? _latestKeepDramaId;
bool _latestResetToFirst = false;
bool _feedUpdateRunning = false;
bool _hasPendingFeed = false;
bool _deferredFeedNotifyScheduled = false;
int _feedJumpSerial = 0;
Future<void> _feedUpdateFuture = Future<void>.value();
Future<void>? _pendingSeek;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false;
bool get immersive => pip || pipPreparing;
bool get chromeHidden => immersive;
/// 最近一次 feed 更新流程完成;测试和页面退出可等待它结束。
Future<void> 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<int>.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<void> pauseForFullPlayer() async {
if (!_started) return;
_logFeedHandoff('pause');
cancelScrub();
await session.pauseForRouteHandoff();
_logFeedHandoff('paused');
}
/// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。
Future<void> 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<Drama> dramas, {
String? keepDramaId,
bool resetToFirst = false,
}) {
feedRevision++;
pageRequest++;
_latestFeed = List<Drama>.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<void> _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<void> _applyFeedUpdate(
List<Drama> 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<int>.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<void> _replaceSession(
List<Episode> 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<Episode>.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<void> loadResumePoint() {
final inFlight = _resumePointLoad;
if (inFlight != null) return inFlight;
return _resumePointLoad = _loadResumePoint().whenComplete(() {
_resumePointLoad = null;
});
}
Future<void> _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<bool> 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<void> refreshEpisodeStats(int index) {
if (_disposed || index < 0 || index >= sessionEpisodes.length) {
return Future<void>.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<void>.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<void> 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<void> _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<bool> 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<void> _waitUntilFrameReady(VideoPlayerController controller) async {
if (controller.value.hasError) {
throw StateError('quality source failed');
}
if (!controller.value.isBuffering) return;
final done = Completer<void>();
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<void> retryTicket() async {
ticketError = null;
notify();
unawaited(selectEpisode(session.currentIndex));
}
/// 金币解锁当前集;成功后强制刷新票据并直接起播。
Future<bool> 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<void> _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<void> 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<void> 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<void> _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<void> 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<void> 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<void> _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<void> _loadIosAutoPipPref() async {
if (!iosDevice) return;
try {
iosAutoPipEnabled =
await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false;
} catch (_) {
iosAutoPipEnabled = false;
}
}
/// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。
Future<void> 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<void> _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<void> _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<void> 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<void> 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<void> saveCurrentProgress() async {
final pendingSeek = _pendingSeek;
if (pendingSeek != null) await pendingSeek;
await _saveCurrentProgressNow();
}
Future<void> _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<void> _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<void> requestEpisodeSelection(int index, {int? revision}) {
if (_disposed || index < 0 || index >= sessionEpisodes.length) {
return Future<void>.value();
}
_pendingSelectionIndex = index;
_pendingSelectionRevision = revision;
return _selectionDrain ??= _drainEpisodeSelections();
}
Future<void> _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<void> 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<void> _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<void> 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<void> endScrub(
BuildContext context,
VideoPlayerPort player,
Duration position,
) => seekTo(context, player, position);
Future<void> 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<void> _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<void> leave(BuildContext context) async {
await saveCurrentProgress();
if (context.mounted) Navigator.of(context).pop();
}
/// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
Future<void> 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<void> openSeriesAt(
BuildContext context,
Drama drama,
int index,
) async {
final route = Navigator.of(context).push(
FadePageRoute<void>(
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<void> showEpisodes(BuildContext context) =>
_showDramaSheet(context, DramaInfoTab.episodes);
Future<void> _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<void>(
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<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
if (drama.detailLoaded) return drama;
final CatalogController catalog;
try {
catalog = context.read<CatalogController>();
} 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<void> 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<MyPageAction>(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<void> showSearch(BuildContext context) async {
if (embedded) {
onSearch?.call();
return;
}
await showDiscover(context);
}
Future<void> 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<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
if (context.mounted) {
session.setRouteVisible(true);
}
}
Future<void> 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<void> 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<void> showComments(
BuildContext context,
Drama drama, {
String? episodeId,
}) async {
session.setOverlayOpen(true);
await session.playbackSettled;
if (!context.mounted) {
session.setOverlayOpen(false);
return;
}
try {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.transparent,
showDragHandle: false,
isDismissible: false,
enableDrag: false,
builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId),
);
} finally {
session.setOverlayOpen(false);
}
}
Future<void> 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);
}
}
}