fix(player): 播放页改为非阻塞加载
This commit is contained in:
1 parent
e8f192ccf8
commit
ee4e573f4f
7 files changed
+239
-455
No files matched your search
@@ -1,134 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
|
|
||||||
import '../../core/config/app_config.dart';
|
|
||||||
|
|
||||||
class PreparedPlayerFrame {
|
|
||||||
const PreparedPlayerFrame({this.bytes, this.provider});
|
|
||||||
|
|
||||||
final Uint8List? bytes;
|
|
||||||
final ImageProvider<Object>? provider;
|
|
||||||
|
|
||||||
bool get isReady => bytes != null || provider != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlayerFrameCapture {
|
|
||||||
const PlayerFrameCapture._();
|
|
||||||
|
|
||||||
static const int budgetMilliseconds = 32;
|
|
||||||
|
|
||||||
static Future<PreparedPlayerFrame> captureAndPrecache({
|
|
||||||
required BuildContext context,
|
|
||||||
required GlobalKey boundaryKey,
|
|
||||||
required String source,
|
|
||||||
String? fallbackPosterUrl,
|
|
||||||
}) async {
|
|
||||||
final bytes = await capture(
|
|
||||||
context: context,
|
|
||||||
boundaryKey: boundaryKey,
|
|
||||||
source: source,
|
|
||||||
);
|
|
||||||
if (bytes != null) {
|
|
||||||
if (!context.mounted) return const PreparedPlayerFrame();
|
|
||||||
final provider = MemoryImage(bytes);
|
|
||||||
if (await _precache(context, provider, source: source, kind: 'capture')) {
|
|
||||||
_log(
|
|
||||||
'[player-entry] phase=frame-precached source=$source kind=capture',
|
|
||||||
);
|
|
||||||
return PreparedPlayerFrame(bytes: bytes, provider: provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final poster = fallbackPosterUrl?.trim() ?? '';
|
|
||||||
if (poster.isNotEmpty) {
|
|
||||||
if (!context.mounted) return const PreparedPlayerFrame();
|
|
||||||
final provider = NetworkImage(AppConfig.mediaUrl(poster));
|
|
||||||
if (await _precache(context, provider, source: source, kind: 'poster')) {
|
|
||||||
_log('[player-entry] phase=frame-precached source=$source kind=poster');
|
|
||||||
return PreparedPlayerFrame(provider: provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return const PreparedPlayerFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Uint8List?> capture({
|
|
||||||
required BuildContext context,
|
|
||||||
required GlobalKey boundaryKey,
|
|
||||||
required String source,
|
|
||||||
}) async {
|
|
||||||
final boundary = boundaryKey.currentContext?.findRenderObject();
|
|
||||||
if (boundary is! RenderRepaintBoundary) {
|
|
||||||
_log('[player-entry] phase=frame-capture-unavailable source=$source');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final stopwatch = Stopwatch()..start();
|
|
||||||
try {
|
|
||||||
final pixelRatio = MediaQuery.devicePixelRatioOf(
|
|
||||||
context,
|
|
||||||
).clamp(1.0, 2.0).toDouble();
|
|
||||||
final image = await boundary.toImage(pixelRatio: pixelRatio);
|
|
||||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
||||||
image.dispose();
|
|
||||||
if (stopwatch.elapsedMilliseconds > budgetMilliseconds) {
|
|
||||||
_log(
|
|
||||||
'[player-entry] phase=frame-capture-over-budget source=$source '
|
|
||||||
'ms=${stopwatch.elapsedMilliseconds} budget_ms=$budgetMilliseconds',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (data == null) {
|
|
||||||
_log('[player-entry] phase=frame-capture-empty source=$source');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final bytes = data.buffer.asUint8List(
|
|
||||||
data.offsetInBytes,
|
|
||||||
data.lengthInBytes,
|
|
||||||
);
|
|
||||||
_log(
|
|
||||||
'[player-entry] phase=frame-captured source=$source '
|
|
||||||
'bytes=${bytes.length} ms=${stopwatch.elapsedMilliseconds}',
|
|
||||||
);
|
|
||||||
return bytes;
|
|
||||||
} catch (error) {
|
|
||||||
_log(
|
|
||||||
'[player-entry] phase=frame-capture-failed source=$source '
|
|
||||||
'error=$error',
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> _precache(
|
|
||||||
BuildContext context,
|
|
||||||
ImageProvider<Object> provider, {
|
|
||||||
required String source,
|
|
||||||
required String kind,
|
|
||||||
}) async {
|
|
||||||
var failed = false;
|
|
||||||
try {
|
|
||||||
await precacheImage(
|
|
||||||
provider,
|
|
||||||
context,
|
|
||||||
onError: (_, _) {
|
|
||||||
failed = true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
if (failed) {
|
|
||||||
_log(
|
|
||||||
'[player-entry] phase=frame-precache-failed source=$source kind=$kind',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return !failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _log(String message) {
|
|
||||||
if (kDebugMode) debugPrint(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,6 @@ import '../discover/search_page.dart';
|
|||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../drama_detail/drama_detail_view.dart';
|
||||||
import '../mine/mine_types.dart';
|
import '../mine/mine_types.dart';
|
||||||
import '../mine/mine_view.dart';
|
import '../mine/mine_view.dart';
|
||||||
import 'player_frame_capture.dart';
|
|
||||||
import 'player_view.dart';
|
import 'player_view.dart';
|
||||||
import 'widgets/drama_comments_sheet.dart';
|
import 'widgets/drama_comments_sheet.dart';
|
||||||
import 'widgets/episode_picker_sheet.dart';
|
import 'widgets/episode_picker_sheet.dart';
|
||||||
@@ -65,9 +64,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final Future<void> Function() _waitForFrame;
|
final Future<void> Function() _waitForFrame;
|
||||||
|
|
||||||
late PageController pages;
|
late PageController pages;
|
||||||
final GlobalKey renderBoundaryKey = GlobalKey(
|
|
||||||
debugLabel: 'player-render-boundary',
|
|
||||||
);
|
|
||||||
late PlaybackQueue queue;
|
late PlaybackQueue queue;
|
||||||
late List<int> visibleIndices;
|
late List<int> visibleIndices;
|
||||||
late PlaybackSession session;
|
late PlaybackSession session;
|
||||||
@@ -1229,18 +1225,6 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
Drama drama,
|
Drama drama,
|
||||||
int index,
|
int index,
|
||||||
) async {
|
) async {
|
||||||
final fallbackPosterUrl = index >= 0 && index < drama.episodes.length
|
|
||||||
? drama.episodes[index].posterUrl
|
|
||||||
: null;
|
|
||||||
final preparedFrame = await PlayerFrameCapture.captureAndPrecache(
|
|
||||||
context: context,
|
|
||||||
boundaryKey: renderBoundaryKey,
|
|
||||||
source: 'watch-all',
|
|
||||||
fallbackPosterUrl: fallbackPosterUrl,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
await pauseForFullPlayer();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
final route = Navigator.of(context).push(
|
final route = Navigator.of(context).push(
|
||||||
FadePageRoute<void>(
|
FadePageRoute<void>(
|
||||||
transitionDuration: Duration.zero,
|
transitionDuration: Duration.zero,
|
||||||
@@ -1250,15 +1234,13 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
initialIndex: index,
|
initialIndex: index,
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
revealAfterFirstFrame: preparedFrame.isReady,
|
|
||||||
initialFrameBytes: preparedFrame.bytes,
|
|
||||||
initialFrameProvider: preparedFrame.provider,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0');
|
debugPrint('[player-entry] phase=route-pushed source=watch-all ms=0');
|
||||||
}
|
}
|
||||||
|
unawaited(pauseForFullPlayer());
|
||||||
unawaited(saveCurrentProgress());
|
unawaited(saveCurrentProgress());
|
||||||
await route;
|
await route;
|
||||||
if (!isMounted()) return;
|
if (!isMounted()) return;
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../home/home_feed_channel.dart';
|
import '../home/home_feed_channel.dart';
|
||||||
import 'comments_auth.dart';
|
import 'comments_auth.dart';
|
||||||
import 'player_binding.dart';
|
import 'player_binding.dart';
|
||||||
import 'player_frame_capture.dart';
|
|
||||||
import 'player_logic.dart';
|
import 'player_logic.dart';
|
||||||
import 'widgets/home_feed_header.dart';
|
import 'widgets/home_feed_header.dart';
|
||||||
import 'widgets/home_feed_overlay.dart';
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/exit_clear_screen_button.dart';
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
import 'widgets/feed_pull_refresh.dart';
|
import 'widgets/feed_pull_refresh.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
|
import 'widgets/player_loading_overlay.dart';
|
||||||
import 'widgets/player_more_sheet.dart';
|
import 'widgets/player_more_sheet.dart';
|
||||||
import 'widgets/player_overlay.dart';
|
import 'widgets/player_overlay.dart';
|
||||||
import 'widgets/player_overlay_models.dart';
|
import 'widgets/player_overlay_models.dart';
|
||||||
@@ -91,9 +91,6 @@ class PlayerPage extends StatefulWidget {
|
|||||||
this.wallet,
|
this.wallet,
|
||||||
this.downloads,
|
this.downloads,
|
||||||
this.handoff,
|
this.handoff,
|
||||||
this.revealAfterFirstFrame = false,
|
|
||||||
this.initialFrameBytes,
|
|
||||||
this.initialFrameProvider,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
@@ -121,9 +118,6 @@ class PlayerPage extends StatefulWidget {
|
|||||||
final WalletController? wallet;
|
final WalletController? wallet;
|
||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
final PlayerPageHandoff? handoff;
|
final PlayerPageHandoff? handoff;
|
||||||
final bool revealAfterFirstFrame;
|
|
||||||
final Uint8List? initialFrameBytes;
|
|
||||||
final ImageProvider<Object>? initialFrameProvider;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PlayerPage> createState() => _PlayerPageState();
|
State<PlayerPage> createState() => _PlayerPageState();
|
||||||
@@ -146,16 +140,13 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
DateTime? _swipeStartedAt;
|
DateTime? _swipeStartedAt;
|
||||||
bool _swipePrewarmCaptured = false;
|
bool _swipePrewarmCaptured = false;
|
||||||
bool _presentationActive = false;
|
bool _presentationActive = false;
|
||||||
late bool _firstFrameReady;
|
|
||||||
late bool _decorVisible;
|
|
||||||
late bool _decorRemoved;
|
|
||||||
bool _decorFadeScheduled = false;
|
|
||||||
bool _leavingHandoff = false;
|
bool _leavingHandoff = false;
|
||||||
bool _preparingFullPlayer = false;
|
bool _preparingFullPlayer = false;
|
||||||
bool _loggedInitialFrameBuild = false;
|
int? _loadingIndex;
|
||||||
bool _loggedDecorCovered = false;
|
int? _videoReadyIndex;
|
||||||
DateTime? _entryDiagnosticsStartedAt;
|
bool _loadingVisible = false;
|
||||||
final Map<String, bool> _entryElementStates = {};
|
bool _loadingRemoved = true;
|
||||||
|
bool _loadingFadeScheduled = false;
|
||||||
final Set<String> _precachedPosterUrls = {};
|
final Set<String> _precachedPosterUrls = {};
|
||||||
|
|
||||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||||
@@ -165,14 +156,6 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_firstFrameReady = !widget.revealAfterFirstFrame;
|
|
||||||
final hasInitialFrame =
|
|
||||||
widget.initialFrameBytes != null || widget.initialFrameProvider != null;
|
|
||||||
_decorVisible = widget.revealAfterFirstFrame && hasInitialFrame;
|
|
||||||
_decorRemoved = !_decorVisible;
|
|
||||||
if (widget.revealAfterFirstFrame) {
|
|
||||||
_entryDiagnosticsStartedAt = DateTime.now();
|
|
||||||
}
|
|
||||||
final handoff = widget.handoff;
|
final handoff = widget.handoff;
|
||||||
logic = PlayerBinding().create(
|
logic = PlayerBinding().create(
|
||||||
history: context.read<WatchHistoryRepository>(),
|
history: context.read<WatchHistoryRepository>(),
|
||||||
@@ -196,11 +179,10 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
|
initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true,
|
||||||
);
|
);
|
||||||
_pageController = logic.pages;
|
_pageController = logic.pages;
|
||||||
if (kDebugMode && widget.revealAfterFirstFrame) {
|
if (!widget.embedded) {
|
||||||
debugPrint(
|
_loadingIndex = logic.session.currentIndex;
|
||||||
'[player-entry] phase=waiting-first-frame '
|
_loadingVisible = true;
|
||||||
'session=${identityHashCode(logic.session)}',
|
_loadingRemoved = false;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +544,33 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final interactions = context.watch<InteractionController>();
|
final interactions = context.watch<InteractionController>();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final indices = logic.visibleIndices;
|
final indices = logic.visibleIndices;
|
||||||
|
final currentIndex = logic.session.currentIndex;
|
||||||
|
if (!widget.embedded && _loadingIndex != currentIndex) {
|
||||||
|
_loadingIndex = currentIndex;
|
||||||
|
_videoReadyIndex = null;
|
||||||
|
_loadingVisible = true;
|
||||||
|
_loadingRemoved = false;
|
||||||
|
_loadingFadeScheduled = false;
|
||||||
|
}
|
||||||
|
final entryUnavailable =
|
||||||
|
logic.ticketError != null ||
|
||||||
|
logic.blockedEpisodeId != null ||
|
||||||
|
logic.session.error != null ||
|
||||||
|
logic.isEpisodeLocked(logic.session.currentEpisode);
|
||||||
|
if (!widget.embedded &&
|
||||||
|
!_loadingRemoved &&
|
||||||
|
!_loadingFadeScheduled &&
|
||||||
|
entryUnavailable) {
|
||||||
|
_scheduleLoadingFade('entry-unavailable');
|
||||||
|
}
|
||||||
|
final activePort = logic.session.active;
|
||||||
|
if (!widget.embedded &&
|
||||||
|
!_loadingRemoved &&
|
||||||
|
!_loadingFadeScheduled &&
|
||||||
|
activePort != null &&
|
||||||
|
activePort is! VideoPlayerPort) {
|
||||||
|
_scheduleLoadingFade('non-video-player-ready');
|
||||||
|
}
|
||||||
final content = AnimatedBuilder(
|
final content = AnimatedBuilder(
|
||||||
animation: logic.session,
|
animation: logic.session,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -618,14 +627,11 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
RepaintBoundary(
|
PlayerRenderStack(
|
||||||
key: logic.renderBoundaryKey,
|
pageController: _pageController,
|
||||||
child: PlayerRenderStack(
|
slots: _renderSlots(indices),
|
||||||
pageController: _pageController,
|
onCurrentVideoVisible: _handleCurrentVideoVisible,
|
||||||
slots: _renderSlots(indices),
|
showNeighbors: widget.embedded || _loadingRemoved,
|
||||||
onCurrentVideoVisible: _handleCurrentVideoVisible,
|
|
||||||
showNeighbors: !widget.revealAfterFirstFrame || _decorRemoved,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pageLayer,
|
pageLayer,
|
||||||
],
|
],
|
||||||
@@ -669,6 +675,14 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!widget.embedded && !_loadingRemoved)
|
||||||
|
Positioned.fill(
|
||||||
|
child: PlayerLoadingOverlay(
|
||||||
|
key: ValueKey('player-loading-${_loadingIndex ?? currentIndex}'),
|
||||||
|
opacity: _loadingVisible ? 1 : 0,
|
||||||
|
onFadeEnd: _handleLoadingFadeEnd,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
final player = AnnotatedRegion<SystemUiOverlayStyle>(
|
final player = AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
@@ -692,78 +706,25 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_logEntryElement('player-ui', true);
|
if (widget.handoff == null) return player;
|
||||||
_logEntryElement(
|
|
||||||
'home-feed-header',
|
|
||||||
widget.homeMode &&
|
|
||||||
widget.showHomeFeedHeader &&
|
|
||||||
!_scrubbing &&
|
|
||||||
!_boosting &&
|
|
||||||
!logic.pip &&
|
|
||||||
!logic.pipPreparing,
|
|
||||||
);
|
|
||||||
final hasInitialFrame =
|
|
||||||
widget.initialFrameBytes != null || widget.initialFrameProvider != null;
|
|
||||||
if (kDebugMode &&
|
|
||||||
widget.revealAfterFirstFrame &&
|
|
||||||
hasInitialFrame &&
|
|
||||||
!_loggedInitialFrameBuild) {
|
|
||||||
_loggedInitialFrameBuild = true;
|
|
||||||
debugPrint(
|
|
||||||
'[player-entry] phase=first-build-with-initial-frame '
|
|
||||||
'kind=${widget.initialFrameBytes != null ? 'capture' : 'poster'}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final usesInitialFrame = widget.revealAfterFirstFrame && hasInitialFrame;
|
|
||||||
if (usesInitialFrame &&
|
|
||||||
_decorVisible &&
|
|
||||||
!_loggedDecorCovered &&
|
|
||||||
kDebugMode) {
|
|
||||||
_loggedDecorCovered = true;
|
|
||||||
debugPrint('[player-entry] phase=decor-covers-video');
|
|
||||||
}
|
|
||||||
final entryUnavailableReason = logic.ticketError != null
|
|
||||||
? 'ticket-error'
|
|
||||||
: logic.blockedEpisodeId != null
|
|
||||||
? 'blocked-episode'
|
|
||||||
: null;
|
|
||||||
if (usesInitialFrame && _decorVisible && entryUnavailableReason != null) {
|
|
||||||
_scheduleDecorFade(entryUnavailableReason);
|
|
||||||
}
|
|
||||||
final presentedPlayer = usesInitialFrame
|
|
||||||
? Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
player,
|
|
||||||
if (!_decorRemoved)
|
|
||||||
Positioned.fill(
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
key: const Key('player-entry-decor'),
|
|
||||||
opacity: _decorVisible ? 1 : 0,
|
|
||||||
duration: const Duration(milliseconds: 140),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
onEnd: _handleDecorFadeEnd,
|
|
||||||
child: _buildInitialFrameImage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: player;
|
|
||||||
if (widget.handoff == null) return presentedPlayer;
|
|
||||||
return PopScope<void>(
|
return PopScope<void>(
|
||||||
canPop: _leavingHandoff,
|
canPop: _leavingHandoff,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) unawaited(_leaveHandoff(context));
|
if (!didPop) unawaited(_leaveHandoff(context));
|
||||||
},
|
},
|
||||||
child: presentedPlayer,
|
child: player,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleCurrentVideoVisible() {
|
void _handleCurrentVideoVisible(int? index) {
|
||||||
if (!mounted || _firstFrameReady) return;
|
if (!mounted) return;
|
||||||
_firstFrameReady = true;
|
final currentIndex = logic.session.currentIndex;
|
||||||
|
if (index == null ||
|
||||||
|
index != currentIndex ||
|
||||||
|
_videoReadyIndex == currentIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_videoReadyIndex = currentIndex;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[player-entry] phase=video-first-frame '
|
'[player-entry] phase=video-first-frame '
|
||||||
@@ -772,95 +733,41 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_startDecorFade('video-first-frame');
|
_startLoadingFade(currentIndex);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleDecorFade(String reason) {
|
void _scheduleLoadingFade(String reason) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_startDecorFade(reason);
|
_startLoadingFade(logic.session.currentIndex, reason: reason);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startDecorFade(String reason) {
|
void _startLoadingFade(int index, {String reason = 'video-first-frame'}) {
|
||||||
if (!mounted || !_decorVisible || _decorRemoved || _decorFadeScheduled) {
|
if (!mounted ||
|
||||||
|
widget.embedded ||
|
||||||
|
index != logic.session.currentIndex ||
|
||||||
|
!_loadingVisible ||
|
||||||
|
_loadingRemoved ||
|
||||||
|
_loadingFadeScheduled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_decorFadeScheduled = true;
|
_loadingFadeScheduled = true;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[player-entry] phase=decor-fade-start reason=$reason');
|
debugPrint('[player-entry] phase=loading-fade-start reason=$reason');
|
||||||
}
|
}
|
||||||
setState(() => _decorVisible = false);
|
setState(() => _loadingVisible = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDecorFadeEnd() {
|
void _handleLoadingFadeEnd() {
|
||||||
if (!mounted || _decorVisible || _decorRemoved) return;
|
if (!mounted || _loadingVisible || _loadingRemoved) return;
|
||||||
setState(() => _decorRemoved = true);
|
setState(() => _loadingRemoved = true);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[player-entry] phase=decor-removed');
|
debugPrint('[player-entry] phase=loading-removed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInitialFrameImage() {
|
|
||||||
if (widget.initialFrameBytes != null) {
|
|
||||||
return Image.memory(
|
|
||||||
widget.initialFrameBytes!,
|
|
||||||
key: const Key('player-entry-frame'),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => _buildInitialFrameFallback(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final provider = widget.initialFrameProvider;
|
|
||||||
if (provider != null) {
|
|
||||||
return Image(
|
|
||||||
image: provider,
|
|
||||||
key: const Key('player-entry-frame'),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (_, _, _) => _buildInitialFrameFallback(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _buildInitialFrameFallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _logEntryElement(String element, bool visible) {
|
|
||||||
final startedAt = _entryDiagnosticsStartedAt;
|
|
||||||
if (startedAt == null ||
|
|
||||||
DateTime.now().difference(startedAt) >
|
|
||||||
const Duration(milliseconds: 800)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_entryElementStates[element] == visible) return;
|
|
||||||
_entryElementStates[element] = visible;
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[player-entry] phase=element-state element=$element '
|
|
||||||
'visible=${visible ? 1 : 0} '
|
|
||||||
'ms=${DateTime.now().difference(startedAt).inMilliseconds}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInitialFrameFallback() {
|
|
||||||
final index = widget.initialIndex;
|
|
||||||
if (index < 0 || index >= widget.drama.episodes.length) {
|
|
||||||
return const ColoredBox(
|
|
||||||
key: Key('player-entry-black'),
|
|
||||||
color: AppColors.mediaBlack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final episode = widget.drama.episodes[index];
|
|
||||||
return playerOpeningFrame(
|
|
||||||
episode.thumbnailsUrl,
|
|
||||||
posterUrl: episode.posterUrl,
|
|
||||||
spriteSource: _spriteAxisService,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
) ??
|
|
||||||
const ColoredBox(color: AppColors.mediaBlack);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openCurrentMore() {
|
void _openCurrentMore() {
|
||||||
final entry = logic.queue.entryAt(logic.session.currentIndex);
|
final entry = logic.queue.entryAt(logic.session.currentIndex);
|
||||||
_openMore(context, entry.episode);
|
_openMore(context, entry.episode);
|
||||||
@@ -897,11 +804,6 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
logic.session.previousPreloadedIndex;
|
logic.session.previousPreloadedIndex;
|
||||||
final next =
|
final next =
|
||||||
logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex;
|
logic.nextVisibleIndex(current) ?? logic.session.preloadedIndex;
|
||||||
_logEntryElement(
|
|
||||||
'adjacent-slots',
|
|
||||||
(!widget.revealAfterFirstFrame || _decorRemoved) &&
|
|
||||||
(previous != null || next != null),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
PlayerRenderSlotRole.previous: _renderSlot(
|
PlayerRenderSlotRole.previous: _renderSlot(
|
||||||
PlayerRenderSlotRole.previous,
|
PlayerRenderSlotRole.previous,
|
||||||
@@ -948,33 +850,6 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
|
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
|
||||||
final videoReady = videoPort?.controller.value.isInitialized ?? false;
|
final videoReady = videoPort?.controller.value.isInitialized ?? false;
|
||||||
final theater = logic.pip || logic.pipPreparing;
|
final theater = logic.pip || logic.pipPreparing;
|
||||||
if (selected) {
|
|
||||||
final videoLoading =
|
|
||||||
!locked &&
|
|
||||||
!theater &&
|
|
||||||
!videoReady &&
|
|
||||||
port == null &&
|
|
||||||
!logic.ticketLoading &&
|
|
||||||
logic.ticketError == null &&
|
|
||||||
logic.session.error == null &&
|
|
||||||
logic.blockedEpisodeId != episode.id &&
|
|
||||||
!logic.session.isLocked;
|
|
||||||
_logEntryElement('loading-spinner', videoLoading);
|
|
||||||
_logEntryElement(
|
|
||||||
'opening-frame',
|
|
||||||
!locked && !videoReady && episode.posterUrl?.isNotEmpty == true ||
|
|
||||||
!locked && !videoReady && episode.thumbnailsUrl?.isNotEmpty == true,
|
|
||||||
);
|
|
||||||
_logEntryElement('pause-button', !locked && !logic.session.userWantsPlay);
|
|
||||||
_logEntryElement(
|
|
||||||
'player-controls',
|
|
||||||
!widget.homeMode && !theater && !locked,
|
|
||||||
);
|
|
||||||
_logEntryElement(
|
|
||||||
'buffering',
|
|
||||||
videoPort?.controller.value.isBuffering ?? false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
|
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
|
||||||
final preparedFrameAvailable =
|
final preparedFrameAvailable =
|
||||||
!locked &&
|
!locked &&
|
||||||
@@ -1388,24 +1263,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
seriesEpisodes.insert(0, sourceEpisode);
|
seriesEpisodes.insert(0, sourceEpisode);
|
||||||
episodeIndex = 0;
|
episodeIndex = 0;
|
||||||
}
|
}
|
||||||
final fallbackPosterUrl =
|
|
||||||
episodeIndex >= 0 && episodeIndex < seriesEpisodes.length
|
|
||||||
? seriesEpisodes[episodeIndex].posterUrl
|
|
||||||
: sourceEpisode.posterUrl;
|
|
||||||
final preparedFrame = await PlayerFrameCapture.captureAndPrecache(
|
|
||||||
context: context,
|
|
||||||
boundaryKey: logic.renderBoundaryKey,
|
|
||||||
source: 'full-player',
|
|
||||||
fallbackPosterUrl: fallbackPosterUrl,
|
|
||||||
);
|
|
||||||
if (!mounted || !context.mounted) return;
|
|
||||||
final sourceUserWantsPlay = logic.session.userWantsPlay;
|
final sourceUserWantsPlay = logic.session.userWantsPlay;
|
||||||
try {
|
|
||||||
await logic.pauseForFullPlayer();
|
|
||||||
} finally {
|
|
||||||
_preparingFullPlayer = false;
|
|
||||||
}
|
|
||||||
if (!mounted || !context.mounted) return;
|
|
||||||
final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay);
|
final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay);
|
||||||
_logFullPlayerRoute('present');
|
_logFullPlayerRoute('present');
|
||||||
setState(() => _presentationActive = true);
|
setState(() => _presentationActive = true);
|
||||||
@@ -1421,15 +1279,13 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
handoff: handoff,
|
handoff: handoff,
|
||||||
wallet: logic.wallet,
|
wallet: logic.wallet,
|
||||||
downloads: logic.downloads,
|
downloads: logic.downloads,
|
||||||
revealAfterFirstFrame: preparedFrame.isReady,
|
|
||||||
initialFrameBytes: preparedFrame.bytes,
|
|
||||||
initialFrameProvider: preparedFrame.provider,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[player-entry] phase=route-pushed source=full-player ms=0');
|
debugPrint('[player-entry] phase=route-pushed source=full-player ms=0');
|
||||||
}
|
}
|
||||||
|
unawaited(logic.pauseForFullPlayer());
|
||||||
unawaited(logic.saveCurrentProgress());
|
unawaited(logic.saveCurrentProgress());
|
||||||
await route;
|
await route;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
|
||||||
|
class PlayerLoadingOverlay extends StatelessWidget {
|
||||||
|
const PlayerLoadingOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.opacity,
|
||||||
|
required this.onFadeEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double opacity;
|
||||||
|
final VoidCallback onFadeEnd;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
key: const Key('player-entry-loading-overlay'),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: opacity,
|
||||||
|
duration: const Duration(milliseconds: 140),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
onEnd: onFadeEnd,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: AppColors.mediaBlack,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
key: const Key('player-loading-surface'),
|
||||||
|
width: AppDimens.refreshIndicatorSize,
|
||||||
|
height: AppDimens.refreshIndicatorSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.refreshIndicatorSurface,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.refreshIndicatorRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const SizedBox.square(
|
||||||
|
dimension: AppDimens.refreshSpinnerSize,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
key: Key('player-entry-loading'),
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: AppDimens.refreshSpinnerStroke,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ class PlayerRenderStack extends StatefulWidget {
|
|||||||
|
|
||||||
final PageController pageController;
|
final PageController pageController;
|
||||||
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
||||||
final VoidCallback? onCurrentVideoVisible;
|
final ValueChanged<int?>? onCurrentVideoVisible;
|
||||||
final bool showNeighbors;
|
final bool showNeighbors;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -202,7 +202,7 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
|
|||||||
warmSlotReused: slot.warmSlotReused,
|
warmSlotReused: slot.warmSlotReused,
|
||||||
waitForPlaybackFrame: slot.waitForPlaybackFrame,
|
waitForPlaybackFrame: slot.waitForPlaybackFrame,
|
||||||
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
||||||
? widget.onCurrentVideoVisible
|
? () => widget.onCurrentVideoVisible?.call(slot.index)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -129,30 +129,32 @@ void main() {
|
|||||||
expect(sourceLiveBefore, greaterThan(1));
|
expect(sourceLiveBefore, greaterThan(1));
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await _waitForFullPlayerEntry(tester);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final fullPlayer = _fullPlayerFinder();
|
final fullPlayer = _fullPlayerFinder();
|
||||||
final fullLogic = _pageLogic(tester, fullPlayer);
|
final fullLogic = _pageLogic(tester, fullPlayer);
|
||||||
expect(fullLogic.session, isNot(same(sourceLogic.session)));
|
expect(fullLogic.session, isNot(same(sourceLogic.session)));
|
||||||
final fullPlayerWidget = tester.widget<PlayerPage>(fullPlayer);
|
|
||||||
expect(
|
|
||||||
fullPlayerWidget.initialFrameBytes != null ||
|
|
||||||
fullPlayerWidget.initialFrameProvider != null,
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
expect(fullPlayerWidget.revealAfterFirstFrame, isTrue);
|
|
||||||
final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort;
|
final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort;
|
||||||
expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller)));
|
expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller)));
|
||||||
expect(feedPlayer.disposed, isFalse);
|
expect(feedPlayer.disposed, isFalse);
|
||||||
expect(feedPlayer.playing, isFalse);
|
expect(feedPlayer.playing, isFalse);
|
||||||
|
|
||||||
|
final fullPlayerPage = find
|
||||||
|
.descendant(
|
||||||
|
of: _fullPlayerFinder(),
|
||||||
|
matching: find.byWidgetPredicate(
|
||||||
|
(widget) =>
|
||||||
|
widget is PageView && widget.scrollDirection == Axis.vertical,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
for (var index = 0; index < 4; index++) {
|
for (var index = 0; index < 4; index++) {
|
||||||
await tester.drag(
|
await tester.drag(
|
||||||
find.byKey(const Key('player-video-tap')),
|
fullPlayerPage,
|
||||||
const Offset(0, -560),
|
const Offset(0, -560),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(fullLogic.session.currentEpisode.number, 5);
|
expect(fullLogic.session.currentEpisode.number, 5);
|
||||||
@@ -168,7 +170,8 @@ void main() {
|
|||||||
expect(feedPlayer.disposed, isFalse);
|
expect(feedPlayer.disposed, isFalse);
|
||||||
|
|
||||||
final requestsBeforeReturn = playback.requests.length;
|
final requestsBeforeReturn = playback.requests.length;
|
||||||
await tester.tap(find.byKey(const Key('player-back')));
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('player-back')).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await sourceLogic.session.preloadSettled;
|
await sourceLogic.session.preloadSettled;
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -229,7 +232,6 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await _waitForFullPlayerEntry(tester);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(_fullPlayerFinder(), findsOneWidget);
|
expect(_fullPlayerFinder(), findsOneWidget);
|
||||||
@@ -250,36 +252,109 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_capture_feed_frame_when_opening_series_from_picker', (
|
testWidgets('should_show_non_blocking_loading_when_opening_series', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final factory = _RouteVideoFactory();
|
final initialization = Completer<void>();
|
||||||
await _pumpApp(tester, playerFactory: factory.create);
|
final routeFactory = _RouteVideoFactory();
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
playerFactory: (episode) => episode.id == 'the-signal-demo-2'
|
||||||
|
? _GatedPlayer(initialization.future)
|
||||||
|
: routeFactory.create(episode),
|
||||||
|
);
|
||||||
await tester.tap(find.byKey(const Key('tab-recommend')));
|
await tester.tap(find.byKey(const Key('tab-recommend')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('rail-episodes')));
|
await tester.tap(find.byKey(const Key('rail-episodes')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('player-episode-2')));
|
await tester.tap(find.byKey(const Key('player-episode-2')));
|
||||||
await _waitForRevealedPlayerEntry(tester);
|
await tester.pump();
|
||||||
await tester.pumpAndSettle();
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
|
||||||
final player = find.byWidgetPredicate(
|
final loadingOverlay = find.byKey(
|
||||||
(widget) => widget is PlayerPage && widget.revealAfterFirstFrame,
|
const Key('player-entry-loading-overlay'),
|
||||||
);
|
);
|
||||||
expect(player, findsOneWidget);
|
final fullPlayer = find.byType(PlayerPage).last;
|
||||||
final playerWidget = tester.widget<PlayerPage>(player);
|
final fullLogic = _pageLogic(tester, fullPlayer);
|
||||||
|
final fullPlayerPage = find
|
||||||
|
.descendant(
|
||||||
|
of: fullPlayer,
|
||||||
|
matching: find.byWidgetPredicate(
|
||||||
|
(widget) =>
|
||||||
|
widget is PageView && widget.scrollDirection == Axis.vertical,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
expect(loadingOverlay, findsOneWidget);
|
||||||
|
expect(tester.widget<IgnorePointer>(loadingOverlay).ignoring, isTrue);
|
||||||
expect(
|
expect(
|
||||||
playerWidget.initialFrameBytes != null ||
|
tester
|
||||||
playerWidget.initialFrameProvider != null,
|
.widget<AnimatedOpacity>(
|
||||||
isTrue,
|
find.descendant(
|
||||||
|
of: loadingOverlay,
|
||||||
|
matching: find.byType(AnimatedOpacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.duration,
|
||||||
|
const Duration(milliseconds: 140),
|
||||||
);
|
);
|
||||||
expect(playerWidget.revealAfterFirstFrame, isTrue);
|
expect(
|
||||||
expect(playerWidget.initialIndex, 1);
|
tester.getSize(find.byKey(const Key('player-loading-surface'))),
|
||||||
|
const Size(40, 40),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('player-entry-loading'))),
|
||||||
|
const Size(20, 20),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CircularProgressIndicator>(
|
||||||
|
find.byKey(const Key('player-entry-loading')),
|
||||||
|
)
|
||||||
|
.color,
|
||||||
|
Colors.white,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<ColoredBox>(
|
||||||
|
find.descendant(
|
||||||
|
of: loadingOverlay,
|
||||||
|
matching: find.byType(ColoredBox),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.color,
|
||||||
|
AppColors.mediaBlack,
|
||||||
|
);
|
||||||
|
final loadingSurface = tester.widget<Container>(
|
||||||
|
find.byKey(const Key('player-loading-surface')),
|
||||||
|
);
|
||||||
|
final decoration = loadingSurface.decoration! as BoxDecoration;
|
||||||
|
expect(decoration.color, AppColors.refreshIndicatorSurface);
|
||||||
|
expect(decoration.borderRadius, BorderRadius.circular(20));
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('player-back')));
|
// IgnorePointer 让加载中的播放暂停与上下滑仍能透传。
|
||||||
|
expect(fullLogic.session.userWantsPlay, isTrue);
|
||||||
|
await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable());
|
||||||
|
await tester.pump();
|
||||||
|
expect(fullLogic.session.userWantsPlay, isFalse);
|
||||||
|
final indexBeforeSwipe = fullLogic.session.currentIndex;
|
||||||
|
await tester.drag(
|
||||||
|
fullPlayerPage,
|
||||||
|
const Offset(0, -560),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe));
|
||||||
|
|
||||||
|
// IgnorePointer 让加载中的返回按钮仍可点击。
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(loadingOverlay, findsNothing);
|
||||||
|
await tester.tap(find.byKey(const Key('player-back')).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
|
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
|
||||||
|
initialization.complete();
|
||||||
|
await tester.pump();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async {
|
testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async {
|
||||||
@@ -304,7 +379,6 @@ void main() {
|
|||||||
expect(feedIndex, 1);
|
expect(feedIndex, 1);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await _waitForFullPlayerEntry(tester);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final fullLogic = _pageLogic(tester, _fullPlayerFinder());
|
final fullLogic = _pageLogic(tester, _fullPlayerFinder());
|
||||||
@@ -1700,35 +1774,6 @@ Finder _fullPlayerFinder() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _waitForFullPlayerEntry(WidgetTester tester) async {
|
|
||||||
for (var attempt = 0; attempt < 120; attempt++) {
|
|
||||||
await tester.runAsync(
|
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
if (_fullPlayerFinder().evaluate().isNotEmpty) return;
|
|
||||||
}
|
|
||||||
fail('full player route was not pushed after frame capture');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _waitForRevealedPlayerEntry(WidgetTester tester) async {
|
|
||||||
for (var attempt = 0; attempt < 120; attempt++) {
|
|
||||||
await tester.runAsync(
|
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
if (find
|
|
||||||
.byWidgetPredicate(
|
|
||||||
(widget) => widget is PlayerPage && widget.revealAfterFirstFrame,
|
|
||||||
)
|
|
||||||
.evaluate()
|
|
||||||
.isNotEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fail('revealed player route was not pushed after frame capture');
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RouteVideoFactory {
|
class _RouteVideoFactory {
|
||||||
final List<_RouteVideoPlayer> created = [];
|
final List<_RouteVideoPlayer> created = [];
|
||||||
final List<Episode> episodes = [];
|
final List<Episode> episodes = [];
|
||||||
|
|||||||
@@ -470,7 +470,6 @@ void main() {
|
|||||||
await _pumpRecommend(tester);
|
await _pumpRecommend(tester);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-series-name')));
|
await tester.tap(find.byKey(const Key('home-feed-series-name')));
|
||||||
await _waitForFullPlayer(tester);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(PlayerPage).last, findsOneWidget);
|
expect(find.byType(PlayerPage).last, findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
@@ -485,7 +484,6 @@ void main() {
|
|||||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||||
await _waitForFullPlayer(tester);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(PlayerPage).last, findsOneWidget);
|
expect(find.byType(PlayerPage).last, findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
@@ -552,24 +550,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _waitForFullPlayer(WidgetTester tester) async {
|
|
||||||
for (var attempt = 0; attempt < 120; attempt++) {
|
|
||||||
await tester.runAsync(
|
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
|
||||||
);
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
if (find
|
|
||||||
.byWidgetPredicate(
|
|
||||||
(widget) => widget is PlayerPage && !widget.homeMode,
|
|
||||||
)
|
|
||||||
.evaluate()
|
|
||||||
.isNotEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fail('full player route was not pushed after frame capture');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pumpRecommend(WidgetTester tester) async {
|
Future<void> _pumpRecommend(WidgetTester tester) async {
|
||||||
tester.view.physicalSize = const Size(390, 844);
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|||||||
Reference in new issue
Block a user