novyronst/lib/pages/play/widgets/player_view.dart

526 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
import 'package:video_player/video_player.dart';
class PlayerView extends StatefulWidget {
const PlayerView({
super.key,
required this.videos,
this.initialIndex = 0,
this.onPageChanged,
this.onCollectTap,
this.isLoadingMore = false,
this.isActive = true,
this.bottomMetaSpacing = 76,
this.progressHeaderBuilder,
});
final List<NyDramaModel> videos;
final int initialIndex;
final ValueChanged<int>? onPageChanged;
final ValueChanged<int>? onCollectTap;
final bool isLoadingMore;
final bool isActive;
final double bottomMetaSpacing;
final Widget Function(BuildContext context, NyDramaModel drama, int index)?
progressHeaderBuilder;
@override
State<PlayerView> createState() => _PlayerViewState();
}
class _PlayerViewState extends State<PlayerView> {
late final PageController _pageController;
final Map<int, VideoPlayerController> _controllers = {};
final Map<int, Future<VideoPlayerController?>> _initializingControllers = {};
int _currentIndex = 0;
@override
void initState() {
super.initState();
_currentIndex = widget.videos.isEmpty
? 0
: widget.initialIndex.clamp(0, widget.videos.length - 1);
_pageController = PageController(initialPage: _currentIndex);
_cachePlaybackWindow(_currentIndex);
widget.isActive ? _playActiveVideo() : _pauseAllVideos();
}
@override
void didUpdateWidget(covariant PlayerView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.videos.isEmpty) return;
if (oldWidget.initialIndex != widget.initialIndex) {
_currentIndex = widget.initialIndex.clamp(0, widget.videos.length - 1);
if (_pageController.hasClients) {
_jumpToPageAfterBuild(_currentIndex);
}
}
if (_currentIndex >= widget.videos.length) {
_currentIndex = widget.videos.length - 1;
}
_cachePlaybackWindow(_currentIndex);
widget.isActive ? _playActiveVideo() : _pauseAllVideos();
}
@override
void dispose() {
_pageController.dispose();
for (final controller in _controllers.values) {
controller
..removeListener(_handleControllerChanged)
..dispose();
}
_controllers.clear();
_initializingControllers.clear();
super.dispose();
}
Future<void> _cachePlaybackWindow(int index) async {
final keepIndexes = <int>{index};
if (index + 1 < widget.videos.length) {
keepIndexes.add(index + 1);
}
final disposeIndexes = _controllers.keys
.where((controllerIndex) => !keepIndexes.contains(controllerIndex))
.toList();
for (final controllerIndex in disposeIndexes) {
final controller = _controllers.remove(controllerIndex);
_initializingControllers.remove(controllerIndex);
controller
?..removeListener(_handleControllerChanged)
..dispose();
}
await _ensureController(index);
if (index + 1 < widget.videos.length) {
await _ensureController(index + 1);
}
}
Future<VideoPlayerController?> _ensureController(int index) async {
if (index < 0 || index >= widget.videos.length) return null;
final cachedController = _controllers[index];
if (cachedController != null) {
if (cachedController.value.isInitialized) return cachedController;
final initializingController = _initializingControllers[index];
if (initializingController != null) return initializingController;
return cachedController;
}
final videoUrl = resolveDramaVideoUrl(widget.videos[index]);
if (videoUrl == null) return null;
final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl));
_controllers[index] = controller;
controller.addListener(_handleControllerChanged);
final initializingController = _initializeController(index, controller);
_initializingControllers[index] = initializingController;
return initializingController;
}
Future<VideoPlayerController?> _initializeController(
int index,
VideoPlayerController controller,
) async {
try {
await controller.initialize();
await controller.setLooping(true);
if (!mounted || _controllers[index] != controller) return controller;
_initializingControllers.remove(index);
setState(() {});
if (index == _currentIndex &&
shouldPlayActiveVideo(
controller.value,
isPageActive: widget.isActive,
)) {
await controller.play();
}
} catch (_) {
if (_controllers[index] == controller) {
_controllers.remove(index);
}
_initializingControllers.remove(index);
controller
..removeListener(_handleControllerChanged)
..dispose();
if (mounted) setState(() {});
return null;
}
return controller;
}
Future<void> _playActiveVideo() async {
if (!widget.isActive) {
await _pauseAllVideos();
return;
}
for (final entry in _controllers.entries) {
if (entry.key != _currentIndex && entry.value.value.isPlaying) {
await entry.value.pause();
}
}
final controller = await _ensureController(_currentIndex);
if (!mounted ||
controller == null ||
_currentIndex >= widget.videos.length) {
return;
}
if (shouldPlayActiveVideo(
controller.value,
isPageActive: widget.isActive,
)) {
await controller.play();
}
}
Future<void> _pauseAllVideos() async {
for (final controller in _controllers.values) {
if (controller.value.isPlaying) {
await controller.pause();
}
}
}
void _handleControllerChanged() {
if (mounted) {
setState(() {});
}
}
void _handlePageChanged(int index) {
if (index == _currentIndex) return;
_currentIndex = index;
widget.onPageChanged?.call(index);
_cachePlaybackWindow(index);
widget.isActive ? _playActiveVideo() : _pauseAllVideos();
}
void _jumpToPageAfterBuild(int index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || widget.videos.isEmpty || !_pageController.hasClients) {
return;
}
final nextIndex = index.clamp(0, widget.videos.length - 1);
_pageController.jumpToPage(nextIndex);
});
}
void _togglePlayback() {
final controller = _controllers[_currentIndex];
if (!widget.isActive ||
controller == null ||
!controller.value.isInitialized) {
return;
}
controller.value.isPlaying ? controller.pause() : controller.play();
}
@override
Widget build(BuildContext context) {
if (widget.videos.isEmpty) {
return const SizedBox.shrink();
}
return PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
itemCount: widget.videos.length,
onPageChanged: _handlePageChanged,
itemBuilder: (context, index) {
return _PlayerPage(
drama: widget.videos[index],
index: index,
controller: _controllers[index],
isPageActive: widget.isActive,
isLoadingMore:
widget.isLoadingMore && index == widget.videos.length - 1,
bottomMetaSpacing: widget.bottomMetaSpacing,
onTap: _togglePlayback,
onCollectTap: () => widget.onCollectTap?.call(index),
progressHeaderBuilder: widget.progressHeaderBuilder,
);
},
);
}
}
class _PlayerPage extends StatelessWidget {
const _PlayerPage({
required this.drama,
required this.index,
required this.controller,
required this.isPageActive,
required this.isLoadingMore,
required this.bottomMetaSpacing,
required this.onTap,
required this.onCollectTap,
required this.progressHeaderBuilder,
});
final NyDramaModel drama;
final int index;
final VideoPlayerController? controller;
final bool isPageActive;
final bool isLoadingMore;
final double bottomMetaSpacing;
final VoidCallback onTap;
final VoidCallback onCollectTap;
final Widget Function(BuildContext context, NyDramaModel drama, int index)?
progressHeaderBuilder;
@override
Widget build(BuildContext context) {
final metaBottom = resolvePlayerMetaBottom(
safeAreaBottom: MediaQuery.paddingOf(context).bottom,
bottomSpacing: bottomMetaSpacing,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: Colors.black, child: _buildVideoBody()),
const _BottomShade(),
if (isPageActive && shouldShowPauseIcon(controller?.value))
const _PauseIndicator(),
Positioned(
left: 16,
right: 16,
bottom: metaBottom,
child: _buildMeta(context),
),
if (isLoadingMore)
Positioned(
left: 0,
right: 0,
bottom: metaBottom - 20,
child: const Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
),
),
],
),
);
}
Widget _buildVideoBody() {
final activeController = controller;
if (activeController != null && activeController.value.isInitialized) {
final size = activeController.value.size;
return FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: size.width,
height: size.height,
child: VideoPlayer(activeController),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
if (drama.imageUrl.isNotEmpty)
Image.network(
drama.imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
Center(
child: resolveDramaVideoUrl(drama) == null
? const Icon(
Icons.videocam_off_outlined,
color: Colors.white54,
size: 42,
)
: const CircularProgressIndicator(color: Colors.white),
),
],
);
}
Widget _buildMeta(BuildContext context) {
final isCollect = drama.isCollect ?? false;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText(
drama.name,
fontSize: 20,
fontWeight: FontWeight.w700,
maxLines: 1,
shadow: const Shadow(color: Colors.black54, blurRadius: 8),
),
const SizedBox(height: 8),
NovyText(
drama.description,
color: Colors.white.withValues(alpha: 0.82),
fontSize: 14,
maxLines: 2,
lineHeight: 1.3,
shadow: const Shadow(color: Colors.black54, blurRadius: 8),
),
],
),
),
const SizedBox(width: 16),
SizedBox(
width: 56,
child: IconButton(
onPressed: onCollectTap,
iconSize: 34,
color: isCollect ? ConstColor.btGreen : Colors.white,
icon: Icon(isCollect ? Icons.favorite : Icons.favorite_border),
),
),
],
),
const SizedBox(height: 16),
if (progressHeaderBuilder != null) ...[
progressHeaderBuilder!(context, drama, index),
const SizedBox(height: 8),
],
_buildProgress(),
],
);
}
Widget _buildProgress() {
final activeController = controller;
if (activeController != null && activeController.value.isInitialized) {
return VideoProgressIndicator(
activeController,
allowScrubbing: true,
padding: EdgeInsets.zero,
colors: VideoProgressColors(
playedColor: ConstColor.btGreen,
bufferedColor: Colors.white.withValues(alpha: 0.36),
backgroundColor: Colors.white.withValues(alpha: 0.18),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
minHeight: 3,
value: 0,
color: ConstColor.btGreen,
backgroundColor: Colors.white.withValues(alpha: 0.18),
),
);
}
}
class _BottomShade extends StatelessWidget {
const _BottomShade();
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.12),
Colors.black.withValues(alpha: 0.72),
],
stops: const [0.42, 0.68, 1],
),
),
),
);
}
}
class _PauseIndicator extends StatelessWidget {
const _PauseIndicator();
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Center(
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.38),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
),
child: const Icon(
Icons.play_arrow_outlined,
color: Colors.white,
size: 42,
),
),
),
);
}
}
String? resolveDramaVideoUrl(NyDramaModel drama) {
final infoUrl = drama.videoInfo?['video_url']?.toString().trim();
if (infoUrl != null && infoUrl.isNotEmpty) return infoUrl;
final videoUrl = drama.videoUrl?.trim();
if (videoUrl != null && videoUrl.isNotEmpty) return videoUrl;
return null;
}
bool shouldShowPauseIcon(VideoPlayerValue? value) {
return value != null && value.isInitialized && !value.isPlaying;
}
bool shouldPlayActiveVideo(
VideoPlayerValue? value, {
required bool isPageActive,
}) {
return isPageActive &&
value != null &&
value.isInitialized &&
!value.isPlaying;
}
double resolvePlayerMetaBottom({
required double safeAreaBottom,
required double bottomSpacing,
}) {
return safeAreaBottom + bottomSpacing;
}