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, }); final List videos; final int initialIndex; final ValueChanged? onPageChanged; final ValueChanged? onCollectTap; final bool isLoadingMore; final bool isActive; @override State createState() => _PlayerViewState(); } class _PlayerViewState extends State { late final PageController _pageController; final Map _controllers = {}; final Map> _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 (_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 _cachePlaybackWindow(int index) async { final keepIndexes = {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 _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 _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 _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 _pauseAllVideos() async { for (final controller in _controllers.values) { if (controller.value.isPlaying) { await controller.pause(); } } } void _handleControllerChanged() { if (mounted) { setState(() {}); } } void _handlePageChanged(int index) { _currentIndex = index; widget.onPageChanged?.call(index); _cachePlaybackWindow(index); widget.isActive ? _playActiveVideo() : _pauseAllVideos(); } 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], controller: _controllers[index], isPageActive: widget.isActive, isLoadingMore: widget.isLoadingMore && index == widget.videos.length - 1, onTap: _togglePlayback, onCollectTap: () => widget.onCollectTap?.call(index), ); }, ); } } class _PlayerPage extends StatelessWidget { const _PlayerPage({ required this.drama, required this.controller, required this.isPageActive, required this.isLoadingMore, required this.onTap, required this.onCollectTap, }); final NyDramaModel drama; final VideoPlayerController? controller; final bool isPageActive; final bool isLoadingMore; final VoidCallback onTap; final VoidCallback onCollectTap; @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.paddingOf(context).bottom + 86; 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: bottomPadding - 10, child: _buildMeta(), ), if (isLoadingMore) Positioned( left: 0, right: 0, bottom: bottomPadding - 30, 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() { 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), _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; }