220 lines
6.7 KiB
Dart
220 lines
6.7 KiB
Dart
// 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,
|
|
this.onCurrentVideoVisible,
|
|
this.resumeSignal = 0,
|
|
});
|
|
|
|
final PageController pageController;
|
|
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
|
|
final ValueChanged<int?>? onCurrentVideoVisible;
|
|
|
|
/// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。
|
|
final int resumeSignal;
|
|
|
|
@override
|
|
State<PlayerRenderStack> createState() => _PlayerRenderStackState();
|
|
}
|
|
|
|
class _PlayerRenderStackState extends State<PlayerRenderStack> {
|
|
int? _lastCurrentIndex;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_lastCurrentIndex = _currentSlot.index;
|
|
_log(
|
|
'init current=${_lastCurrentIndex ?? '-'} '
|
|
'pageController=${identityHashCode(widget.pageController)}',
|
|
);
|
|
}
|
|
|
|
@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 ?? '-'} '
|
|
'pageController=${identityHashCode(widget.pageController)}',
|
|
);
|
|
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,
|
|
onVideoVisible: slot.role == PlayerRenderSlotRole.current
|
|
? () => widget.onCurrentVideoVisible?.call(slot.index)
|
|
: null,
|
|
resumeSignal: widget.resumeSignal,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _log(String message) {
|
|
if (!kDebugMode) return;
|
|
debugPrint(
|
|
'[player-render] ts=${DateTime.now().millisecondsSinceEpoch} $message',
|
|
);
|
|
}
|
|
}
|