// 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/repositories/comment_repository.dart'; import '../../core/data/services/catalog_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/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_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; 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; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; final Map _commentCounts = {}; @override void initState() { super.initState(); final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), playback: context.read(), positionCache: context.read(), wallet: widget.wallet, downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), coordinator: widget.embedded ? context.read() : PlaybackCoordinator(), initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; 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); 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); _spriteAxisService ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); } void _updatePlaybackSpeed(double value) { setState(() => _playbackSpeed = value); } 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(NetworkImage(imageUrl), 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(); unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); } /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { _favoriteDelta[dramaId] = (_favoriteDelta[dramaId] ?? 0) + (nowSaved ? 1 : -1); }); } Future _openComments( BuildContext context, Drama drama, Episode episode, ) async { final comments = context.read(); await openCommentsWithAuth( 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; 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; 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, ); return Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), onCurrentVideoVisible: _handleCurrentVideoVisible, ), 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 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 ? body : Scaffold( key: const Key('player-scaffold'), backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: body, ), ), ); 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; 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.isEpisodeLocked(episode) || (selected && logic.session.isLocked) || logic.blockedEpisodeId == episode.id; 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; if (!locked && !posterReady && thumbnailsUrl != null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } if (selected && !locked && videoPort != null && videoPort.controller.value.isInitialized) { unawaited( _spriteAxisService.prepareAt( thumbnailsUrl, videoPort.controller.value.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 entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeLocked(episode) || logic.session.isLocked || logic.blockedEpisodeId == episode.id; // 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', ); } 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: drama.favoriteCount == null ? null : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), commentCount: _commentCounts[episode.id] ?? episode.commentCount, saved: context.watch().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), 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, ) 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, bottom: 56, 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 entry = logic.queue.entryAt(index); final drama = entry.drama; final episode = _resolvedEpisode(index); final selected = index == logic.session.currentIndex; final locked = logic.isEpisodeLocked(episode) || logic.session.isLocked || logic.blockedEpisodeId == episode.id; 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; 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: _commentCounts[episode.id] ?? episode.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, 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, ); } 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 sourceUserWantsPlay = logic.session.userWantsPlay; final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); _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 ?? '-'}', ); } }