fix(player): 持久化竖滑播放画面层
This commit is contained in:
1 parent
5dafc68775
commit
2a32dec183
5 files changed
+593
-101
No files matched your search
@@ -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.warmSlotReused = false,
|
||||
this.waitForPlaybackFrame = false,
|
||||
this.debugSlot = 'default',
|
||||
this.debugIndex,
|
||||
this.showFallbackBackground = true,
|
||||
});
|
||||
|
||||
final VideoPlayerController? controller;
|
||||
@@ -49,6 +52,9 @@ class PlayerVideoSurface extends StatefulWidget {
|
||||
|
||||
/// 自动播放中的新集等待 position 首次前进后再移交画面。
|
||||
final bool waitForPlaybackFrame;
|
||||
final String debugSlot;
|
||||
final int? debugIndex;
|
||||
final bool showFallbackBackground;
|
||||
final String videoUrl;
|
||||
final bool theater;
|
||||
|
||||
@@ -79,8 +85,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
final value = widget.controller?.value;
|
||||
_updatePlaybackFlags(value);
|
||||
_videoSize = value != null && value.isInitialized ? value.size : null;
|
||||
_logSlot('init', value);
|
||||
if (_firstFrameConfirmed(value)) {
|
||||
_confirmFirstFrame(initial: true);
|
||||
} else {
|
||||
_logLayer('placeholder', value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +97,19 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
_logSlot('controller-change-before', oldWidget.controller?.value);
|
||||
_detachController(oldWidget.controller);
|
||||
_resetForControllerChange();
|
||||
_attachController(widget.controller);
|
||||
final value = widget.controller?.value;
|
||||
_updatePlaybackFlags(value);
|
||||
_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;
|
||||
}
|
||||
if (!_transitioning &&
|
||||
@@ -119,6 +134,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSlot('dispose', widget.controller?.value);
|
||||
_detachController(widget.controller);
|
||||
_crossFade
|
||||
..removeStatusListener(_handleFadeStatus)
|
||||
@@ -143,10 +159,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(
|
||||
key: Key('player-fallback-background'),
|
||||
color: AppColors.mediaBlack,
|
||||
),
|
||||
if (widget.showFallbackBackground)
|
||||
const ColoredBox(
|
||||
key: Key('player-fallback-background'),
|
||||
color: AppColors.mediaBlack,
|
||||
),
|
||||
if (showPlaceholder)
|
||||
KeyedSubtree(
|
||||
key: const Key('player-single-placeholder'),
|
||||
@@ -210,14 +227,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
_confirmationScheduled = true;
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} '
|
||||
'[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} '
|
||||
'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} '
|
||||
'phase=first-frame-scheduled',
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (kDebugMode) {
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -246,6 +265,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
void _confirmFirstFrame({bool initial = false}) {
|
||||
if (_frameConfirmed || _videoSize == null) return;
|
||||
_frameConfirmed = true;
|
||||
_logSlot('firstFrameRendered', widget.controller?.value);
|
||||
final canSkipPlaceholder =
|
||||
widget.warmFrameAvailable || _placeholderSnapshot == null;
|
||||
if (canSkipPlaceholder) {
|
||||
@@ -303,6 +323,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
? 'video'
|
||||
: _fallbackLabel(widget.fallback);
|
||||
final signature =
|
||||
'slot=${widget.debugSlot} '
|
||||
'index=${widget.debugIndex ?? '-'} '
|
||||
'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} '
|
||||
'reusedWarm=${widget.warmSlotReused ? 1 : 0} '
|
||||
'frame=${_frameConfirmed ? 1 : 0} '
|
||||
@@ -312,14 +334,34 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
|
||||
'init=${value?.isInitialized == true ? 1 : 0} '
|
||||
'position=${value?.position.inMilliseconds ?? 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;
|
||||
_lastLayerState = signature;
|
||||
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) {
|
||||
if (_placeholderSnapshot == null) return 'black';
|
||||
return switch (fallback) {
|
||||
|
||||
Reference in new issue
Block a user