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

971 lines
33 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/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_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 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,
});
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;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage>
with WidgetsBindingObserver, RouteAware {
late final PlayerLogic logic;
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;
final Set<String> _precachedPosterUrls = {};
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
final Map<String, int> _commentCounts = {};
@override
void initState() {
super.initState();
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: context.read<PlaybackCoordinator>(),
);
}
@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() {
logic.onRouteCovered();
}
@override
void didPopNext() {
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)),
);
}
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: logic.pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
physics: widget.onRefreshFeed == null
? null
: const FeedRefreshPhysics(),
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
),
);
if (widget.onRefreshFeed == null) return pages;
return FeedPullRefresh(
pageController: logic.pages,
topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
onRefresh: _refreshHomeFeed,
child: pages,
);
},
);
final body = Stack(
fit: StackFit.expand,
children: [
content,
if (widget.homeMode &&
widget.showHomeFeedHeader &&
!_scrubbing &&
_chromeVisible &&
!_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,
),
),
),
],
);
return 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,
),
),
);
}
void _openCurrentMore() {
final entry = logic.queue.entryAt(logic.session.currentIndex);
_openMore(context, entry.episode);
}
Widget _buildEpisode(
BuildContext context,
int index,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
? logic.sessionEpisodes[index]
: entry.episode;
final displayed = widget.homeMode
? logic.displayEpisode(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;
final episode = needsSessionThumbnails || needsSessionPoster
? displayed.withVideoUrl(
displayed.videoUrl,
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
posterUrl: sessionEpisode.posterUrl,
)
: displayed;
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 handoff = selected && !locked ? logic.session.handoffAt(index) : null;
final handoffPort = handoff is VideoPlayerPort ? handoff : 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 videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable =
!locked &&
videoReady &&
(selected
? logic.session.reusedWarmAt(index)
: logic.session.preparedPlayerAt(index) != null);
final warmSlotReused = logic.session.reusedWarmAt(index);
final thumbnailsUrl = episode.thumbnailsUrl;
final posterUrl = episode.posterUrl;
final posterReady = posterUrl?.trim().isNotEmpty ?? false;
final openingFrameReady =
thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
if (!posterReady && thumbnailsUrl != null) {
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
}
if (selected &&
!locked &&
videoPort != null &&
videoPort.controller.value.isInitialized) {
unawaited(
_spriteAxisService.prepareAt(
episode.thumbnailsUrl,
videoPort.controller.value.position,
),
);
}
return Stack(
fit: StackFit.expand,
children: [
if (widget.homeMode)
const ColoredBox(
key: Key('home-feed-black-background'),
color: AppColors.mediaBlack,
),
PlayerVideoSurface(
controller: locked ? null : videoPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
warmFrameAvailable: preparedFrameAvailable,
warmSlotReused: warmSlotReused,
thumbnailReady: openingFrameReady,
posterReady: posterReady,
fallback: handoffPort != null
? PlayerOpeningFallback.handoff
: playerOpeningFallback(
posterUrl: posterUrl,
thumbnailsUrl: thumbnailsUrl,
),
waitForPlaybackFrame: logic.session.userWantsPlay,
placeholder: locked
? null
: handoffPort != null
? ControllerFramePlaceholder(
controller: handoffPort.controller,
theater: theater,
)
: playerOpeningFrame(
thumbnailsUrl,
posterUrl: posterUrl,
spriteSource: _spriteAxisService,
fit: videoFit,
),
),
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)
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: () async {
await widget.onOpenFullPlayer?.call(drama, episode);
if (!mounted || !widget.active) return;
logic.session.setUserWantsPlay(true);
},
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
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.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,
),
// 票据三态之三:后端判定不可播放 —— 用 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,
),
),
],
);
}
}