fix(player): 持久化竖滑播放画面层
This commit is contained in:
1 parent
5dafc68775
commit
2a32dec183
5 files changed
+539
-47
No files matched your search
@@ -40,6 +40,7 @@ import 'widgets/player_center_toggle.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';
|
||||||
|
import 'widgets/player_render_stack.dart';
|
||||||
import 'widgets/player_speed_sheet.dart';
|
import 'widgets/player_speed_sheet.dart';
|
||||||
import 'widgets/player_unlock_sheet.dart';
|
import 'widgets/player_unlock_sheet.dart';
|
||||||
import 'widgets/player_opening_frame.dart';
|
import 'widgets/player_opening_frame.dart';
|
||||||
@@ -528,6 +529,14 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
itemCount: indices.length,
|
itemCount: indices.length,
|
||||||
onPageChanged: (position) {
|
onPageChanged: (position) {
|
||||||
final index = indices[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);
|
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||||
unawaited(_selectPage(index));
|
unawaited(_selectPage(index));
|
||||||
if (position >= indices.length - 3) {
|
if (position >= indices.length - 3) {
|
||||||
@@ -535,17 +544,38 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
if (loadMore != null) unawaited(loadMore());
|
if (loadMore != null) unawaited(loadMore());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context, position) =>
|
itemBuilder: (context, position) {
|
||||||
_buildEpisode(context, indices[position], interactions),
|
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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (widget.onRefreshFeed == null) return pages;
|
final pageLayer = widget.onRefreshFeed == null
|
||||||
return FeedPullRefresh(
|
? pages
|
||||||
|
: FeedPullRefresh(
|
||||||
pageController: logic.pages,
|
pageController: logic.pages,
|
||||||
topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
|
topInset:
|
||||||
|
MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
|
||||||
onRefresh: _refreshHomeFeed,
|
onRefresh: _refreshHomeFeed,
|
||||||
child: pages,
|
child: pages,
|
||||||
);
|
);
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
PlayerRenderStack(
|
||||||
|
pageController: logic.pages,
|
||||||
|
slots: _renderSlots(indices),
|
||||||
|
),
|
||||||
|
pageLayer,
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final body = Stack(
|
final body = Stack(
|
||||||
@@ -555,7 +585,6 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
if (widget.homeMode &&
|
if (widget.homeMode &&
|
||||||
widget.showHomeFeedHeader &&
|
widget.showHomeFeedHeader &&
|
||||||
!_scrubbing &&
|
!_scrubbing &&
|
||||||
_chromeVisible &&
|
|
||||||
!_boosting &&
|
!_boosting &&
|
||||||
!logic.pip &&
|
!logic.pip &&
|
||||||
!logic.pipPreparing)
|
!logic.pipPreparing)
|
||||||
@@ -616,19 +645,13 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
_openMore(context, entry.episode);
|
_openMore(context, entry.episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEpisode(
|
Episode _resolvedEpisode(int index) {
|
||||||
BuildContext context,
|
|
||||||
int index,
|
|
||||||
InteractionController interactions,
|
|
||||||
) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final entry = logic.queue.entryAt(index);
|
final entry = logic.queue.entryAt(index);
|
||||||
final drama = entry.drama;
|
|
||||||
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
|
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
|
||||||
? logic.sessionEpisodes[index]
|
? logic.sessionEpisodes[index]
|
||||||
: entry.episode;
|
: entry.episode;
|
||||||
final displayed = widget.homeMode
|
final displayed = widget.homeMode
|
||||||
? logic.displayEpisode(drama, sessionEpisode)
|
? logic.displayEpisode(entry.drama, sessionEpisode)
|
||||||
: sessionEpisode;
|
: sessionEpisode;
|
||||||
final needsSessionThumbnails =
|
final needsSessionThumbnails =
|
||||||
(displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) &&
|
(displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) &&
|
||||||
@@ -636,20 +659,60 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final needsSessionPoster =
|
final needsSessionPoster =
|
||||||
(displayed.posterUrl == null || displayed.posterUrl!.isEmpty) &&
|
(displayed.posterUrl == null || displayed.posterUrl!.isEmpty) &&
|
||||||
sessionEpisode.posterUrl != null;
|
sessionEpisode.posterUrl != null;
|
||||||
final episode = needsSessionThumbnails || needsSessionPoster
|
if (!needsSessionThumbnails && !needsSessionPoster) return displayed;
|
||||||
? displayed.withVideoUrl(
|
return displayed.withVideoUrl(
|
||||||
displayed.videoUrl,
|
displayed.videoUrl,
|
||||||
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
|
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
|
||||||
posterUrl: sessionEpisode.posterUrl,
|
posterUrl: sessionEpisode.posterUrl,
|
||||||
)
|
);
|
||||||
: displayed;
|
}
|
||||||
final selected = index == logic.session.currentIndex;
|
|
||||||
|
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 =
|
final locked =
|
||||||
logic.isEpisodeLocked(episode) ||
|
logic.isEpisodeLocked(episode) ||
|
||||||
logic.session.isLocked ||
|
(selected && logic.session.isLocked) ||
|
||||||
logic.blockedEpisodeId == episode.id;
|
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
|
final port = locked
|
||||||
? null
|
? null
|
||||||
: (selected ? logic.session.active : null) ??
|
: (selected ? logic.session.active : null) ??
|
||||||
@@ -657,10 +720,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final videoPort = port is VideoPlayerPort ? port : null;
|
final videoPort = port is VideoPlayerPort ? port : null;
|
||||||
final handoff = selected && !locked ? logic.session.handoffAt(index) : null;
|
final handoff = selected && !locked ? logic.session.handoffAt(index) : null;
|
||||||
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;
|
||||||
if (selected && videoReady) _logSwipeReveal();
|
|
||||||
final ready = !locked && (videoReady || port != null);
|
|
||||||
final theater = logic.pip || logic.pipPreparing;
|
final theater = logic.pip || logic.pipPreparing;
|
||||||
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
|
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
|
||||||
final preparedFrameAvailable =
|
final preparedFrameAvailable =
|
||||||
@@ -669,14 +729,13 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
(selected
|
(selected
|
||||||
? logic.session.reusedWarmAt(index)
|
? logic.session.reusedWarmAt(index)
|
||||||
: logic.session.preparedPlayerAt(index) != null);
|
: logic.session.preparedPlayerAt(index) != null);
|
||||||
final warmSlotReused = logic.session.reusedWarmAt(index);
|
|
||||||
final thumbnailsUrl = episode.thumbnailsUrl;
|
final thumbnailsUrl = episode.thumbnailsUrl;
|
||||||
final posterUrl = episode.posterUrl;
|
final posterUrl = episode.posterUrl;
|
||||||
final posterReady = posterUrl?.trim().isNotEmpty ?? false;
|
final posterReady = posterUrl?.trim().isNotEmpty ?? false;
|
||||||
final openingFrameReady =
|
final thumbnailReady =
|
||||||
thumbnailsUrl != null &&
|
thumbnailsUrl != null &&
|
||||||
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
|
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
|
||||||
if (!posterReady && thumbnailsUrl != null) {
|
if (!locked && !posterReady && thumbnailsUrl != null) {
|
||||||
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
|
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
|
||||||
}
|
}
|
||||||
if (selected &&
|
if (selected &&
|
||||||
@@ -685,27 +744,21 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
videoPort.controller.value.isInitialized) {
|
videoPort.controller.value.isInitialized) {
|
||||||
unawaited(
|
unawaited(
|
||||||
_spriteAxisService.prepareAt(
|
_spriteAxisService.prepareAt(
|
||||||
episode.thumbnailsUrl,
|
thumbnailsUrl,
|
||||||
videoPort.controller.value.position,
|
videoPort.controller.value.position,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final pagePosition = indices.indexOf(index);
|
||||||
return Stack(
|
return PlayerRenderSlotData(
|
||||||
fit: StackFit.expand,
|
role: role,
|
||||||
children: [
|
index: index,
|
||||||
if (widget.homeMode)
|
pagePosition: pagePosition >= 0 ? pagePosition.toDouble() : null,
|
||||||
const ColoredBox(
|
|
||||||
key: Key('home-feed-black-background'),
|
|
||||||
color: AppColors.mediaBlack,
|
|
||||||
),
|
|
||||||
PlayerVideoSurface(
|
|
||||||
controller: locked ? null : videoPort?.controller,
|
controller: locked ? null : videoPort?.controller,
|
||||||
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
||||||
theater: theater,
|
theater: theater,
|
||||||
warmFrameAvailable: preparedFrameAvailable,
|
warmFrameAvailable: preparedFrameAvailable,
|
||||||
warmSlotReused: warmSlotReused,
|
thumbnailReady: thumbnailReady,
|
||||||
thumbnailReady: openingFrameReady,
|
|
||||||
posterReady: posterReady,
|
posterReady: posterReady,
|
||||||
fallback: handoffPort != null
|
fallback: handoffPort != null
|
||||||
? PlayerOpeningFallback.handoff
|
? PlayerOpeningFallback.handoff
|
||||||
@@ -713,6 +766,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
posterUrl: posterUrl,
|
posterUrl: posterUrl,
|
||||||
thumbnailsUrl: thumbnailsUrl,
|
thumbnailsUrl: thumbnailsUrl,
|
||||||
),
|
),
|
||||||
|
warmSlotReused: logic.session.reusedWarmAt(index),
|
||||||
waitForPlaybackFrame: logic.session.userWantsPlay,
|
waitForPlaybackFrame: logic.session.userWantsPlay,
|
||||||
placeholder: locked
|
placeholder: locked
|
||||||
? null
|
? null
|
||||||
@@ -727,7 +781,39 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
spriteSource: _spriteAxisService,
|
spriteSource: _spriteAxisService,
|
||||||
fit: videoFit,
|
fit: videoFit,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEpisode(
|
||||||
|
BuildContext context,
|
||||||
|
int index,
|
||||||
|
InteractionController interactions,
|
||||||
|
) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final entry = logic.queue.entryAt(index);
|
||||||
|
final drama = entry.drama;
|
||||||
|
final episode = _resolvedEpisode(index);
|
||||||
|
final selected = index == logic.session.currentIndex;
|
||||||
|
final locked =
|
||||||
|
logic.isEpisodeLocked(episode) ||
|
||||||
|
logic.session.isLocked ||
|
||||||
|
logic.blockedEpisodeId == episode.id;
|
||||||
|
// Selection changes before the prepared slot is promoted. Use its controller
|
||||||
|
// throughout the handoff instead of briefly falling back to a placeholder.
|
||||||
|
final port = locked
|
||||||
|
? null
|
||||||
|
: (selected ? logic.session.active : null) ??
|
||||||
|
logic.session.preparedPlayerAt(index);
|
||||||
|
final videoPort = port is VideoPlayerPort ? port : null;
|
||||||
|
// 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。
|
||||||
|
final videoReady = videoPort?.controller.value.isInitialized ?? false;
|
||||||
|
if (selected && videoReady) _logSwipeReveal();
|
||||||
|
final ready = !locked && (videoReady || port != null);
|
||||||
|
final theater = logic.pip || logic.pipPreparing;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
if (selected &&
|
if (selected &&
|
||||||
!locked &&
|
!locked &&
|
||||||
!theater &&
|
!theater &&
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import 'player_opening_frame.dart';
|
||||||
|
import 'player_video_surface.dart';
|
||||||
|
|
||||||
|
enum PlayerRenderSlotRole { previous, current, next }
|
||||||
|
|
||||||
|
class PlayerRenderSlotData {
|
||||||
|
const PlayerRenderSlotData({
|
||||||
|
required this.role,
|
||||||
|
this.index,
|
||||||
|
this.pagePosition,
|
||||||
|
this.controller,
|
||||||
|
this.videoUrl = '',
|
||||||
|
this.theater = false,
|
||||||
|
this.placeholder,
|
||||||
|
this.warmFrameAvailable = false,
|
||||||
|
this.thumbnailReady = false,
|
||||||
|
this.posterReady = false,
|
||||||
|
this.fallback = PlayerOpeningFallback.black,
|
||||||
|
this.warmSlotReused = false,
|
||||||
|
this.waitForPlaybackFrame = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PlayerRenderSlotRole role;
|
||||||
|
final int? index;
|
||||||
|
final double? pagePosition;
|
||||||
|
final VideoPlayerController? controller;
|
||||||
|
final String videoUrl;
|
||||||
|
final bool theater;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final bool warmFrameAvailable;
|
||||||
|
final bool thumbnailReady;
|
||||||
|
final bool posterReady;
|
||||||
|
final PlayerOpeningFallback fallback;
|
||||||
|
final bool warmSlotReused;
|
||||||
|
final bool waitForPlaybackFrame;
|
||||||
|
|
||||||
|
factory PlayerRenderSlotData.empty(PlayerRenderSlotRole role) {
|
||||||
|
return PlayerRenderSlotData(role: role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放画面不随 PageView 生命周期销毁,三个固定槽位只更新控制器和位置。
|
||||||
|
class PlayerRenderStack extends StatefulWidget {
|
||||||
|
const PlayerRenderStack({
|
||||||
|
super.key,
|
||||||
|
required this.pageController,
|
||||||
|
required this.slots,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PageController pageController;
|
||||||
|
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerRenderStackState extends State<PlayerRenderStack> {
|
||||||
|
int? _lastCurrentIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_lastCurrentIndex = _currentSlot.index;
|
||||||
|
_log('init current=${_lastCurrentIndex ?? '-'}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant PlayerRenderStack oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final currentIndex = _currentSlot.index;
|
||||||
|
if (_lastCurrentIndex != currentIndex) {
|
||||||
|
_log(
|
||||||
|
'session-index previous=${_lastCurrentIndex ?? '-'} '
|
||||||
|
'current=${currentIndex ?? '-'}',
|
||||||
|
);
|
||||||
|
_lastCurrentIndex = currentIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_log('dispose current=${_lastCurrentIndex ?? '-'}');
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerRenderSlotData get _currentSlot =>
|
||||||
|
widget.slots[PlayerRenderSlotRole.current] ??
|
||||||
|
PlayerRenderSlotData.empty(PlayerRenderSlotRole.current);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: widget.pageController,
|
||||||
|
builder: (context, _) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final viewportHeight = constraints.maxHeight.isFinite
|
||||||
|
? constraints.maxHeight
|
||||||
|
: MediaQuery.sizeOf(context).height;
|
||||||
|
final pageOffset = _pageOffset();
|
||||||
|
final currentPage = _currentSlot.pagePosition ?? pageOffset;
|
||||||
|
final slots = [
|
||||||
|
_slotData(PlayerRenderSlotRole.previous),
|
||||||
|
_slotData(PlayerRenderSlotRole.current),
|
||||||
|
_slotData(PlayerRenderSlotRole.next),
|
||||||
|
];
|
||||||
|
return Stack(
|
||||||
|
key: const Key('player-render-stack'),
|
||||||
|
fit: StackFit.expand,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
const ColoredBox(
|
||||||
|
key: Key('player-fallback-background'),
|
||||||
|
color: AppColors.mediaBlack,
|
||||||
|
),
|
||||||
|
for (final slot in slots)
|
||||||
|
_positionedSurface(
|
||||||
|
slot,
|
||||||
|
pageOffset: pageOffset,
|
||||||
|
viewportHeight: viewportHeight,
|
||||||
|
fallbackPage: _fallbackPage(slot.role, currentPage),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerRenderSlotData _slotData(PlayerRenderSlotRole role) {
|
||||||
|
return widget.slots[role] ?? PlayerRenderSlotData.empty(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _pageOffset() {
|
||||||
|
if (!widget.pageController.hasClients) {
|
||||||
|
return widget.pageController.initialPage.toDouble();
|
||||||
|
}
|
||||||
|
final position = widget.pageController.position;
|
||||||
|
if (!position.hasPixels ||
|
||||||
|
!position.hasViewportDimension ||
|
||||||
|
position.viewportDimension <= 0) {
|
||||||
|
return widget.pageController.initialPage.toDouble();
|
||||||
|
}
|
||||||
|
return position.pixels / position.viewportDimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _fallbackPage(PlayerRenderSlotRole role, double currentPage) {
|
||||||
|
return switch (role) {
|
||||||
|
PlayerRenderSlotRole.previous => currentPage - 1,
|
||||||
|
PlayerRenderSlotRole.current => currentPage,
|
||||||
|
PlayerRenderSlotRole.next => currentPage + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _positionedSurface(
|
||||||
|
PlayerRenderSlotData slot, {
|
||||||
|
required double pageOffset,
|
||||||
|
required double viewportHeight,
|
||||||
|
required double fallbackPage,
|
||||||
|
}) {
|
||||||
|
final slotPage = slot.pagePosition ?? fallbackPage;
|
||||||
|
final offset = (slotPage - pageOffset) * viewportHeight;
|
||||||
|
return Positioned.fill(
|
||||||
|
child: Transform.translate(
|
||||||
|
key: Key('player-render-transform-${slot.role.name}'),
|
||||||
|
offset: Offset(0, offset),
|
||||||
|
child: PlayerVideoSurface(
|
||||||
|
key: ValueKey('player-render-slot-${slot.role.name}'),
|
||||||
|
debugSlot: slot.role.name,
|
||||||
|
debugIndex: slot.index,
|
||||||
|
showFallbackBackground: false,
|
||||||
|
controller: slot.controller,
|
||||||
|
videoUrl: slot.videoUrl,
|
||||||
|
theater: slot.theater,
|
||||||
|
placeholder: slot.placeholder,
|
||||||
|
warmFrameAvailable: slot.warmFrameAvailable,
|
||||||
|
thumbnailReady: slot.thumbnailReady,
|
||||||
|
posterReady: slot.posterReady,
|
||||||
|
fallback: slot.fallback,
|
||||||
|
warmSlotReused: slot.warmSlotReused,
|
||||||
|
waitForPlaybackFrame: slot.waitForPlaybackFrame,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String message) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
debugPrint(
|
||||||
|
'[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
this.fallback = PlayerOpeningFallback.black,
|
this.fallback = PlayerOpeningFallback.black,
|
||||||
this.warmSlotReused = false,
|
this.warmSlotReused = false,
|
||||||
this.waitForPlaybackFrame = false,
|
this.waitForPlaybackFrame = false,
|
||||||
|
this.debugSlot = 'default',
|
||||||
|
this.debugIndex,
|
||||||
|
this.showFallbackBackground = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VideoPlayerController? controller;
|
final VideoPlayerController? controller;
|
||||||
@@ -49,6 +52,9 @@ class PlayerVideoSurface extends StatefulWidget {
|
|||||||
|
|
||||||
/// 自动播放中的新集等待 position 首次前进后再移交画面。
|
/// 自动播放中的新集等待 position 首次前进后再移交画面。
|
||||||
final bool waitForPlaybackFrame;
|
final bool waitForPlaybackFrame;
|
||||||
|
final String debugSlot;
|
||||||
|
final int? debugIndex;
|
||||||
|
final bool showFallbackBackground;
|
||||||
final String videoUrl;
|
final String videoUrl;
|
||||||
final bool theater;
|
final bool theater;
|
||||||
|
|
||||||
@@ -79,8 +85,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
final value = widget.controller?.value;
|
final value = widget.controller?.value;
|
||||||
_updatePlaybackFlags(value);
|
_updatePlaybackFlags(value);
|
||||||
_videoSize = value != null && value.isInitialized ? value.size : null;
|
_videoSize = value != null && value.isInitialized ? value.size : null;
|
||||||
|
_logSlot('init', value);
|
||||||
if (_firstFrameConfirmed(value)) {
|
if (_firstFrameConfirmed(value)) {
|
||||||
_confirmFirstFrame(initial: true);
|
_confirmFirstFrame(initial: true);
|
||||||
|
} else {
|
||||||
|
_logLayer('placeholder', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +97,19 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.controller != widget.controller) {
|
if (oldWidget.controller != widget.controller) {
|
||||||
|
_logSlot('controller-change-before', oldWidget.controller?.value);
|
||||||
_detachController(oldWidget.controller);
|
_detachController(oldWidget.controller);
|
||||||
_resetForControllerChange();
|
_resetForControllerChange();
|
||||||
_attachController(widget.controller);
|
_attachController(widget.controller);
|
||||||
final value = widget.controller?.value;
|
final value = widget.controller?.value;
|
||||||
_updatePlaybackFlags(value);
|
_updatePlaybackFlags(value);
|
||||||
_videoSize = value != null && value.isInitialized ? value.size : null;
|
_videoSize = value != null && value.isInitialized ? value.size : null;
|
||||||
if (_firstFrameConfirmed(value)) _confirmFirstFrame(initial: true);
|
_logSlot('controller-change-after', value);
|
||||||
|
if (_firstFrameConfirmed(value)) {
|
||||||
|
_confirmFirstFrame(initial: true);
|
||||||
|
} else {
|
||||||
|
_logLayer('placeholder', value);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_transitioning &&
|
if (!_transitioning &&
|
||||||
@@ -119,6 +134,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_logSlot('dispose', widget.controller?.value);
|
||||||
_detachController(widget.controller);
|
_detachController(widget.controller);
|
||||||
_crossFade
|
_crossFade
|
||||||
..removeStatusListener(_handleFadeStatus)
|
..removeStatusListener(_handleFadeStatus)
|
||||||
@@ -143,6 +159,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.showFallbackBackground)
|
||||||
const ColoredBox(
|
const ColoredBox(
|
||||||
key: Key('player-fallback-background'),
|
key: Key('player-fallback-background'),
|
||||||
color: AppColors.mediaBlack,
|
color: AppColors.mediaBlack,
|
||||||
@@ -210,14 +227,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
_confirmationScheduled = true;
|
_confirmationScheduled = true;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
|
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
|
||||||
|
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
|
||||||
'phase=first-frame-scheduled',
|
'phase=first-frame-scheduled',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
|
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
|
||||||
|
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
|
||||||
'phase=first-frame-callback mounted=$mounted',
|
'phase=first-frame-callback mounted=$mounted',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -246,6 +265,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
void _confirmFirstFrame({bool initial = false}) {
|
void _confirmFirstFrame({bool initial = false}) {
|
||||||
if (_frameConfirmed || _videoSize == null) return;
|
if (_frameConfirmed || _videoSize == null) return;
|
||||||
_frameConfirmed = true;
|
_frameConfirmed = true;
|
||||||
|
_logSlot('firstFrameRendered', widget.controller?.value);
|
||||||
final canSkipPlaceholder =
|
final canSkipPlaceholder =
|
||||||
widget.warmFrameAvailable || _placeholderSnapshot == null;
|
widget.warmFrameAvailable || _placeholderSnapshot == null;
|
||||||
if (canSkipPlaceholder) {
|
if (canSkipPlaceholder) {
|
||||||
@@ -303,6 +323,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
? 'video'
|
? 'video'
|
||||||
: _fallbackLabel(widget.fallback);
|
: _fallbackLabel(widget.fallback);
|
||||||
final signature =
|
final signature =
|
||||||
|
'slot=${widget.debugSlot} '
|
||||||
|
'index=${widget.debugIndex ?? '-'} '
|
||||||
'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
|
'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
|
||||||
'reusedWarm=${widget.warmSlotReused ? 1 : 0} '
|
'reusedWarm=${widget.warmSlotReused ? 1 : 0} '
|
||||||
'frame=${_frameConfirmed ? 1 : 0} '
|
'frame=${_frameConfirmed ? 1 : 0} '
|
||||||
@@ -312,14 +334,34 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
|||||||
'init=${value?.isInitialized == true ? 1 : 0} '
|
'init=${value?.isInitialized == true ? 1 : 0} '
|
||||||
'position=${value?.position.inMilliseconds ?? 0} '
|
'position=${value?.position.inMilliseconds ?? 0} '
|
||||||
'playing=${value?.isPlaying == true ? 1 : 0} '
|
'playing=${value?.isPlaying == true ? 1 : 0} '
|
||||||
'buffering=${value?.isBuffering == true ? 1 : 0}';
|
'buffering=${value?.isBuffering == true ? 1 : 0} '
|
||||||
|
'placeholder=${_placeholderLabel()}';
|
||||||
if (signature == _lastLayerState) return;
|
if (signature == _lastLayerState) return;
|
||||||
_lastLayerState = signature;
|
_lastLayerState = signature;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature',
|
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} $signature',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _logSlot(String phase, VideoPlayerValue? value) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
debugPrint(
|
||||||
|
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
|
||||||
|
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
|
||||||
|
'phase=$phase init=${value?.isInitialized == true ? 1 : 0}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _placeholderLabel() {
|
||||||
|
if (_placeholderSnapshot == null) return 'none';
|
||||||
|
return switch (widget.fallback) {
|
||||||
|
PlayerOpeningFallback.handoff => 'handoff',
|
||||||
|
PlayerOpeningFallback.poster => 'poster',
|
||||||
|
PlayerOpeningFallback.thumbnails => 'thumbnails',
|
||||||
|
PlayerOpeningFallback.black => 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
String _fallbackLabel(PlayerOpeningFallback fallback) {
|
String _fallbackLabel(PlayerOpeningFallback fallback) {
|
||||||
if (_placeholderSnapshot == null) return 'black';
|
if (_placeholderSnapshot == null) return 'black';
|
||||||
return switch (fallback) {
|
return switch (fallback) {
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ void main() {
|
|||||||
pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical),
|
pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical),
|
||||||
hasLength(1),
|
hasLength(1),
|
||||||
);
|
);
|
||||||
|
expect(find.byKey(const Key('player-render-stack')), findsOneWidget);
|
||||||
|
final renderStackPageAncestors = tester.widgetList<PageView>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byKey(const Key('player-render-stack')),
|
||||||
|
matching: find.byType(PageView),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
renderStackPageAncestors.where(
|
||||||
|
(pageView) => pageView.scrollDirection == Axis.vertical,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_keep_slots_alive_and_only_translate_during_page_change', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final pages = PageController(initialPage: 1);
|
||||||
|
final previousController = _controller('previous');
|
||||||
|
final currentController = _controller('current');
|
||||||
|
final nextController = _controller('next');
|
||||||
|
addTearDown(pages.dispose);
|
||||||
|
addTearDown(previousController.dispose);
|
||||||
|
addTearDown(currentController.dispose);
|
||||||
|
addTearDown(nextController.dispose);
|
||||||
|
|
||||||
|
var currentIndex = 1;
|
||||||
|
late StateSetter rebuild;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
rebuild = setState;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
PlayerRenderStack(
|
||||||
|
pageController: pages,
|
||||||
|
slots: _slots(
|
||||||
|
currentIndex: currentIndex,
|
||||||
|
previousController: previousController,
|
||||||
|
currentController: currentController,
|
||||||
|
nextController: nextController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PageView(
|
||||||
|
controller: pages,
|
||||||
|
children: const [
|
||||||
|
SizedBox.expand(key: Key('page-0')),
|
||||||
|
SizedBox.expand(key: Key('page-1')),
|
||||||
|
SizedBox.expand(key: Key('page-2')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final currentSlot = find.byKey(
|
||||||
|
const ValueKey('player-render-slot-current'),
|
||||||
|
);
|
||||||
|
final currentState = tester.state(currentSlot);
|
||||||
|
final currentElement = tester.element(currentSlot);
|
||||||
|
final currentSurface = tester.widget<PlayerVideoSurface>(currentSlot);
|
||||||
|
expect(currentSurface.controller, same(currentController));
|
||||||
|
expect(_translationY(tester, 'current'), 0);
|
||||||
|
expect(_translationY(tester, 'next'), greaterThan(0));
|
||||||
|
|
||||||
|
pages.jumpToPage(2);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.state(currentSlot), same(currentState));
|
||||||
|
expect(tester.element(currentSlot), same(currentElement));
|
||||||
|
expect(
|
||||||
|
tester.widget<PlayerVideoSurface>(currentSlot).controller,
|
||||||
|
same(currentController),
|
||||||
|
);
|
||||||
|
expect(_translationY(tester, 'current'), lessThan(0));
|
||||||
|
expect(_translationY(tester, 'next'), 0);
|
||||||
|
|
||||||
|
rebuild(() => currentIndex = 2);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.state(currentSlot), same(currentState));
|
||||||
|
expect(tester.element(currentSlot), same(currentElement));
|
||||||
|
expect(_translationY(tester, 'current'), 0);
|
||||||
|
expect(_translationY(tester, 'previous'), lessThan(0));
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(PlayerRenderStack),
|
||||||
|
matching: find.byType(IgnorePointer),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<PlayerRenderSlotRole, PlayerRenderSlotData> _slots({
|
||||||
|
required int currentIndex,
|
||||||
|
required VideoPlayerController previousController,
|
||||||
|
required VideoPlayerController currentController,
|
||||||
|
required VideoPlayerController nextController,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
PlayerRenderSlotRole.previous: PlayerRenderSlotData(
|
||||||
|
role: PlayerRenderSlotRole.previous,
|
||||||
|
index: currentIndex - 1,
|
||||||
|
pagePosition: (currentIndex - 1).toDouble(),
|
||||||
|
controller: previousController,
|
||||||
|
placeholder: const ColoredBox(
|
||||||
|
key: Key('previous-placeholder'),
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlayerRenderSlotRole.current: PlayerRenderSlotData(
|
||||||
|
role: PlayerRenderSlotRole.current,
|
||||||
|
index: currentIndex,
|
||||||
|
pagePosition: currentIndex.toDouble(),
|
||||||
|
controller: currentController,
|
||||||
|
placeholder: const ColoredBox(
|
||||||
|
key: Key('current-placeholder'),
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PlayerRenderSlotRole.next: PlayerRenderSlotData(
|
||||||
|
role: PlayerRenderSlotRole.next,
|
||||||
|
index: currentIndex + 1,
|
||||||
|
pagePosition: (currentIndex + 1).toDouble(),
|
||||||
|
controller: nextController,
|
||||||
|
placeholder: const ColoredBox(
|
||||||
|
key: Key('next-placeholder'),
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayerController _controller(String name) {
|
||||||
|
return VideoPlayerController.networkUrl(
|
||||||
|
Uri.parse('https://example.test/$name.mp4'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _translationY(WidgetTester tester, String role) {
|
||||||
|
final transform = tester.widget<Transform>(
|
||||||
|
find.byKey(Key('player-render-transform-$role')),
|
||||||
|
);
|
||||||
|
return transform.transform.getTranslation().y;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user