// 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/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.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/error_text.dart'; import '../../core/navigation/app_route_observer.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_header.dart'; import 'widgets/home_feed_overlay.dart'; import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; import 'widgets/player_video_surface.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; part 'player_view_actions.dart'; const double _boostPlaybackSpeed = 2.0; class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, required this.drama, required this.initialIndex, this.homeMode = false, this.embedded = false, this.active = true, this.resumeRequest = 0, this.backRequest = 0, this.onDiscover, this.onMy, this.onSearch, this.onMenu, this.feedDramas, this.onDramaChanged, this.onImmersiveChanged, this.onOpenFullPlayer, this.onRefreshFeed, this.wallet, this.downloads, }); final Drama drama; final int initialIndex; final bool homeMode; final bool embedded; final bool active; final int resumeRequest; final int backRequest; final VoidCallback? onDiscover; final VoidCallback? onMy; final VoidCallback? onSearch; final VoidCallback? onMenu; final List? feedDramas; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final Future Function()? onRefreshFeed; final WalletController? wallet; final DownloadController? downloads; @override State createState() => _PlayerPageState(); } class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; bool _boosting = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @override void initState() { super.initState(); logic = PlayerBinding().create( history: context.read(), playback: context.read(), wallet: widget.wallet, downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), coordinator: context.read(), ); } @override void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); logic.onPageUpdated(oldWidget); } @override void didChangeDependencies() { super.didChangeDependencies(); final route = ModalRoute.of(context); if (route is! PageRoute || route == _subscribedRoute) return; if (_subscribedRoute != null) appRouteObserver.unsubscribe(this); _subscribedRoute = route; appRouteObserver.subscribe(this, route); } @override void didChangeAppLifecycleState(AppLifecycleState state) { logic.onLifecycle(state); } @override void didPushNext() { logic.onRouteCovered(); } @override void didPopNext() { logic.onRouteRevealed(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); logic.dispose(); super.dispose(); } void _updatePlaybackSpeed(double value) { setState(() => _playbackSpeed = value); } void _hideChrome() { setState(() => _chromeVisible = false); } /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 void _startBoost() { if (_boosting) return; // 暂停时长按两侧先恢复播放,再进入 2 倍速。 if (!logic.session.userWantsPlay) logic.session.setUserWantsPlay(true); setState(() { _boosting = true; _speedBeforeBoost = _playbackSpeed; _chromeBeforeBoost = _chromeVisible; _playbackSpeed = _boostPlaybackSpeed; _chromeVisible = false; }); unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); } /// 松手或手势取消:恢复长按前的倍速与清屏状态。 void _endBoost() { if (!_boosting) return; final speed = _speedBeforeBoost ?? _playbackSpeed; final chrome = _chromeBeforeBoost ?? true; setState(() { _boosting = false; _speedBeforeBoost = null; _chromeBeforeBoost = null; _playbackSpeed = speed; _chromeVisible = chrome; }); unawaited(logic.setPlaybackSpeed(speed)); } /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { _favoriteDelta[dramaId] = (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); }); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: logic, builder: (context, _) => _buildPlayer(context), ); } Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( animation: logic.session, builder: (context, _) => PageView.builder( controller: pages, scrollDirection: Axis.vertical, allowImplicitScrolling: true, itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(logic.selectEpisode(index)); }, itemBuilder: (context, position) => _buildEpisode(context, indices[position], interactions), ), ); final body = Stack( fit: StackFit.expand, children: [ content, if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing) Positioned( top: 0, left: 0, right: 0, child: SafeArea( bottom: false, child: HomeFeedHeader( l10n: l10n, onRefresh: () async { final ok = await widget.onRefreshFeed?.call() ?? true; if (context.mounted && !ok) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.catalogError))); } return ok; }, onSearch: widget.onSearch ?? () => logic.showDiscover(context), onMore: _openCurrentMore, ), ), ), ], ); return AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: Colors.transparent, systemNavigationBarColor: Colors.black, systemNavigationBarDividerColor: Colors.transparent, systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: Theme( data: AppTheme.light(), child: widget.embedded ? body : Scaffold( key: const Key('player-scaffold'), backgroundColor: Colors.transparent, body: body, ), ), ); } void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); } Widget _buildEpisode( BuildContext context, int index, InteractionController interactions, ) { final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = widget.homeMode ? logic.displayEpisode(drama, entry.episode) : entry.episode; final selected = index == logic.session.currentIndex; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. final port = (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; return Stack( fit: StackFit.expand, children: [ if (widget.homeMode) const ColoredBox( key: Key('home-feed-black-background'), color: Colors.black, ), PlayerVideoSurface( controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( child: GestureDetector( key: const Key('home-feed-video-toggle'), behavior: HitTestBehavior.opaque, onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( child: CircularProgressIndicator( key: Key('player-ticket-loading'), color: AppColors.blue, ), ), // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 if (selected && !logic.ticketLoading && logic.ticketError != null) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.wifi_off_rounded, color: Colors.white70, size: 36, ), const SizedBox(height: 12), Text( errorText(l10n, logic.ticketError, l10n.playbackError), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), const SizedBox(height: 12), FilledButton( onPressed: () => unawaited(logic.retryTicket()), child: Text(l10n.retry), ), ], ), ), ), if (selected && logic.session.loading) const Center( child: CircularProgressIndicator( key: Key('player-session-loading'), color: AppColors.blue, ), ), if (selected && !ready && logic.session.error != null) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.error_outline_rounded, color: Colors.white70, size: 36, ), const SizedBox(height: 12), Text( l10n.playbackError, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), ], ), ), ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && !ready && logic.blockedEpisodeId != episode.id && logic.session.error == null && logic.session.isLocked) Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.movie_filter_outlined, color: Colors.white70, size: 36, ), const SizedBox(height: 12), Text( episode.locked ? l10n.lockedTitle(episode.number) : l10n.noMediaNotice, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70), ), ], ), ), ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode) HomeFeedOverlay( l10n: l10n, drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, visible: _chromeVisible, boosting: _boosting, onBoostStart: _startBoost, onBoostEnd: _endBoost, onTogglePlay: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), commentCount: drama.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { if (await ensureSignedIn(context)) { if (!context.mounted) return; unawaited(context.read().lightImpact()); final result = await interactions.toggleDramaLike(drama.id); if (!result.isSuccess && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, ), ), ); } } }, onSave: () async { final favorites = context.read(); if (await ensureSignedIn(context)) { if (!context.mounted) return; unawaited(context.read().lightImpact()); final ok = await favorites.toggle(drama.id, card: drama); if (ok || !context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.favoriteToggleFailed)), ); } }, onComments: () => unawaited( openCommentsWithAuth(context, drama, show: logic.showComments), ), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () async { await widget.onOpenFullPlayer?.call(drama, episode); if (!mounted || !widget.active) return; logic.session.setUserWantsPlay(true); }, onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, ) else PlayerPlaybackOverlay( data: PlayerOverlayData( l10n: l10n, drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), likeCount: interactions.likeCount(drama.id) ?? drama.likeCount, commentCount: drama.commentCount, favoriteCount: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), saved: context.watch().isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, boosting: _boosting, ), actions: PlayerOverlayActions( onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), onTogglePlay: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), onComments: () => unawaited( openCommentsWithAuth(context, drama, show: logic.showComments), ), onLike: () => unawaited(_toggleLike(context, interactions, drama)), onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, episode), onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, onLongPressCenter: () => _openMore(context, episode), onBoostStart: _startBoost, onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, ), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 if (selected && !ready && logic.blockedEpisodeId == episode.id) PlayerBlockedNotice( l10n: l10n, episode: episode, accessType: logic.blockedTicket?.accessType, unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, unlocking: logic.unlockingEpisodeId == episode.id, error: logic.unlockError, onOpenUnlock: logic.wallet == null ? null : () => _openUnlockSheet(context, index, episode), ), if (widget.homeMode && !_chromeVisible && !_boosting) Positioned( right: 12, bottom: 56, child: ExitClearScreenButton( key: const Key('home-feed-exit-clear-screen'), tooltip: l10n.exitClearScreen, onPressed: () => setState(() => _chromeVisible = true), ), ), if (widget.homeMode && !logic.chromeHidden && selected && !logic.session.userWantsPlay) Center( child: PlayerCenterToggle( key: const Key('home-feed-center-play'), playing: false, onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), ], ); } }