1463 lines
49 KiB
Dart
1463 lines
49 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/models/episode_stats.dart';
|
|
import '../../core/data/repositories/comment_repository.dart';
|
|
import '../../core/data/services/catalog_controller.dart';
|
|
import '../../core/data/services/auth_controller.dart';
|
|
import '../../core/data/services/favorite_controller.dart';
|
|
import '../../core/data/services/download_controller.dart';
|
|
import '../../core/data/services/interaction_controller.dart';
|
|
import '../../core/data/services/playback_position_cache.dart';
|
|
import '../../core/data/services/playback_session.dart';
|
|
import '../../core/data/services/sprite_thumbnail_axis.dart';
|
|
import '../../core/data/services/playback_thumbnail_service.dart';
|
|
import '../../core/data/services/video_player_port.dart';
|
|
import '../../core/data/services/wallet_controller.dart';
|
|
import '../../core/formatting/error_text.dart';
|
|
import '../../core/navigation/app_route_observer.dart';
|
|
import '../../core/platform/haptics.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../core/widgets/auth_gate.dart';
|
|
import '../../core/widgets/centered_toast.dart';
|
|
import '../../core/widgets/download_action.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../home/home_feed_channel.dart';
|
|
import 'comments_auth.dart';
|
|
import 'player_binding.dart';
|
|
import 'player_logic.dart';
|
|
import 'widgets/home_feed_header.dart';
|
|
import 'widgets/home_feed_overlay.dart';
|
|
import 'widgets/exit_clear_screen_button.dart';
|
|
import 'widgets/feed_pull_refresh.dart';
|
|
import 'widgets/player_center_toggle.dart';
|
|
import 'widgets/player_loading_overlay.dart';
|
|
import 'widgets/player_heart_overlay_scope.dart';
|
|
import 'widgets/player_more_sheet.dart';
|
|
import 'widgets/player_overlay.dart';
|
|
import 'widgets/player_overlay_models.dart';
|
|
import 'widgets/player_render_stack.dart';
|
|
import 'widgets/player_speed_sheet.dart';
|
|
import 'widgets/player_unlock_sheet.dart';
|
|
import 'widgets/player_opening_frame.dart';
|
|
import 'widgets/player_video_surface.dart';
|
|
import '../../core/data/repositories/watch_history_repository.dart';
|
|
import '../../core/data/repositories/playback_repository.dart';
|
|
|
|
part 'player_view_actions.dart';
|
|
|
|
const double _boostPlaybackSpeed = 2.0;
|
|
const double _playerLoadingSize = 24;
|
|
const double _playerLoadingStrokeWidth = 2;
|
|
const double _feedRefreshTopGap = 56;
|
|
|
|
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<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;
|
|
|
|
@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;
|
|
bool _leavingHandoff = false;
|
|
bool _preparingFullPlayer = false;
|
|
int? _videoReadyIndex;
|
|
bool _loadingVisible = false;
|
|
bool _loadingRemoved = true;
|
|
bool _loadingFadeScheduled = false;
|
|
bool _loggedLoadingUiVisible = false;
|
|
bool _loggedStartConditions = false;
|
|
final PlayerHeartOverlayController _heartOverlayController =
|
|
PlayerHeartOverlayController();
|
|
final Set<String> _precachedPosterUrls = {};
|
|
|
|
/// 评论接口返回的最新数量;统计快照优先,失败时保留这里的旧值。
|
|
final Map<String, int> _commentCounts = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final handoff = widget.handoff;
|
|
final initialUserWantsPlay = handoff?.sourceUserWantsPlay ?? true;
|
|
logic = PlayerBinding().create(
|
|
history: context.read<WatchHistoryRepository>(),
|
|
playback: context.read<PlaybackRepository>(),
|
|
positionCache: context.read<PlaybackPositionCache>(),
|
|
wallet: widget.wallet,
|
|
downloads: widget.downloads,
|
|
isSignedIn: () => context.read<AuthController>().isSignedIn,
|
|
);
|
|
logic.onEpisodeStatsUpdated = _applyEpisodeStats;
|
|
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: initialUserWantsPlay,
|
|
);
|
|
if (kDebugMode) {
|
|
debugPrint(
|
|
'[player-start] phase=initialized '
|
|
'session=${identityHashCode(logic.session)} '
|
|
'initialUserWantsPlay=$initialUserWantsPlay '
|
|
'sessionUserWantsPlay=${logic.session.userWantsPlay} '
|
|
'active=${logic.active} embedded=${widget.embedded} '
|
|
'appResumed=${logic.session.debugAppResumed} '
|
|
'routeVisible=${logic.session.debugRouteVisible} '
|
|
'overlayOpen=${logic.session.debugOverlayOpen} '
|
|
'live=${logic.session.livePlayerCount}',
|
|
);
|
|
}
|
|
_pageController = logic.pages;
|
|
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<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);
|
|
_heartOverlayController.dispose();
|
|
_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<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();
|
|
if (kDebugMode) {
|
|
debugPrint(
|
|
'[player-like] phase=double-tap-callback '
|
|
'episodeId=${episode.id} '
|
|
'busy=${interactions.isEpisodeBusy(episode.id)} '
|
|
'liked=${interactions.isEpisodeLiked(episode.id)}',
|
|
);
|
|
}
|
|
unawaited(_toggleLike(context, interactions, episode, likeOnly: true));
|
|
}
|
|
|
|
void _refreshLikeState() {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
}
|
|
|
|
void _applyEpisodeStats(EpisodeStats stats) {
|
|
if (!mounted) return;
|
|
context.read<InteractionController>().applyEpisodeStats(
|
|
stats.episodeId,
|
|
liked: stats.liked,
|
|
likeCount: stats.likeCount,
|
|
);
|
|
if (stats.dramaId.isNotEmpty) {
|
|
context.read<FavoriteController>().applyServerState(
|
|
stats.dramaId,
|
|
saved: stats.favorited,
|
|
);
|
|
}
|
|
setState(() {
|
|
_commentCounts[stats.episodeId] = stats.commentCount;
|
|
});
|
|
}
|
|
|
|
/// 收藏状态确认后同步右侧剧级计数。
|
|
void _applyFavoriteDelta(Drama drama, {required bool nowSaved}) {
|
|
final current = logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount;
|
|
final next = current == null
|
|
? null
|
|
: nowSaved
|
|
? current + 1
|
|
: (current > 0 ? current - 1 : 0);
|
|
logic.applyDramaFavoriteState(
|
|
drama.id,
|
|
favorited: nowSaved,
|
|
favoriteCount: next,
|
|
);
|
|
}
|
|
|
|
Future<void> _openComments(
|
|
BuildContext context,
|
|
Drama drama,
|
|
Episode episode,
|
|
) async {
|
|
final comments = context.read<CommentRepository>();
|
|
await openComments(
|
|
context,
|
|
drama,
|
|
show: (context, drama) =>
|
|
logic.showComments(context, drama, episodeId: episode.id),
|
|
);
|
|
try {
|
|
final count = await comments.count(drama.id, episodeId: episode.id);
|
|
if (!mounted) return;
|
|
logic.applyEpisodeCommentCount(episode.id, count);
|
|
setState(() => _commentCounts[episode.id] = count);
|
|
} catch (_) {
|
|
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => _buildPlayer(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlayer(BuildContext context) {
|
|
final interactions = context.watch<InteractionController>();
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final indices = logic.visibleIndices;
|
|
if (indices.isEmpty) {
|
|
return const ColoredBox(
|
|
color: AppColors.mediaBlack,
|
|
child: SizedBox.expand(),
|
|
);
|
|
}
|
|
if (kDebugMode && !_loggedStartConditions) {
|
|
_loggedStartConditions = true;
|
|
debugPrint(
|
|
'[player-start] phase=first-build '
|
|
'session=${identityHashCode(logic.session)} '
|
|
'tickerMode=${TickerMode.valuesOf(context).enabled} '
|
|
'routeVisible=${logic.session.debugRouteVisible} '
|
|
'appResumed=${logic.session.debugAppResumed} '
|
|
'overlayOpen=${logic.session.debugOverlayOpen} '
|
|
'visible=${logic.session.debugVisible} '
|
|
'mayPlay=${logic.session.debugMayPlay}',
|
|
);
|
|
}
|
|
final activePort = logic.session.active;
|
|
final activeVideo = activePort is VideoPlayerPort ? activePort : null;
|
|
final canWaitForVideoFrame =
|
|
activeVideo != null &&
|
|
activeVideo.controller.value.isInitialized &&
|
|
!activeVideo.controller.value.hasError;
|
|
final entryUnavailable =
|
|
logic.ticketError != null ||
|
|
logic.blockedEpisodeId != null ||
|
|
logic.session.error != null ||
|
|
logic.isEpisodeLocked(logic.session.currentEpisode);
|
|
if (!widget.embedded &&
|
|
!_loadingRemoved &&
|
|
!_loadingFadeScheduled &&
|
|
entryUnavailable &&
|
|
!canWaitForVideoFrame) {
|
|
_scheduleLoadingFade('entry-unavailable');
|
|
}
|
|
if (!widget.embedded &&
|
|
!_loadingRemoved &&
|
|
!_loadingFadeScheduled &&
|
|
activePort != null &&
|
|
activePort is! VideoPlayerPort) {
|
|
_scheduleLoadingFade('non-video-player-ready');
|
|
}
|
|
final content = AnimatedBuilder(
|
|
animation: logic.session,
|
|
builder: (context, _) {
|
|
_prewarmAdjacentFrames();
|
|
final pages = NotificationListener<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,
|
|
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 scopedBody = PlayerHeartOverlayScope(
|
|
controller: _heartOverlayController,
|
|
child: body,
|
|
);
|
|
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
|
|
? scopedBody
|
|
: Scaffold(
|
|
key: const Key('player-scaffold'),
|
|
backgroundColor: AppColors.transparent,
|
|
resizeToAvoidBottomInset: false,
|
|
body: scopedBody,
|
|
),
|
|
),
|
|
);
|
|
if (widget.handoff == null) return player;
|
|
return PopScope<void>(
|
|
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<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;
|
|
final entryLoading = !widget.embedded && !_loadingRemoved;
|
|
if (kDebugMode && entryLoading && selected && !_loggedLoadingUiVisible) {
|
|
_loggedLoadingUiVisible = true;
|
|
debugPrint(
|
|
'[player-entry] phase=loading-ui-visible '
|
|
'index=$index top=1 rail=1 progress=1',
|
|
);
|
|
}
|
|
if (widget.homeMode && !_presentationActive && !theater) {
|
|
_logRailLikeState('home', episode.id, interactions);
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
if (selected &&
|
|
!entryLoading &&
|
|
!locked &&
|
|
!theater &&
|
|
!ready &&
|
|
!logic.ticketLoading &&
|
|
logic.ticketError == null &&
|
|
logic.session.error == null &&
|
|
logic.blockedEpisodeId != episode.id &&
|
|
!logic.session.isLocked)
|
|
const Center(
|
|
child: SizedBox.square(
|
|
dimension: _playerLoadingSize,
|
|
child: CircularProgressIndicator(
|
|
key: Key('player-video-loading'),
|
|
color: AppColors.blue,
|
|
strokeWidth: _playerLoadingStrokeWidth,
|
|
),
|
|
),
|
|
),
|
|
// 票据三态之一:起播前正在向后端要签名地址
|
|
if (selected && !entryLoading && !theater && logic.ticketLoading)
|
|
const Center(
|
|
child: SizedBox.square(
|
|
dimension: _playerLoadingSize,
|
|
child: CircularProgressIndicator(
|
|
key: Key('player-ticket-loading'),
|
|
color: AppColors.blue,
|
|
strokeWidth: _playerLoadingStrokeWidth,
|
|
),
|
|
),
|
|
),
|
|
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
|
if (selected &&
|
|
!theater &&
|
|
!logic.ticketLoading &&
|
|
logic.ticketError != null)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 36),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.wifi_off_rounded,
|
|
color: AppColors.muted,
|
|
size: 36,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
errorText(l10n, logic.ticketError, l10n.playbackError),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.muted),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton(
|
|
onPressed: () => unawaited(logic.retryTicket()),
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (selected && !theater && !ready && logic.session.error != null)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 36),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.error_outline_rounded,
|
|
color: AppColors.muted,
|
|
size: 36,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.playbackError,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
|
|
if (selected &&
|
|
!theater &&
|
|
!ready &&
|
|
logic.blockedEpisodeId != episode.id &&
|
|
logic.session.error == null &&
|
|
logic.session.isLocked)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 36),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.movie_filter_outlined,
|
|
color: AppColors.muted,
|
|
size: 36,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
episode.locked
|
|
? l10n.lockedTitle(episode.number)
|
|
: l10n.noMediaNotice,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: AppColors.muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (logic.pip || logic.pipPreparing)
|
|
const SizedBox.expand()
|
|
else if (widget.homeMode && !_presentationActive)
|
|
HomeFeedOverlay(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
controller: videoReady ? videoPort!.controller : null,
|
|
locked: locked,
|
|
visible: _chromeVisible,
|
|
boosting: _boosting,
|
|
onBoostStart: _startBoost,
|
|
onBoostEnd: _endBoost,
|
|
onTogglePlay: _handleFeedSurfaceTap,
|
|
liked: interactions.isEpisodeLiked(episode.id),
|
|
likeCount:
|
|
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
|
favoriteCount:
|
|
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
|
commentCount:
|
|
logic.episodeStats(episode.id)?.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,
|
|
suppressBufferLoading: !widget.embedded && !_loadingRemoved,
|
|
)
|
|
else if (!widget.homeMode)
|
|
_standardPlaybackOverlay(context, index, interactions),
|
|
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
|
|
if (selected && !theater && locked)
|
|
PlayerBlockedNotice(
|
|
l10n: l10n,
|
|
episode: episode,
|
|
accessType: logic.blockedTicket?.accessType,
|
|
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
|
|
unlocking: logic.unlockingEpisodeId == episode.id,
|
|
error: logic.unlockError,
|
|
onOpenUnlock: logic.wallet == null
|
|
? null
|
|
: () => _openUnlockSheet(context, index, episode),
|
|
),
|
|
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
|
|
Positioned(
|
|
right: 12,
|
|
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;
|
|
_logRailLikeState('player', episode.id, interactions);
|
|
return PlayerPlaybackOverlay(
|
|
data: PlayerOverlayData(
|
|
l10n: l10n,
|
|
drama: drama,
|
|
episode: episode,
|
|
controller: videoReady ? videoPort!.controller : null,
|
|
locked: locked,
|
|
liked: interactions.isEpisodeLiked(episode.id),
|
|
likeCount:
|
|
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
|
|
commentCount:
|
|
logic.episodeStats(episode.id)?.commentCount ??
|
|
_commentCounts[episode.id] ??
|
|
episode.commentCount,
|
|
favoriteCount:
|
|
logic.dramaFavoriteCount(drama.id) ?? drama.favoriteCount,
|
|
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,
|
|
onDoubleTapLike: locked
|
|
? null
|
|
: (_) => _handleDoubleTapLike(context, interactions, episode),
|
|
onClear: () => setState(() => _chromeVisible = false),
|
|
onExitClear: () => setState(() => _chromeVisible = true),
|
|
onDetail: () => unawaited(logic.openDetail(context)),
|
|
onSpeed: () => unawaited(_openSpeedSheet(context)),
|
|
onEpisodes: () => logic.showEpisodes(context),
|
|
onComments: () => unawaited(_openComments(context, drama, episode)),
|
|
onLike: () => unawaited(_toggleLike(context, interactions, episode)),
|
|
onSave: () => unawaited(_toggleSave(context, drama)),
|
|
onShare: () => logic.shareDrama(context, drama),
|
|
onMore: () => _openMore(context, episode),
|
|
onScrubStart: videoReady
|
|
? () {
|
|
_setScrubbing(true);
|
|
logic.beginScrub(videoPort!);
|
|
}
|
|
: null,
|
|
onScrubEnd: () => _setScrubbing(false),
|
|
onSeek: videoReady
|
|
? (position) => logic.seekTo(context, videoPort!, position)
|
|
: null,
|
|
onLongPressCenter: () => _openMore(context, episode),
|
|
onBoostStart: _startBoost,
|
|
onBoostEnd: _endBoost,
|
|
),
|
|
controlsVisible: _chromeVisible,
|
|
suppressProgressLoading: !widget.embedded && !_loadingRemoved,
|
|
);
|
|
}
|
|
|
|
void _logRailLikeState(
|
|
String surface,
|
|
String episodeId,
|
|
InteractionController interactions,
|
|
) {
|
|
if (!kDebugMode) return;
|
|
debugPrint(
|
|
'[player-like] phase=rail-state surface=$surface '
|
|
'episodeId=$episodeId '
|
|
'liked=${interactions.isEpisodeLiked(episodeId)} '
|
|
'count=${interactions.episodeLikeCount(episodeId)}',
|
|
);
|
|
}
|
|
|
|
Future<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 handoff = PlayerPageHandoff(sourceUserWantsPlay: true);
|
|
_logFullPlayerRoute('present');
|
|
setState(() => _presentationActive = true);
|
|
final route = Navigator.of(context).push<void>(
|
|
PageRouteBuilder<void>(
|
|
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<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 ?? '-'}',
|
|
);
|
|
}
|
|
}
|