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/player_port.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 '../../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.pictureInPicture}); final WatchHistoryRepository history; 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); Future pendingWrite = Future.value(); 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; 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, }) { pictureInPicture.setStateListener(onPipChanged); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) : PlaybackQueue.home(feedDramas!); visibleIndices = List.generate(queue.length, (index) => index); pages = PageController(initialPage: initialIndex); session = PlaybackSession( episodes: [for (final entry in queue.entries) entry.episode], factory: factory, // 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定 canPlay: (episode) => episode.videoUrl.isNotEmpty, prepare: preparePlayer, activate: activatePlayer, coordinator: coordinator, ); if (!active || visibleIndices.isEmpty) { session.setRouteVisible(false); } unawaited(selectEpisode(initialIndex)); } 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()); 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()); 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; final drama = queue.entries .firstWhere((e) => e.episode.id == episode.id) .drama; try { await pendingWrite; final saved = await history.positionFor( demoProfileId, drama.id, episode.id, ); if (saved != null) { await player.controller.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } } catch (_) { // A local preference failure does not prevent playback. } } 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), ); } if (!completionHandled && player.controller.value.isCompleted && session.canAutoAdvance && session.currentIndex == index) { completionHandled = true; unawaited(advanceAfterEnd(player, index)); } final now = DateTime.now(); if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) { return; } lastProgressWrite = now; 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. } } Future queueProgress(Drama drama, Episode episode, Duration position) { pendingWrite = pendingWrite .then( (_) => history.save(demoProfileId, drama.id, episode.id, position), ) .catchError((Object _) { // Playback continues if local history cannot be stored. }); 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); } 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; 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 ? const Color(0xFF2F7BFF) : 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); } } }