Files
ReelShort/lib/pages/player/player_view.dart
T

1253 lines
42 KiB
Dart

// 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_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,
this.revealAfterFirstFrame = false,
});
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<Drama>? feedDramas;
final HomeFeedChannel? feedChannel;
final HomeFeedChannel? headerChannel;
final bool showHomeFeedHeader;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
final Future<bool> Function()? onRefreshFeed;
final Future<void> Function()? onLoadMore;
final ValueChanged<HomeFeedChannel>? onChannelSelected;
final WalletController? wallet;
final DownloadController? downloads;
final PlayerPageHandoff? handoff;
final bool revealAfterFirstFrame;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage>
with WidgetsBindingObserver, RouteAware {
late final PlayerLogic logic;
late PageController _pageController;
late final PlaybackThumbnailService _thumbnailService;
late final SpriteThumbnailAxisService _spriteAxisService;
PageRoute<dynamic>? _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;
late bool _firstFrameReady;
bool _leavingHandoff = false;
bool _preparingFullPlayer = false;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
final Map<String, int> _commentCounts = {};
@override
void initState() {
super.initState();
_firstFrameReady = !widget.revealAfterFirstFrame;
final handoff = widget.handoff;
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(),
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<PlayerFactory>(),
coordinator: widget.embedded
? context.read<PlaybackCoordinator>()
: PlaybackCoordinator(),
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
);
_pageController = logic.pages;
if (kDebugMode && widget.revealAfterFirstFrame) {
debugPrint(
'[player-entry] phase=waiting-first-frame '
'session=${identityHashCode(logic.session)}',
);
}
}
@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<Drama>? 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<dynamic> || 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;
} 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<void> _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<void> _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 = <int?>[
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<void> _openComments(
BuildContext context,
Drama drama,
Episode episode,
) async {
final comments = context.read<CommentRepository>();
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<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) {
_prewarmAdjacentFrames();
final pages = NotificationListener<ScrollNotification>(
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,
children: [
PlayerRenderStack(
pageController: _pageController,
slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible,
),
pageLayer,
],
);
},
);
final body = Stack(
fit: StackFit.expand,
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<SystemUiOverlayStyle>(
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,
),
),
);
final revealContent =
!widget.revealAfterFirstFrame ||
_firstFrameReady ||
logic.session.isLocked ||
logic.ticketError != null ||
logic.blockedEpisodeId != null ||
logic.session.error != null;
final presentedPlayer = widget.revealAfterFirstFrame
? Opacity(opacity: revealContent ? 1 : 0, child: player)
: player;
if (widget.handoff == null) return presentedPlayer;
return PopScope<void>(
canPop: _leavingHandoff,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_leaveHandoff(context));
},
child: presentedPlayer,
);
}
void _handleCurrentVideoVisible() {
if (!mounted || _firstFrameReady) return;
setState(() => _firstFrameReady = true);
if (kDebugMode) {
debugPrint(
'[player-entry] phase=ready '
'session=${identityHashCode(logic.session)}',
);
}
}
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<PlayerRenderSlotRole, PlayerRenderSlotData> _renderSlots(
List<int> 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<int> 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;
return Stack(
fit: StackFit.expand,
children: [
if (selected &&
!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 && !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<FavoriteController>().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<FavoriteController>().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,
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<void> _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<CatalogController>().requireDetail(
seriesDrama,
);
} catch (_) {
// 详情不可用时仍使用当前剧已知的集列表。
}
_preparingFullPlayer = false;
if (!mounted ||
!context.mounted ||
logic.session.currentIndex != sourceIndex ||
logic.session.currentEpisode.id != sourceEpisode.id) {
return;
}
final seriesEpisodes = List<Episode>.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;
try {
await logic.pauseForFullPlayer();
} finally {
_preparingFullPlayer = false;
}
if (!mounted || !context.mounted) return;
final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay);
_logFullPlayerRoute('present');
setState(() => _presentationActive = true);
final route = Navigator.of(context).push<void>(
PageRouteBuilder<void>(
opaque: false,
barrierDismissible: false,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => PlayerPage(
drama: seriesDrama,
initialIndex: episodeIndex,
handoff: handoff,
wallet: logic.wallet,
downloads: logic.downloads,
revealAfterFirstFrame: true,
),
),
);
await route;
if (!mounted) return;
logic.attach(widget, isMounted: () => mounted);
setState(() => _presentationActive = false);
await logic.resumeAfterFullPlayer();
if (!mounted) return;
_logFullPlayerRoute('restored');
}
Future<void> _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 ?? '-'}',
);
}
}