1994 lines
65 KiB
Dart
1994 lines
65 KiB
Dart
// 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/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 '../drama_detail/drama_detail_view.dart';
|
||
import '../mine/mine_types.dart';
|
||
import '../mine/mine_view.dart';
|
||
import 'player_view.dart';
|
||
import 'widgets/drama_comments_sheet.dart';
|
||
import 'widgets/episode_picker_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,
|
||
PlaybackPositionCache? positionCache,
|
||
Future<void> Function()? waitForFrame,
|
||
DateTime Function()? clock,
|
||
bool Function()? isSignedIn,
|
||
}) : 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 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;
|
||
|
||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||
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;
|
||
|
||
/// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。
|
||
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>{};
|
||
static final Map<String, int> pickedEpisode = {};
|
||
final Set<String> _unlockedEpisodeIds = {};
|
||
final Map<String, EpisodeStats> _episodeStats = {};
|
||
final Map<String, DateTime> _episodeStatsFetchedAt = {};
|
||
final Map<String, Future<void>> _episodeStatsInFlight = {};
|
||
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() 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) {
|
||
_unlockedEpisodeIds.add(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) {
|
||
_unlockedEpisodeIds.add(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;
|
||
}
|
||
_unlockedEpisodeIds.add(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 &&
|
||
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 pendingWrite;
|
||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||
if (resumePoint == null) await 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);
|
||
}
|
||
} finally {
|
||
_selectionDrain = null;
|
||
final index = _pendingSelectionIndex;
|
||
if (!_disposed && index != null) {
|
||
unawaited(
|
||
requestEpisodeSelection(index, revision: _pendingSelectionRevision),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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) &&
|
||
!_unlockedEpisodeIds.contains(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) async {
|
||
session.setOverlayOpen(true);
|
||
await saveCurrentProgress();
|
||
try {
|
||
if (!context.mounted) return;
|
||
await Navigator.of(context).push(
|
||
FadePageRoute<void>(
|
||
builder: (_) =>
|
||
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
|
||
),
|
||
);
|
||
} finally {
|
||
session.setOverlayOpen(false);
|
||
}
|
||
await saveCurrentProgress();
|
||
}
|
||
|
||
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) 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 session.playbackSettled;
|
||
if (!context.mounted) {
|
||
session.setOverlayOpen(false);
|
||
return;
|
||
}
|
||
try {
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
backgroundColor: AppColors.transparent,
|
||
isScrollControlled: true,
|
||
builder: (_) => EpisodePickerSheet(
|
||
drama: drama,
|
||
currentNumber: currentNumber,
|
||
onSelect: (index) =>
|
||
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
|
||
),
|
||
);
|
||
} finally {
|
||
session.setOverlayOpen(false);
|
||
}
|
||
}
|
||
|
||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||
/// 取不到(或页面外没有目录来源)就退回卡片本身。
|
||
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);
|
||
}
|
||
}
|
||
}
|