// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/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/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_logic.dart'; import '../discover/discover_view.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_logic.dart'; import '../mine/mine_view.dart'; import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; class PlayerLogic extends ChangeNotifier { PlayerLogic({ required this.history, required this.playback, required this.pictureInPicture, }); final WatchHistoryRepository history; final PlaybackRepository playback; final AndroidPictureInPicture pictureInPicture; 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 []; int pageRequest = 0; bool muted = false; bool stoppedAtFeedEnd = false; VideoPlayerPort? scrubbingPlayer; bool fullscreen = false; bool pip = false; bool pipPreparing = false; bool screenCleared = false; final Map pickedEpisode = {}; 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 => fullscreen || pip || pipPreparing; bool get chromeHidden => immersive || screenCleared; 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 setScreenCleared(bool value) { if (screenCleared == value) return; screenCleared = value; notify(); notifyImmersive(); } 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) async { if (index < 0 || index >= sessionEpisodes.length) return false; final episode = sessionEpisodes[index]; final request = pageRequest; ticketLoading = true; ticketError = null; notify(); try { final ticket = await playback.ticketFor(episode.id); if (request != pageRequest) return false; blockedTicket = null; blockedEpisodeId = null; 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(); } } /// 重试当前集的票据(失败态的"重试"按钮)。 Future retryTicket() async { ticketError = null; notify(); unawaited(selectEpisode(session.currentIndex)); } 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) { if (fullscreen) { unawaited(setFullscreen(false)); } else if (screenCleared) { setScreenCleared(false); } else { onMenu?.call(); } } }); } } void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state == AppLifecycleState.resumed) { // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 否则会撞上 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); if (fullscreen) { unawaited( SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), ); unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); } unawaited(saveCurrentProgress()); if (_started) { session.setRouteVisible(false); cancelScrub(); unawaited(session.close()); pages.dispose(); } super.dispose(); } void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); Future setFullscreen(bool value) async { if (fullscreen == value || !isMounted()) return; fullscreen = value; notify(); notifyImmersive(); try { await SystemChrome.setPreferredOrientations( value ? [ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ] : [DeviceOrientation.portraitUp], ); await SystemChrome.setEnabledSystemUIMode( value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, ); } catch (_) { if (value && isMounted()) { fullscreen = false; notify(); notifyImmersive(); } } } 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; } if (fullscreen) await setFullscreen(false); 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 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); 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; } 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) 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(); } } Future leave(BuildContext context) async { await saveCurrentProgress(); if (context.mounted) Navigator.of(context).pop(); } Future openDetail(BuildContext context) async { session.setRouteVisible(false); await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama), ), ); if (isMounted()) session.setRouteVisible(active); } 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( MaterialPageRoute( builder: (_) => PlayerPage(drama: drama, initialIndex: index), ), ); if (isMounted()) session.setRouteVisible(active); } Future showEpisodes(BuildContext context) async { final l10n = AppLocalizations.of(context)!; final entry = queue.entryAt(session.currentIndex); final drama = await _withFullEpisodes(context, entry.drama); if (!context.mounted) return; final homeFeed = feedDramas != null; final episodes = drama.episodes; final currentNumber = entry.episode.number; session.setOverlayOpen(true); await session.playbackSettled; if (!context.mounted) { session.setOverlayOpen(false); return; } try { await showModalBottomSheet( context: context, backgroundColor: const Color(0xFF16171C), showDragHandle: true, builder: (sheetContext) => SafeArea( child: SizedBox( height: MediaQuery.sizeOf(sheetContext).height * 0.46, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), child: Text( l10n.episodes, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: Text( localizedDramaTitle(l10n, drama), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Color(0xFFB7B3B8), fontSize: 14, fontWeight: FontWeight.w400, ), ), ), Expanded( child: GridView.builder( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 6, mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: 1.45, ), itemCount: episodes.length, itemBuilder: (_, index) => _episodeCell( context, sheetContext, drama, episodes[index], 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; } } Widget _episodeCell( BuildContext pageContext, BuildContext sheetContext, Drama drama, Episode episode, int index, bool homeFeed, int currentNumber, ) { final selected = episode.number == currentNumber; return Material( color: selected ? AppColors.bluePressed : const Color(0xFF2A2C33), borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { Navigator.of(sheetContext).pop(); if (homeFeed) { if (episode.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, ), ); }, child: Center( child: Text( '${episode.number}', style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), ); } Future showMy(BuildContext context, {int initialSection = 0}) async { if (embedded) { onMy?.call(); return; } session.setRouteVisible(false); await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; final port = session.active; final drama = queue.entryAt(session.currentIndex).drama; final duration = port is VideoPlayerPort ? port.controller.value.duration : Duration.zero; final action = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => MyPage( drama: drama, duration: duration, initialSection: initialSection, ), ), ); 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 port = session.active; final duration = port is VideoPlayerPort ? port.controller.value.duration : Duration.zero; final action = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => DiscoverPage( drama: pageDrama, duration: duration, focusSearch: focusSearch, ), ), ); if (!context.mounted) return; if (action == DiscoverPageAction.detail) { await openDetail(context); } else if (action == DiscoverPageAction.my || action == DiscoverPageAction.watchlist) { await showMy( context, initialSection: action == DiscoverPageAction.watchlist ? 1 : 0, ); } else { session.setRouteVisible(true); if (action == DiscoverPageAction.resume) { session.setUserWantsPlay(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 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: const Color(0xFF232328), showDragHandle: true, 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); } } }