// 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 visibleIndices; late PlaybackSession session; VideoPlayerPort? trackedPlayer; Episode? trackedEpisode; Drama? trackedDrama; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); /// 进度上报节流:契约建议每 10 秒一次 Duration progressInterval = const Duration(seconds: 10); Future pendingWrite = Future.value(); /// 服务端续看点:进入播放页时定位到"那一集的哪一秒" ResumePoint? resumePoint; bool resumeLoaded = false; /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 bool ticketLoading = false; String? ticketError; PlayTicket? blockedTicket; String? blockedEpisodeId; List sessionEpisodes = const []; List 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 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? feedDramas; VoidCallback? onDiscover; VoidCallback? onMy; VoidCallback? onSearch; VoidCallback? onMenu; ValueChanged? onDramaChanged; ValueChanged? 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.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 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 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 retryTicket() async { ticketError = null; notify(); unawaited(selectEpisode(session.currentIndex)); } /// 金币解锁当前集;成功后强制刷新票据并直接起播。 Future 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 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 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 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 advanceAfterEnd(VideoPlayerPort player, int index) async { if (!isMounted() || session.active != player || !session.canAutoAdvance) { return; } final next = visibleIndices.indexOf(index) + 1; if (next <= 0 || next >= visibleIndices.length) { stoppedAtFeedEnd = true; session.setUserWantsPlay(false); return; } final controller = pages; try { if (!controller.hasClients) return; await controller.animateToPage( next, duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, ); } catch (_) { // The page can leave the tree before the animation finishes. } } /// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。 Future queueProgress( Drama drama, Episode episode, Duration position, { Duration? duration, bool finished = false, }) { pendingWrite = pendingWrite .then( (_) => history.save( episodeId: episode.id, position: position, duration: duration, finished: finished, ), ) .catchError((Object _) { // 进度上报失败不影响播放(下次节流点会再报一次)。 return null; }); return pendingWrite; } Future saveCurrentProgress() { 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 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 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 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 leave(BuildContext context) async { await saveCurrentProgress(); if (context.mounted) Navigator.of(context).pop(); } Future openDetail(BuildContext context) async { session.setOverlayOpen(true); try { if (!context.mounted) return; await Navigator.of(context).push( FadePageRoute( builder: (_) => DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), ), ); } finally { session.setOverlayOpen(false); } await saveCurrentProgress(); } Future 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( builder: (_) => PlayerPage( drama: drama, initialIndex: index, wallet: wallet, downloads: downloads, ), ), ); if (isMounted()) session.setRouteVisible(active); } Future 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( 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 _withFullEpisodes(BuildContext context, Drama drama) async { if (drama.detailLoaded) return drama; final CatalogController catalog; try { catalog = context.read(); } catch (_) { return drama; } try { return await catalog.requireDetail(drama); } catch (_) { return drama; } } void _selectFromSheet( BuildContext pageContext, Drama drama, int index, bool homeFeed, int currentNumber, ) { if (homeFeed) { if (drama.episodes[index].number == currentNumber) return; unawaited(openSeriesAt(pageContext, drama, index)); return; } if (index == session.currentIndex) return; unawaited( pages.animateToPage( index, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ), ); } Future showMy(BuildContext context) async { if (embedded) { onMy?.call(); return; } session.setRouteVisible(false); await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; final action = await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.discover) { await showDiscover(context); } else { session.setRouteVisible(true); } if (action == MyPageAction.resume) session.setUserWantsPlay(true); } Future showSearch(BuildContext context) async { if (embedded) { onSearch?.call(); return; } await showDiscover(context, focusSearch: true); } Future 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( MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)), ); if (!context.mounted) return; if (action == DiscoverPageAction.my) { await showMy(context); } else { session.setRouteVisible(true); } } Future toggleMute() async { final port = session.active; if (port is! VideoPlayerPort) return; final next = !muted; await port.controller.setVolume(next ? 0 : 1); if (isMounted()) muted = next; notify(); } Future setPlaybackSpeed(double value) async { playbackSpeed = value; final active = session.active; if (active is VideoPlayerPort && active.controller.value.isInitialized) { await active.controller.setPlaybackSpeed(value); } notify(); } Future showComments(BuildContext context, Drama drama) async { session.setOverlayOpen(true); await session.playbackSettled; if (!context.mounted) { session.setOverlayOpen(false); return; } try { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, showDragHandle: false, builder: (_) => DramaCommentsSheet(drama: drama), ); } finally { session.setOverlayOpen(false); } } Future shareDrama(BuildContext context, Drama drama) async { final l10n = AppLocalizations.of(context)!; session.setOverlayOpen(true); await session.playbackSettled; try { await const PlatformShareService().shareDrama( title: localizedDramaTitle(l10n, drama), synopsis: localizedDramaSynopsis(l10n, drama), footer: l10n.shareFooter, ); } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.shareFailed)), ); } } finally { session.setOverlayOpen(false); } } }