// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/episode_stats.dart'; import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/auth_controller.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_position_cache.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; import '../../core/data/services/unlocked_episode_registry.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/centered_toast.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.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/feed_pull_refresh.dart'; import 'widgets/player_center_toggle.dart'; import 'widgets/player_loading_overlay.dart'; import 'widgets/player_heart_overlay_scope.dart'; import 'widgets/player_more_sheet.dart'; import 'widgets/player_overlay.dart'; import 'widgets/player_overlay_models.dart'; import 'widgets/player_render_stack.dart'; import 'widgets/player_speed_sheet.dart'; import 'widgets/player_unlock_sheet.dart'; import 'widgets/player_opening_frame.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; const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; /// 相邻集海报预缓存宽度:列表卡片只有百来 dp,按原图解码会占数 MB。 const int _posterPreviewWidth = 360; /// 画中画还原封面接近全屏,保留更高分辨率但不必按原图。 const int _posterPipWidth = 720; /// 预缓存用低分辨率 provider;与 RemoteImage 一样限制解码尺寸,避免整张原图进缓存。 ImageProvider _posterProvider(String url, {required int width}) { return ResizeImage(NetworkImage(url), width: width, allowUpscaling: false); } class PlayerPageHandoff { PlayerPageHandoff({required this.sourceUserWantsPlay}); final bool sourceUserWantsPlay; } 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.scrubCancelRequest = 0, this.onDiscover, this.onMy, this.onSearch, this.onMenu, this.feedDramas, this.feedChannel, this.headerChannel, this.showHomeFeedHeader = true, this.onDramaChanged, this.onImmersiveChanged, this.onOpenFullPlayer, this.onRefreshFeed, this.onLoadMore, this.onChannelSelected, this.wallet, this.downloads, this.handoff, }); final Drama drama; final int initialIndex; final bool homeMode; final bool embedded; final bool active; final int resumeRequest; final int backRequest; final int scrubCancelRequest; final VoidCallback? onDiscover; final VoidCallback? onMy; final VoidCallback? onSearch; final VoidCallback? onMenu; final List? feedDramas; final HomeFeedChannel? feedChannel; final HomeFeedChannel? headerChannel; final bool showHomeFeedHeader; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final Future Function()? onRefreshFeed; final Future Function()? onLoadMore; final ValueChanged? onChannelSelected; final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; @override State createState() => _PlayerPageState(); } class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; late PageController _pageController; late final PlaybackThumbnailService _thumbnailService; late final SpriteThumbnailAxisService _spriteAxisService; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; bool _boosting = false; bool _scrubbing = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; int? _videoReadyIndex; bool _loadingVisible = false; bool _loadingRemoved = true; bool _loadingFadeScheduled = false; bool _loggedLoadingUiVisible = false; bool _loggedStartConditions = false; final PlayerHeartOverlayController _heartOverlayController = PlayerHeartOverlayController(); final Set _precachedPosterUrls = {}; /// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。 final Map _commentCounts = {}; @override void initState() { super.initState(); final handoff = widget.handoff; final initialUserWantsPlay = handoff?.sourceUserWantsPlay ?? true; logic = PlayerBinding().create( history: context.read(), playback: context.read(), positionCache: context.read(), unlockedEpisodes: _readUnlockedEpisodes(context), wallet: widget.wallet, downloads: widget.downloads, isSignedIn: () => context.read().isSignedIn, ); logic.onEpisodeStatsUpdated = _applyEpisodeStats; logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); // 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位, // 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会 // 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。 _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), coordinator: widget.embedded ? context.read() : PlaybackCoordinator(), initialUserWantsPlay: initialUserWantsPlay, ); if (kDebugMode) { debugPrint( '[player-start] phase=initialized ' 'session=${identityHashCode(logic.session)} ' 'initialUserWantsPlay=$initialUserWantsPlay ' 'sessionUserWantsPlay=${logic.session.userWantsPlay} ' 'active=${logic.active} embedded=${widget.embedded} ' 'appResumed=${logic.session.debugAppResumed} ' 'routeVisible=${logic.session.debugRouteVisible} ' 'overlayOpen=${logic.session.debugOverlayOpen} ' 'live=${logic.session.livePlayerCount}', ); } _pageController = logic.pages; // 自动小窗:独立播放页进入即挂;沉浸流随所在 tab 的激活状态挂/摘。 unawaited(logic.syncPipAutoArm(widget.embedded ? widget.active : true)); logic.setPipPageActive(widget.embedded ? widget.active : true); if (!widget.embedded && !logic.isEpisodeLocked(logic.session.currentEpisode)) { _loadingVisible = true; _loadingRemoved = false; if (kDebugMode) { debugPrint( '[player-entry] phase=loading-shown-once ' 'index=${logic.session.currentIndex}', ); } } } @override void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); // 沉浸流所在 tab 切换时同步自动小窗(激活挂、切走摘)。 if (widget.embedded && oldWidget.active != widget.active) { unawaited(logic.syncPipAutoArm(widget.active)); logic.setPipPageActive(widget.active); } if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { _cancelFeedScrub(); } final channelChanged = oldWidget.feedChannel != widget.feedChannel; final feedChanged = _feedSignature(oldWidget.feedDramas) != _feedSignature(widget.feedDramas); if (widget.homeMode && widget.feedDramas != null && (feedChanged || channelChanged)) { final feedDramas = widget.feedDramas!; final keepDramaId = channelChanged ? null : logic.currentDramaId; final resetToFirst = channelChanged; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; logic.updateFeed( feedDramas, keepDramaId: keepDramaId, resetToFirst: resetToFirst, ); }); } logic.onPageUpdated(oldWidget); } String _feedSignature(List? dramas) => dramas == null ? '' : dramas.map((drama) => drama.id).join('|'); PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) { final localPath = widget.downloads?.localPathFor(episodeId); Episode? episode; for (final item in logic.sessionEpisodes) { if (item.id == episodeId) { episode = item; break; } } if (localPath != null && localPath.isNotEmpty) { return PlaybackThumbnailSource(localPath: localPath); } final rawUrl = episode?.videoUrl ?? ''; final active = logic.session.active; final activeSource = active is VideoPlayerPort ? active.controller.dataSource : ''; final currentEpisodeId = logic.sessionEpisodes.isEmpty ? null : logic.session.currentEpisode.id; final activeMatches = currentEpisodeId == episodeId && activeSource.isNotEmpty; if (activeMatches && activeSource.startsWith('file:')) { try { return PlaybackThumbnailSource( localPath: Uri.parse(activeSource).toFilePath(), ); } catch (_) { return null; } } if (rawUrl.startsWith('file:')) { try { return PlaybackThumbnailSource( localPath: Uri.parse(rawUrl).toFilePath(), ); } catch (_) { return null; } } final remoteUrl = AppConfig.mediaUrl( rawUrl.isNotEmpty ? rawUrl : activeMatches ? activeSource : '', ); if (remoteUrl.isEmpty) return null; return PlaybackThumbnailSource(remoteUrl: remoteUrl); } @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() { if (_presentationActive) return; logic.onRouteCovered(); } @override void didPopNext() { if (_presentationActive) return; logic.onRouteRevealed(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); logic.setPipPageActive(false); _heartOverlayController.dispose(); _spriteAxisService ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); } void _updatePlaybackSpeed(double value) { setState(() => _playbackSpeed = value); } /// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider, /// 返回 null 让 PlayerLogic 退化成页面自用的一份。 UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) { try { return context.read(); } on ProviderNotFoundException { return null; } } void _hideChrome() { if (!_chromeVisible) return; 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)); } bool _handlePageScroll(ScrollNotification notification) { if (notification is ScrollStartNotification && notification.dragDetails != null) { _swipeStartedAt = DateTime.now(); _swipePrewarmCaptured = false; if (!widget.embedded && !_loadingRemoved) { setState(() { _loadingVisible = false; _loadingRemoved = true; }); if (kDebugMode) { debugPrint('[player-entry] phase=loading-dismissed-on-swipe'); } } } else if (notification is ScrollUpdateNotification && !_swipePrewarmCaptured && notification.dragDetails != null) { final delta = notification.scrollDelta ?? 0; if (delta != 0) { _swipePrewarmCaptured = true; logic.session.prewarmNeighbors(forward: delta > 0); } } else if (notification is ScrollEndNotification) { _swipePrewarmCaptured = false; } return false; } void _logSwipeReveal() { if (!kDebugMode) return; final startedAt = _swipeStartedAt; if (startedAt == null) return; _swipeStartedAt = null; final elapsed = DateTime.now().difference(startedAt).inMilliseconds; debugPrint('[player] swipe reveal ${elapsed}ms'); } Future _refreshHomeFeed() async { final refresh = widget.onRefreshFeed; if (refresh == null) return; final ok = await refresh(); if (!mounted || ok) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)), ); } Future _selectPage(int index) async { await logic.requestEpisodeSelection(index); if (!mounted || logic.session.currentIndex != index) return; await logic.session.preloadSettled; if (!mounted || logic.session.currentIndex != index) return; _logResourceWatermark(); } void _logResourceWatermark() { if (!kDebugMode) return; final imageCache = PaintingBinding.instance.imageCache; debugPrint( '[player-resources] ' 'slots=${logic.session.livePlayerCount} ' 'warmNext=${logic.session.preloadedIndex ?? '-'} ' 'warmPrevious=${logic.session.previousPreloadedIndex ?? '-'} ' 'sprites=${_spriteAxisService.spriteCacheSize} ' 'imageCacheBytes=${imageCache.currentSizeBytes} ' 'imageCacheCount=${imageCache.currentSize}', ); } void _setScrubbing(bool value) { if (!mounted) return; if (_scrubbing == value) return; setState(() => _scrubbing = value); } void _finishFeedScrub(bool wasVisible) { if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = wasVisible; }); } void _cancelFeedScrub() { logic.cancelScrub(); _scrubbing = false; _chromeVisible = true; } void _handleSpriteFrameChanged() { if (mounted) setState(() {}); } void _prewarmAdjacentFrames() { final current = logic.session.currentIndex; final indices = [ current, logic.previousVisibleIndex(current), logic.nextVisibleIndex(current), ]; for (final index in indices) { if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { continue; } final episode = logic.sessionEpisodes[index]; if (logic.isEpisodeLocked(episode)) continue; final posterUrl = episode.posterUrl?.trim() ?? ''; if (posterUrl.isNotEmpty) { final imageUrl = AppConfig.mediaUrl(posterUrl); if (_precachedPosterUrls.add(imageUrl)) { unawaited( precacheImage( _posterProvider(imageUrl, width: _posterPreviewWidth), context, ).catchError((Object _) { _precachedPosterUrls.remove(imageUrl); }), ); } continue; } final thumbnailsUrl = episode.thumbnailsUrl; if (thumbnailsUrl != null && thumbnailsUrl.isNotEmpty) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } } } void _restoreFeedChrome() { if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = true; }); } void _handleFeedSurfaceTap() { if (_scrubbing || _doubleTapGuardActive) return; if (!_chromeVisible) { _restoreFeedChrome(); return; } logic.session.setUserWantsPlay(!logic.session.userWantsPlay); } void _togglePlayback() { if (_doubleTapGuardActive) return; logic.session.setUserWantsPlay(!logic.session.userWantsPlay); } bool get _doubleTapGuardActive { final lastDoubleTapAt = _lastDoubleTapAt; return lastDoubleTapAt != null && DateTime.now().difference(lastDoubleTapAt) < const Duration(milliseconds: 500); } void _handleDoubleTapLike( BuildContext context, InteractionController interactions, Episode episode, ) { _lastDoubleTapAt = DateTime.now(); if (kDebugMode) { debugPrint( '[player-like] phase=double-tap-callback ' 'episodeId=${episode.id} ' 'busy=${interactions.isEpisodeBusy(episode.id)} ' 'liked=${interactions.isEpisodeLiked(episode.id)}', ); } unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); } void _refreshLikeState() { if (!mounted) return; setState(() {}); } void _applyEpisodeStats(EpisodeStats stats) { if (!mounted) return; context.read().applyEpisodeStats( stats.episodeId, liked: stats.liked, likeCount: stats.likeCount, ); if (stats.dramaId.isNotEmpty) { context.read().applyServerState( stats.dramaId, saved: stats.favorited, favoriteCount: stats.favoriteCount, ); } setState(() { _commentCounts[stats.episodeId] = stats.commentCount; }); } Future _openComments( BuildContext context, Drama drama, Episode episode, ) async { final comments = context.read(); await openComments( context, drama, show: (context, drama) => logic.showComments(context, drama, episodeId: episode.id), ); try { final count = await comments.count(drama.id, episodeId: episode.id); if (!mounted) return; logic.applyEpisodeCommentCount(episode.id, count); setState(() => _commentCounts[episode.id] = count); } catch (_) { // 评论面板已经关闭,计数刷新失败时保留旧值即可。 } } @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; if (indices.isEmpty) { return const ColoredBox( color: AppColors.mediaBlack, child: SizedBox.expand(), ); } if (kDebugMode && !_loggedStartConditions) { _loggedStartConditions = true; debugPrint( '[player-start] phase=first-build ' 'session=${identityHashCode(logic.session)} ' 'tickerMode=${TickerMode.valuesOf(context).enabled} ' 'routeVisible=${logic.session.debugRouteVisible} ' 'appResumed=${logic.session.debugAppResumed} ' 'overlayOpen=${logic.session.debugOverlayOpen} ' 'visible=${logic.session.debugVisible} ' 'mayPlay=${logic.session.debugMayPlay}', ); } final activePort = logic.session.active; final activeVideo = activePort is VideoPlayerPort ? activePort : null; final canWaitForVideoFrame = activeVideo != null && activeVideo.controller.value.isInitialized && !activeVideo.controller.value.hasError; final entryUnavailable = logic.ticketError != null || logic.blockedEpisodeId != null || logic.session.error != null || logic.isEpisodeLocked(logic.session.currentEpisode); if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && entryUnavailable && !canWaitForVideoFrame) { _scheduleLoadingFade('entry-unavailable'); } if (!widget.embedded && !_loadingRemoved && !_loadingFadeScheduled && activePort != null && activePort is! VideoPlayerPort) { _scheduleLoadingFade('non-video-player-ready'); } final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { _prewarmAdjacentFrames(); final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( controller: _pageController, scrollDirection: Axis.vertical, allowImplicitScrolling: true, physics: widget.onRefreshFeed == null ? null : const FeedRefreshPhysics(), itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; if (kDebugMode) { debugPrint( '[player-page] ' 'ts=${DateTime.now().millisecondsSinceEpoch} ' 'phase=session-index ' 'previous=${logic.session.currentIndex} next=$index', ); } widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(_selectPage(index)); if (position >= indices.length - 3) { final loadMore = widget.onLoadMore; if (loadMore != null) unawaited(loadMore()); } }, itemBuilder: (context, position) { final index = indices[position]; if (kDebugMode) { debugPrint( '[player-page] ' 'ts=${DateTime.now().millisecondsSinceEpoch} ' 'phase=item-build page=$position index=$index', ); } return _buildEpisode(context, index, interactions); }, ), ); final pageLayer = widget.onRefreshFeed == null ? pages : FeedPullRefresh( pageController: _pageController, topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, onRefresh: _refreshHomeFeed, child: pages, ); // 进画中画时预加载当前集海报:还原过渡封面立即可显示,避免再黑一次。 if (logic.pip) { final poster = logic.trackedEpisode?.posterUrl?.trim(); if (poster != null && poster.isNotEmpty) { unawaited( precacheImage( _posterProvider( AppConfig.mediaUrl(poster), width: _posterPipWidth, ), context, ), ); } } return Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, resumeSignal: logic.pipResumeToken, ), if (logic.pipRecovering) Positioned.fill( key: const Key('player-pip-recover-cover'), child: _PipRecoverCover(episode: logic.trackedEpisode), ), if (!widget.embedded && !_loadingRemoved) Center( child: IgnorePointer( key: const Key('player-entry-loading-overlay'), child: PlayerLoadingOverlay( opacity: _loadingVisible ? 1 : 0, onFadeEnd: _handleLoadingFadeEnd, ), ), ), pageLayer, ], ); }, ); final body = Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ content, if (widget.homeMode && widget.showHomeFeedHeader && !_scrubbing && !_boosting && !logic.pip && !logic.pipPreparing) Positioned( top: 0, left: 0, right: 0, child: SafeArea( bottom: false, child: HomeFeedHeader( l10n: l10n, channel: widget.headerChannel ?? widget.feedChannel ?? HomeFeedChannel.recommend, onChannelSelected: widget.onChannelSelected ?? (_) {}, 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, ), ), ), ], ); final scopedBody = PlayerHeartOverlayScope( controller: _heartOverlayController, child: body, ); final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: AppColors.transparent, systemNavigationBarColor: AppColors.mediaBlack, systemNavigationBarDividerColor: AppColors.transparent, systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: Theme( data: AppTheme.dark(), child: widget.embedded ? scopedBody : Scaffold( key: const Key('player-scaffold'), backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: scopedBody, ), ), ); if (widget.handoff == null) return player; return PopScope( canPop: _leavingHandoff, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_leaveHandoff(context)); }, child: player, ); } void _handleCurrentVideoVisible(int? index) { if (!mounted || _loadingRemoved) return; // 收起还原封面要放在 early-return 之前:恢复时集未变, // _videoReadyIndex 已相等,只有这里能接到淡入完成的回调。 logic.endPipRecover(); final currentIndex = logic.session.currentIndex; if (index == null || index != currentIndex || _videoReadyIndex == currentIndex) { return; } _videoReadyIndex = currentIndex; if (kDebugMode) { debugPrint( '[player-entry] phase=video-first-frame ' 'session=${identityHashCode(logic.session)}', ); } WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _loadingRemoved || index != logic.session.currentIndex) { return; } if (kDebugMode) { debugPrint('[player-entry] phase=video-frame-painted index=$index'); } _startLoadingFade(currentIndex); }); }); } void _scheduleLoadingFade(String reason) { WidgetsBinding.instance.addPostFrameCallback((_) { _startLoadingFade(logic.session.currentIndex, reason: reason); }); } void _startLoadingFade(int index, {String reason = 'video-first-frame'}) { if (!mounted || widget.embedded || index != logic.session.currentIndex || !_loadingVisible || _loadingRemoved || _loadingFadeScheduled) { return; } _loadingFadeScheduled = true; if (kDebugMode) { debugPrint('[player-entry] phase=loading-fade-start reason=$reason'); } setState(() => _loadingVisible = false); } void _handleLoadingFadeEnd() { if (!mounted || _loadingVisible || _loadingRemoved) return; setState(() => _loadingRemoved = true); if (kDebugMode) { debugPrint('[player-entry] phase=loading-removed'); } } void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); } Episode _resolvedEpisode(int index) { final entry = logic.queue.entryAt(index); final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length ? logic.sessionEpisodes[index] : entry.episode; final displayed = widget.homeMode ? logic.displayEpisode(entry.drama, sessionEpisode) : sessionEpisode; final needsSessionThumbnails = (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && sessionEpisode.thumbnailsUrl != null; final needsSessionPoster = (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && sessionEpisode.posterUrl != null; if (!needsSessionThumbnails && !needsSessionPoster) return displayed; return displayed.withVideoUrl( displayed.videoUrl, thumbnailsUrl: sessionEpisode.thumbnailsUrl, posterUrl: sessionEpisode.posterUrl, ); } Map _renderSlots( List indices, ) { final current = logic.session.currentIndex; final previous = logic.previousVisibleIndex(current) ?? logic.session.previousPreloadedIndex; final next = logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex; return { PlayerRenderSlotRole.previous: _renderSlot( PlayerRenderSlotRole.previous, previous, indices, currentIndex: current, ), PlayerRenderSlotRole.current: _renderSlot( PlayerRenderSlotRole.current, current, indices, currentIndex: current, ), PlayerRenderSlotRole.next: _renderSlot( PlayerRenderSlotRole.next, next, indices, currentIndex: current, ), }; } PlayerRenderSlotData _renderSlot( PlayerRenderSlotRole role, int? index, List indices, { required int currentIndex, }) { if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { return PlayerRenderSlotData.empty(role); } final episode = _resolvedEpisode(index); final selected = index == currentIndex; final locked = logic.isEpisodeBlockedFor(episode, index); final port = locked ? null : (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final handoff = selected && !locked ? logic.session.handoffAt(index) : null; final handoffPort = handoff is VideoPlayerPort ? handoff : null; final videoReady = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = !locked && videoReady && (selected ? logic.session.reusedWarmAt(index) : logic.session.preparedPlayerAt(index) != null); final thumbnailsUrl = episode.thumbnailsUrl; final posterUrl = episode.posterUrl; final posterReady = posterUrl?.trim().isNotEmpty ?? false; final thumbnailReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; // 只在没命中时预取:prepareAt 会触发下载解码并 notifyListeners -> 重建, // 无条件调用会让每次重建都排一次异步任务。 if (!locked && !posterReady && !thumbnailReady && thumbnailsUrl != null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } if (selected && !locked && videoPort != null && videoPort.controller.value.isInitialized) { final position = videoPort.controller.value.position; if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position)); } } final pagePosition = indices.indexOf(index); return PlayerRenderSlotData( role: role, index: index, pagePosition: pagePosition >= 0 ? pagePosition.toDouble() : null, controller: locked ? null : videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, warmFrameAvailable: preparedFrameAvailable, thumbnailReady: thumbnailReady, posterReady: posterReady, fallback: handoffPort != null ? PlayerOpeningFallback.handoff : playerOpeningFallback( posterUrl: posterUrl, thumbnailsUrl: thumbnailsUrl, ), warmSlotReused: logic.session.reusedWarmAt(index), waitForPlaybackFrame: logic.session.userWantsPlay, placeholder: locked ? null : handoffPort != null ? ControllerFramePlaceholder( controller: handoffPort.controller, theater: theater, ) : playerOpeningFrame( thumbnailsUrl, posterUrl: posterUrl, spriteSource: _spriteAxisService, fit: videoFit, ), ); } Widget _buildEpisode( BuildContext context, int index, InteractionController interactions, ) { final l10n = AppLocalizations.of(context)!; final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeBlockedFor(episode, index); // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. final port = locked ? null : (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; if (selected && videoReady) _logSwipeReveal(); final ready = !locked && (videoReady || port != null); final theater = logic.pip || logic.pipPreparing; final entryLoading = !widget.embedded && !_loadingRemoved; if (kDebugMode && entryLoading && selected && !_loggedLoadingUiVisible) { _loggedLoadingUiVisible = true; debugPrint( '[player-entry] phase=loading-ui-visible ' 'index=$index top=1 rail=1 progress=1', ); } if (widget.homeMode && !_presentationActive && !theater) { _logRailLikeState('home', episode.id, interactions); } return Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ if (selected && !entryLoading && !locked && !theater && !ready && !logic.ticketLoading && logic.ticketError == null && logic.session.error == null && logic.blockedEpisodeId != episode.id && !logic.session.isLocked) const Center( child: SizedBox.square( dimension: _playerLoadingSize, child: CircularProgressIndicator( key: Key('player-video-loading'), color: AppColors.blue, strokeWidth: _playerLoadingStrokeWidth, ), ), ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && !entryLoading && !theater && logic.ticketLoading) const Center( child: SizedBox.square( dimension: _playerLoadingSize, child: CircularProgressIndicator( key: Key('player-ticket-loading'), color: AppColors.blue, strokeWidth: _playerLoadingStrokeWidth, ), ), ), // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 if (selected && !theater && !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: AppColors.muted, size: 36, ), const SizedBox(height: 12), Text( errorText(l10n, logic.ticketError, l10n.playbackError), textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted), ), const SizedBox(height: 12), FilledButton( onPressed: () => unawaited(logic.retryTicket()), child: Text(l10n.retry), ), ], ), ), ), if (selected && !theater && !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: AppColors.muted, size: 36, ), const SizedBox(height: 12), Text( l10n.playbackError, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted), ), ], ), ), ), // 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程) if (selected && !theater && !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: AppColors.muted, size: 36, ), const SizedBox(height: 12), Text( episode.locked ? l10n.lockedTitle(episode.number) : l10n.noMediaNotice, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted), ), ], ), ), ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode && !_presentationActive) HomeFeedOverlay( l10n: l10n, drama: drama, episode: episode, controller: videoReady ? videoPort!.controller : null, locked: locked, visible: _chromeVisible, boosting: _boosting, onBoostStart: _startBoost, onBoostEnd: _endBoost, onTogglePlay: _handleFeedSurfaceTap, liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, favoriteCount: favorites.favoriteCountFor(drama), commentCount: logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, saved: favorites.isSaved(drama.id), onLike: () async { await _toggleLike(context, interactions, episode); }, onDoubleTapLike: (_) => _handleDoubleTapLike(context, interactions, episode), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), onOpenFullPlayer: () { unawaited(_presentFullPlayer(context, index)); }, onEpisodes: () => logic.showEpisodes(context), onOpenSearch: widget.onSearch, onMore: _openCurrentMore, onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null, onScrubEnd: _finishFeedScrub, onScrubVisibilityChanged: _setScrubbing, cancelScrubRequest: widget.scrubCancelRequest, onSeek: videoReady ? (position) => logic.endScrub(context, videoPort!, position) : null, thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, suppressBufferLoading: !widget.embedded && !_loadingRemoved, ) else if (!widget.homeMode) _standardPlaybackOverlay(context, index, interactions), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 if (selected && !theater && locked) 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 && !_scrubbing) Positioned( right: 12, // 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。 bottom: 24, child: ExitClearScreenButton( key: const Key('home-feed-exit-clear-screen'), tooltip: l10n.exitClearScreen, onPressed: _restoreFeedChrome, ), ), if (widget.homeMode && !_scrubbing && _chromeVisible && !logic.chromeHidden && selected && !logic.session.userWantsPlay) Center( child: PlayerCenterToggle( key: const Key('home-feed-center-play'), playing: false, onTap: _togglePlayback, ), ), ], ); } Widget _standardPlaybackOverlay( BuildContext context, int index, InteractionController interactions, ) { if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); final l10n = AppLocalizations.of(context)!; final favorites = context.watch(); final entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeBlockedFor(episode, index); final port = locked ? null : (selected ? logic.session.active : null) ?? logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final videoReady = videoPort?.controller.value.isInitialized ?? false; _logRailLikeState('player', episode.id, interactions); return PlayerPlaybackOverlay( data: PlayerOverlayData( l10n: l10n, drama: drama, episode: episode, controller: videoReady ? videoPort!.controller : null, locked: locked, liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, commentCount: logic.episodeStats(episode.id)?.commentCount ?? _commentCounts[episode.id] ?? episode.commentCount, favoriteCount: favorites.favoriteCountFor(drama), saved: favorites.isSaved(drama.id), playing: logic.session.userWantsPlay, playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, boosting: _boosting, thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, ), scrubbing: _scrubbing, actions: PlayerOverlayActions( onBack: widget.handoff != null ? () => unawaited(_leaveHandoff(context)) : widget.embedded ? null : () => unawaited(logic.leave(context)), onTogglePlay: _togglePlayback, onDoubleTapLike: locked ? null : (_) => _handleDoubleTapLike(context, interactions, episode), onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), onDetail: () => unawaited(logic.openDetail(context)), onSpeed: () => unawaited(_openSpeedSheet(context)), onEpisodes: () => logic.showEpisodes(context), onComments: () => unawaited(_openComments(context, drama, episode)), onLike: () => unawaited(_toggleLike(context, interactions, episode)), onSave: () => unawaited(_toggleSave(context, drama)), onShare: () => logic.shareDrama(context, drama), onMore: () => _openMore(context, episode), onScrubStart: videoReady ? () { _setScrubbing(true); logic.beginScrub(videoPort!); } : null, onScrubEnd: () => _setScrubbing(false), onSeek: videoReady ? (position) => logic.seekTo(context, videoPort!, position) : null, onLongPressCenter: () => _openMore(context, episode), onBoostStart: _startBoost, onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, suppressProgressLoading: !widget.embedded && !_loadingRemoved, ); } void _logRailLikeState( String surface, String episodeId, InteractionController interactions, ) { if (!kDebugMode) return; debugPrint( '[player-like] phase=rail-state surface=$surface ' 'episodeId=$episodeId ' 'liked=${interactions.isEpisodeLiked(episodeId)} ' 'count=${interactions.episodeLikeCount(episodeId)}', ); } Future _presentFullPlayer(BuildContext context, int index) async { if (_presentationActive || _preparingFullPlayer || !_pageController.hasClients) { return; } _preparingFullPlayer = true; final sourceIndex = logic.session.currentIndex; final sourceEpisode = logic.session.currentEpisode; final sourceQueue = logic.queue; var seriesDrama = sourceQueue.entryAt(sourceIndex).drama; try { seriesDrama = await context.read().requireDetail( seriesDrama, ); } catch (_) { // 详情不可用时仍使用当前剧已知的集列表。 } _preparingFullPlayer = false; if (!mounted || !context.mounted || logic.session.currentIndex != sourceIndex || logic.session.currentEpisode.id != sourceEpisode.id) { return; } final seriesEpisodes = List.of(seriesDrama.episodes); var episodeIndex = seriesEpisodes.indexWhere( (episode) => episode.id == sourceEpisode.id, ); if (episodeIndex < 0) { episodeIndex = seriesEpisodes.indexWhere( (episode) => episode.number == sourceEpisode.number, ); } if (episodeIndex < 0) { seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } final handoff = PlayerPageHandoff(sourceUserWantsPlay: true); _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( PageRouteBuilder( opaque: true, barrierDismissible: false, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, ), ), ); if (kDebugMode) { debugPrint('[player-entry] phase=route-pushed source=full-player ms=0'); } unawaited(logic.pauseForFullPlayer()); unawaited(logic.saveCurrentProgress()); await route; if (!mounted) return; logic.attach(widget, isMounted: () => mounted); setState(() => _presentationActive = false); await logic.resumeAfterFullPlayer(); if (!mounted) return; _logFullPlayerRoute('restored'); } Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); await logic.session.pauseForRouteHandoff(); if (context.mounted) Navigator.of(context).pop(); } void _logFullPlayerRoute(String phase) { if (!kDebugMode) return; final active = logic.session.active; final video = active is VideoPlayerPort ? active : null; debugPrint( '[player-route] ts=${DateTime.now().millisecondsSinceEpoch} ' 'phase=$phase ' 'episode=${logic.session.currentEpisode.id} ' 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' 'position=${video?.controller.value.position.inMilliseconds ?? '-'}', ); } } /// 画中画还原过渡封面:盖住回前台瞬间的黑纹理帧,视频出帧后由 /// [PlayerLogic.endPipRecover] 收起。背景用主题渐变,海报预加载命中前也有过渡观感。 class _PipRecoverCover extends StatelessWidget { const _PipRecoverCover({required this.episode}); final Episode? episode; @override Widget build(BuildContext context) { final poster = episode?.posterUrl?.trim(); final thumbnail = episode?.thumbnailsUrl?.trim(); final url = poster != null && poster.isNotEmpty ? poster : (thumbnail != null && thumbnail.isNotEmpty ? thumbnail : null); return DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.followingGlow, AppColors.followingGlowMid, Color(0xFF101012), ], stops: [0, 0.22, 0.62], ), ), child: url == null ? const SizedBox.shrink() : Image.network( url, key: const Key('player-pip-recover-cover-image'), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ); } }