Files
ReelShort/lib/pages/player/player_logic.dart
T

843 lines
26 KiB
Dart
Raw 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 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.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_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/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.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';
class PlayerLogic extends ChangeNotifier {
PlayerLogic({
required this.history,
required this.playback,
required this.pictureInPicture,
this.wallet,
this.downloads,
});
final WatchHistoryRepository history;
final PlaybackRepository playback;
final AndroidPictureInPicture pictureInPicture;
final WalletController? wallet;
final DownloadController? downloads;
late PageController pages;
late PlaybackQueue queue;
late List<int> visibleIndices;
late PlaybackSession session;
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
Drama? trackedDrama;
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 [];
int pageRequest = 0;
bool muted = false;
double playbackSpeed = 1.0;
bool stoppedAtFeedEnd = false;
VideoPlayerPort? scrubbingPlayer;
bool pip = false;
bool pipPreparing = false;
final Map<String, int> pickedEpisode = {};
/// 解锁中的集 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;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false;
bool get immersive => pip || pipPreparing;
bool get chromeHidden => immersive;
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
for (final episode in drama.episodes) {
if (episode.number == number) return episode;
}
return fallback;
}
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,
}) {
_started = true;
pictureInPicture.setStateListener(onPipChanged);
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 = PlaybackSession(
episodes: sessionEpisodes,
factory: factory,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
canPlay: (episode) => episode.videoUrl.isNotEmpty,
prepare: preparePlayer,
activate: activatePlayer,
coordinator: coordinator,
);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
Future<void> loadResumePoint() async {
final dramaId = queue.entryAt(initialIndex).drama.id;
try {
resumePoint = await history.resumePointFor(dramaId);
} 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) {
blockedTicket = null;
blockedEpisodeId = null;
qualities = 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;
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
return true;
}
blockedTicket = ticket;
blockedEpisodeId = episode.id;
return false;
} catch (exception) {
if (request != pageRequest) return false;
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
if (episode.videoUrl.isNotEmpty) {
ticketError = null;
return true;
}
ticketError = exception is ApiException
? exception.message
: 'ticketError';
return false;
} finally {
ticketLoading = false;
if (isMounted()) notify();
}
}
String _fileUrl(String path) =>
path.startsWith('file:') ? path : Uri.file(path).toString();
/// 重试当前集的票据(失败态的"重试"按钮)。
Future<void> retryTicket() async {
ticketError = null;
notify();
unawaited(selectEpisode(session.currentIndex));
}
/// 金币解锁当前集;成功后强制刷新票据并直接起播。
Future<bool> unlockEpisode(int index) async {
final service = wallet;
if (service == null || 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;
}
final ready = await ensureTicket(index, forceRefresh: true);
if (ready) {
await session.select(index, preloadIndex: nextVisibleIndex(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) {
// 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位,
// 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。
if (pip || pipPreparing) {
pip = false;
pipPreparing = false;
notify();
notifyImmersive();
}
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
// 否则会撞上 403(契约 §1.4)
final episode = trackedEpisode;
if (episode != null && !playback.isFresh(episode.id)) {
unawaited(selectEpisode(session.currentIndex));
}
}
session.setAppResumed(
state == AppLifecycleState.resumed || pip || pipPreparing,
);
if (state != AppLifecycleState.resumed) cancelScrub();
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
pictureInPicture.setStateListener(null);
unawaited(saveCurrentProgress());
if (_started) {
session.setRouteVisible(false);
cancelScrub();
unawaited(session.close());
pages.dispose();
}
super.dispose();
}
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
Future<void> enterPip(BuildContext context) async {
if (pip || pipPreparing) return;
if (session.active is! VideoPlayerPort) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
);
return;
}
pipPreparing = true;
notify();
notifyImmersive();
await WidgetsBinding.instance.endOfFrame;
bool entered = false;
try {
entered = await pictureInPicture.enter();
} catch (_) {
// Android reports unavailable for devices without PiP support.
}
if (!context.mounted) return;
if (entered) {
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)),
);
}
}
void onPipChanged(bool enabled) {
if (!isMounted()) return;
pip = enabled;
pipPreparing = false;
notify();
notifyImmersive();
session.setAppResumed(
enabled ||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed,
);
}
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return;
try {
await player.controller.setPlaybackSpeed(playbackSpeed);
await pendingWrite;
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint();
final saved = resumePositionFor(episode.id);
if (saved != null) {
await player.controller.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;
trackedPlayer = player;
trackedEpisode = episode;
trackedDrama = drama;
lastProgressWrite = DateTime.now();
var completionHandled = false;
var completionProgressSaved = false;
player.controller.addListener(() {
if (session.active != player || !player.controller.value.isInitialized) {
return;
}
if (player.controller.value.isCompleted && !completionProgressSaved) {
completionProgressSaved = true;
unawaited(
queueProgress(
drama,
episode,
player.controller.value.duration,
duration: player.controller.value.duration,
finished: true,
),
);
}
if (!completionHandled &&
player.controller.value.isCompleted &&
session.canAutoAdvance &&
session.currentIndex == index) {
completionHandled = true;
unawaited(advanceAfterEnd(player, index));
}
if (!shouldReportProgress(DateTime.now())) return;
unawaited(
queueProgress(drama, episode, player.controller.value.position),
);
});
}
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() {
final player = trackedPlayer;
final episode = trackedEpisode;
final drama = trackedDrama;
if (player == null ||
episode == null ||
drama == null ||
!player.controller.value.isInitialized) {
return pendingWrite;
}
return queueProgress(
drama,
episode,
player.controller.value.position,
duration: player.controller.value.duration,
);
}
Future<void> selectEpisode(int index) async {
final request = ++pageRequest;
cancelScrub();
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(saveCurrentProgress());
if (request != pageRequest || !isMounted()) return;
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
if (request != pageRequest || !isMounted()) return;
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
}
int? nextVisibleIndex(int index) {
final position = visibleIndices.indexOf(index);
return position >= 0 && position + 1 < visibleIndices.length
? visibleIndices[position + 1]
: null;
}
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;
session.setOverlayOpen(true);
}
void cancelScrub() {
if (scrubbingPlayer == null) return;
scrubbingPlayer = null;
session.setOverlayOpen(false);
}
Future<void> seekTo(
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;
await player.controller.seekTo(position);
if (session.active == player) {
// 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。
resumeAfterSeek();
await saveCurrentProgress();
}
} 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);
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();
}
Future<void> openSeriesAt(
BuildContext context,
Drama drama,
int index,
) async {
session.setRouteVisible(false);
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
await Navigator.of(context).push(
FadePageRoute<void>(
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index,
wallet: wallet,
downloads: downloads,
),
),
);
if (isMounted()) session.setRouteVisible(active);
}
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: Colors.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.discover) {
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, focusSearch: true);
}
Future<void> showDiscover(
BuildContext context, {
bool focusSearch = false,
}) async {
if (embedded) {
onDiscover?.call();
return;
}
session.setRouteVisible(false);
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(context).push<DiscoverPageAction>(
MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
);
if (!context.mounted) return;
if (action == DiscoverPageAction.my) {
await showMy(context);
} else {
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) async {
session.setOverlayOpen(true);
await session.playbackSettled;
if (!context.mounted) {
session.setOverlayOpen(false);
return;
}
try {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
showDragHandle: false,
isDismissible: false,
enableDrag: false,
builder: (_) => DramaCommentsSheet(drama: drama),
);
} 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);
}
}
}